Compare commits

...

1 Commits

Author SHA1 Message Date
Dominik Pschenitschni 81896dbbe7 feat: mini tiptap improvements
The TipTap editor now uses Vue’s defineModel for its reactive value, simplifies editability control with watchEffect, and correctly types DOM image elements.

Summary

Imported watchEffect and switched to defineModel for the editor’s v-model, removing the old update:modelValue emit

Typed the retrieved image element as HTMLImageElement and removed debugging output from the paste handler

Replaced the watcher controlling editability with a simpler watchEffect and updated model handling to use the new model ref
2026-03-03 11:38:31 +01:00
1 changed files with 27 additions and 43 deletions

View File

@ -142,7 +142,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import {computed, nextTick, onBeforeUnmount, onMounted, ref, watch} from 'vue' import {computed, nextTick, onBeforeUnmount, onMounted, ref, watch, watchEffect} from 'vue'
import {useI18n} from 'vue-i18n' import {useI18n} from 'vue-i18n'
import {eventToHotkeyString} from '@github/hotkey' import {eventToHotkeyString} from '@github/hotkey'
@ -168,8 +168,6 @@ import {TaskList} from '@tiptap/extension-list'
import {TaskItemWithId} from './taskItemWithId' import {TaskItemWithId} from './taskItemWithId'
import HardBreak from '@tiptap/extension-hard-break' import HardBreak from '@tiptap/extension-hard-break'
import {Node} from '@tiptap/pm/model'
import Commands from './commands' import Commands from './commands'
import suggestionSetup from './suggestion' import suggestionSetup from './suggestion'
import mentionSuggestionSetup from './mention/mentionSuggestion' import mentionSuggestionSetup from './mention/mentionSuggestion'
@ -191,7 +189,6 @@ import {setLinkInEditor} from '@/components/input/editor/setLinkInEditor'
import {saveEditorDraft, loadEditorDraft, clearEditorDraft} from '@/helpers/editorDraftStorage' import {saveEditorDraft, loadEditorDraft, clearEditorDraft} from '@/helpers/editorDraftStorage'
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
modelValue: string,
uploadCallback?: UploadCallback, uploadCallback?: UploadCallback,
isEditEnabled?: boolean, isEditEnabled?: boolean,
bottomActions?: BottomAction[], bottomActions?: BottomAction[],
@ -215,7 +212,9 @@ const props = withDefaults(defineProps<{
storageKey: '', storageKey: '',
}) })
const emit = defineEmits(['update:modelValue', 'save']) const emit = defineEmits(['save'])
const modelValue = defineModel<string>({ default: '' })
const tiptapInstanceRef = ref<HTMLInputElement | null>(null) const tiptapInstanceRef = ref<HTMLInputElement | null>(null)
@ -286,7 +285,7 @@ const CustomImage = Image.extend({
nextTick(async () => { nextTick(async () => {
const img = document.getElementById(id) const img = document.getElementById(id) as HTMLImageElement | null
if (!img || !(img instanceof HTMLImageElement)) return if (!img || !(img instanceof HTMLImageElement)) return
@ -334,7 +333,7 @@ const UPLOAD_PLACEHOLDER_ELEMENT = '<p>UPLOAD_PLACEHOLDER</p>'
let lastSavedState = '' let lastSavedState = ''
watch( watch(
() => props.modelValue, modelValue,
(newValue) => { (newValue) => {
if (!contentHasChanged.value) { if (!contentHasChanged.value) {
lastSavedState = newValue lastSavedState = newValue
@ -344,7 +343,7 @@ watch(
) )
watch( watch(
() => internalMode.value, internalMode,
mode => { mode => {
if (mode === 'preview') { if (mode === 'preview') {
contentHasChanged.value = false contentHasChanged.value = false
@ -371,11 +370,10 @@ const PasteHandler = Extension.create({
handlePaste: (view, event) => { handlePaste: (view, event) => {
// Handle images pasted from clipboard // Handle images pasted from clipboard
if (typeof props.uploadCallback !== 'undefined' && event.clipboardData?.items && event.clipboardData.items.length > 0) { if (typeof props.uploadCallback !== 'undefined' && event.clipboardData?.items?.length) {
for (let i = 0; i < event.clipboardData.items.length; i++) { for (const item of event.clipboardData.items) {
const item = event.clipboardData.items[i] if (item.kind === 'file' && item.type.startsWith('image/')) {
if (item && item.kind === 'file' && item.type.startsWith('image/')) {
const file = item.getAsFile() const file = item.getAsFile()
if (file) { if (file) {
uploadAndInsertFiles([file]) uploadAndInsertFiles([file])
@ -438,25 +436,19 @@ const extensions : Extensions = [
}), }),
Placeholder.configure({ Placeholder.configure({
placeholder: ({editor}) => { placeholder({editor}) {
if (!isEditing.value) { if (!isEditing.value || editor.getText() !== '' && !editor.isFocused) {
return '' return ''
} }
if (editor.getText() !== '' && !editor.isFocused) { return props.placeholder || t('input.editor.placeholder')
return ''
}
return props.placeholder !== ''
? props.placeholder
: t('input.editor.placeholder')
}, },
}), }),
Typography, Typography,
Underline, Underline,
NonInclusiveLink.configure({ NonInclusiveLink.configure({
openOnClick: false, openOnClick: false,
validate: (href: string) => (new RegExp( validate: (href) => (new RegExp(
`^(https?|${additionalLinkProtocols.join('|')}):\\/\\/`, `^(https?|${additionalLinkProtocols.join('|')}):\\/\\/`,
'i', 'i',
)).test(href), )).test(href),
@ -475,7 +467,7 @@ const extensions : Extensions = [
TaskList, TaskList,
TaskItemWithId.configure({ TaskItemWithId.configure({
nested: true, nested: true,
onReadOnlyChecked: (node: Node, checked: boolean): boolean => { onReadOnlyChecked(node, checked) {
if (!props.isEditEnabled) { if (!props.isEditEnabled) {
return false return false
} }
@ -575,24 +567,16 @@ const editor = useEditor({
// eslint-disable-next-line vue/no-ref-object-reactivity-loss // eslint-disable-next-line vue/no-ref-object-reactivity-loss
editable: isEditing.value, editable: isEditing.value,
extensions: extensions, extensions: extensions,
onUpdate: () => { onUpdate: bubbleNow,
bubbleNow()
},
parseOptions: { parseOptions: {
preserveWhitespace: true, preserveWhitespace: true,
}, },
}) })
watch( watchEffect(() => editor.value?.setEditable(isEditing.value))
() => isEditing.value,
() => {
editor.value?.setEditable(isEditing.value)
},
{immediate: true},
)
watch( watch(
() => props.modelValue, modelValue,
value => { value => {
if (!editor?.value) return if (!editor?.value) return
@ -606,20 +590,20 @@ watch(
) )
function bubbleNow() { function bubbleNow() {
if (editor.value?.getHTML() === props.modelValue || const editorVal = editor.value!.getHTML()
(editor.value?.getHTML() === '<p></p>') && props.modelValue === '') { if (editorVal === modelValue.value ||
(editorVal === '<p></p>') && modelValue.value === '') {
return return
} }
contentHasChanged.value = true contentHasChanged.value = true
const newContent = editor.value?.getHTML()
// Save to localStorage if storageKey is provided // Save to localStorage if storageKey is provided
if (props.storageKey) { if (props.storageKey) {
saveEditorDraft(props.storageKey, newContent || '') saveEditorDraft(props.storageKey, editorVal)
} }
emit('update:modelValue', newContent) modelValue.value = editorVal
} }
function bubbleSave() { function bubbleSave() {
@ -760,18 +744,18 @@ onMounted(async () => {
// Load draft from localStorage if available // Load draft from localStorage if available
if (props.storageKey) { if (props.storageKey) {
const draft = loadEditorDraft(props.storageKey) const draft = loadEditorDraft(props.storageKey)
if (draft && isEditorContentEmpty(props.modelValue)) { if (draft && isEditorContentEmpty(modelValue.value)) {
// Only load draft if current content is empty // Only load draft if current content is empty
// Set content and force edit mode for immediate editing // Set content and force edit mode for immediate editing
editor.value?.commands.setContent(draft, {emitUpdate: false}) editor.value?.commands.setContent(draft, {emitUpdate: false})
internalMode.value = 'edit' internalMode.value = 'edit'
// Emit the model update so parent sees the restored content // Update the model so parent sees the restored content
emit('update:modelValue', draft) modelValue.value = draft
return return
} }
} }
setModeAndValue(props.modelValue) setModeAndValue(modelValue.value)
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {