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