feat(editor): add hotkeys to quickly edit and discard (#2265)
Reviewed-on: https://kolaente.dev/vikunja/vikunja/pulls/2265 Reviewed-by: konrad <k@knt.li>
This commit is contained in:
commit
fd66e6875c
@ -67,6 +67,7 @@
|
|||||||
class="tiptap__editor"
|
class="tiptap__editor"
|
||||||
:class="{'tiptap__editor-is-edit-enabled': isEditing}"
|
:class="{'tiptap__editor-is-edit-enabled': isEditing}"
|
||||||
:editor="editor"
|
:editor="editor"
|
||||||
|
@dblclick="setEditIfApplicable()"
|
||||||
@click="focusIfEditing()"
|
@click="focusIfEditing()"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@ -171,7 +172,7 @@ import {OrderedList} from '@tiptap/extension-ordered-list'
|
|||||||
import {Paragraph} from '@tiptap/extension-paragraph'
|
import {Paragraph} from '@tiptap/extension-paragraph'
|
||||||
import {Strike} from '@tiptap/extension-strike'
|
import {Strike} from '@tiptap/extension-strike'
|
||||||
import {Text} from '@tiptap/extension-text'
|
import {Text} from '@tiptap/extension-text'
|
||||||
import {BubbleMenu, EditorContent, useEditor} from '@tiptap/vue-3'
|
import {BubbleMenu, EditorContent, type Extensions, useEditor} from '@tiptap/vue-3'
|
||||||
import {Node} from '@tiptap/pm/model'
|
import {Node} from '@tiptap/pm/model'
|
||||||
|
|
||||||
import Commands from './commands'
|
import Commands from './commands'
|
||||||
@ -189,7 +190,7 @@ import BaseButton from '@/components/base/BaseButton.vue'
|
|||||||
import XButton from '@/components/input/button.vue'
|
import XButton from '@/components/input/button.vue'
|
||||||
import {Placeholder} from '@tiptap/extension-placeholder'
|
import {Placeholder} from '@tiptap/extension-placeholder'
|
||||||
import {eventToHotkeyString} from '@github/hotkey'
|
import {eventToHotkeyString} from '@github/hotkey'
|
||||||
import {mergeAttributes} from '@tiptap/core'
|
import {Extension, mergeAttributes} from '@tiptap/core'
|
||||||
import {isEditorContentEmpty} from '@/helpers/editorContentEmpty'
|
import {isEditorContentEmpty} from '@/helpers/editorContentEmpty'
|
||||||
import inputPrompt from '@/helpers/inputPrompt'
|
import inputPrompt from '@/helpers/inputPrompt'
|
||||||
import {setLinkInEditor} from '@/components/input/editor/setLinkInEditor'
|
import {setLinkInEditor} from '@/components/input/editor/setLinkInEditor'
|
||||||
@ -202,6 +203,7 @@ const {
|
|||||||
showSave = false,
|
showSave = false,
|
||||||
placeholder = '',
|
placeholder = '',
|
||||||
editShortcut = '',
|
editShortcut = '',
|
||||||
|
enableDiscardShortcut = false,
|
||||||
} = defineProps<{
|
} = defineProps<{
|
||||||
modelValue: string,
|
modelValue: string,
|
||||||
uploadCallback?: UploadCallback,
|
uploadCallback?: UploadCallback,
|
||||||
@ -210,6 +212,7 @@ const {
|
|||||||
showSave?: boolean,
|
showSave?: boolean,
|
||||||
placeholder?: string,
|
placeholder?: string,
|
||||||
editShortcut?: string,
|
editShortcut?: string,
|
||||||
|
enableDiscardShortcut?: boolean,
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits(['update:modelValue', 'save'])
|
const emit = defineEmits(['update:modelValue', 'save'])
|
||||||
@ -311,6 +314,8 @@ const internalMode = ref<Mode>('preview')
|
|||||||
const isEditing = computed(() => internalMode.value === 'edit' && isEditEnabled)
|
const isEditing = computed(() => internalMode.value === 'edit' && isEditEnabled)
|
||||||
const contentHasChanged = ref<boolean>(false)
|
const contentHasChanged = ref<boolean>(false)
|
||||||
|
|
||||||
|
let lastSavedState = modelValue
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => internalMode.value,
|
() => internalMode.value,
|
||||||
mode => {
|
mode => {
|
||||||
@ -320,109 +325,127 @@ watch(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const extensions : Extensions = [
|
||||||
|
// Starterkit:
|
||||||
|
Blockquote,
|
||||||
|
Bold,
|
||||||
|
BulletList,
|
||||||
|
Code,
|
||||||
|
CodeBlockLowlight.configure({
|
||||||
|
lowlight,
|
||||||
|
}),
|
||||||
|
Document,
|
||||||
|
Dropcursor,
|
||||||
|
Gapcursor,
|
||||||
|
HardBreak.extend({
|
||||||
|
addKeyboardShortcuts() {
|
||||||
|
return {
|
||||||
|
'Shift-Enter': () => this.editor.commands.setHardBreak(),
|
||||||
|
'Mod-Enter': () => {
|
||||||
|
if (contentHasChanged.value) {
|
||||||
|
bubbleSave()
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
Heading,
|
||||||
|
History,
|
||||||
|
HorizontalRule,
|
||||||
|
Italic,
|
||||||
|
ListItem,
|
||||||
|
OrderedList,
|
||||||
|
Paragraph,
|
||||||
|
Strike,
|
||||||
|
Text,
|
||||||
|
|
||||||
|
Placeholder.configure({
|
||||||
|
placeholder: ({editor}) => {
|
||||||
|
if (!isEditing.value) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editor.getText() !== '' && !editor.isFocused) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
return placeholder !== ''
|
||||||
|
? placeholder
|
||||||
|
: t('input.editor.placeholder')
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
Typography,
|
||||||
|
Underline,
|
||||||
|
Link.configure({
|
||||||
|
openOnClick: false,
|
||||||
|
validate: (href: string) => /^https?:\/\//.test(href),
|
||||||
|
}),
|
||||||
|
Table.configure({
|
||||||
|
resizable: true,
|
||||||
|
}),
|
||||||
|
TableRow,
|
||||||
|
TableHeader,
|
||||||
|
// Custom TableCell with backgroundColor attribute
|
||||||
|
CustomTableCell,
|
||||||
|
|
||||||
|
CustomImage,
|
||||||
|
|
||||||
|
TaskList,
|
||||||
|
TaskItem.configure({
|
||||||
|
nested: true,
|
||||||
|
onReadOnlyChecked: (node: Node, checked: boolean): boolean => {
|
||||||
|
if (!isEditEnabled) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// The following is a workaround for this bug:
|
||||||
|
// https://github.com/ueberdosis/tiptap/issues/4521
|
||||||
|
// https://github.com/ueberdosis/tiptap/issues/3676
|
||||||
|
|
||||||
|
editor.value!.state.doc.descendants((subnode, pos) => {
|
||||||
|
if (node.eq(subnode)) {
|
||||||
|
const {tr} = editor.value!.state
|
||||||
|
tr.setNodeMarkup(pos, undefined, {
|
||||||
|
...node.attrs,
|
||||||
|
checked,
|
||||||
|
})
|
||||||
|
editor.value!.view.dispatch(tr)
|
||||||
|
bubbleSave()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
Commands.configure({
|
||||||
|
suggestion: suggestionSetup(t),
|
||||||
|
}),
|
||||||
|
BubbleMenu,
|
||||||
|
]
|
||||||
|
|
||||||
|
// Add a custom extension for the Escape key
|
||||||
|
if (enableDiscardShortcut) {
|
||||||
|
extensions.push(Extension.create({
|
||||||
|
name: 'escapeKey',
|
||||||
|
|
||||||
|
addKeyboardShortcuts() {
|
||||||
|
return {
|
||||||
|
'Escape': () => {
|
||||||
|
exitEditMode()
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
const editor = useEditor({
|
const editor = useEditor({
|
||||||
// eslint-disable-next-line vue/no-ref-object-destructure
|
// eslint-disable-next-line vue/no-ref-object-destructure
|
||||||
editable: isEditing.value,
|
editable: isEditing.value,
|
||||||
extensions: [
|
extensions: extensions,
|
||||||
// Starterkit:
|
|
||||||
Blockquote,
|
|
||||||
Bold,
|
|
||||||
BulletList,
|
|
||||||
Code,
|
|
||||||
CodeBlockLowlight.configure({
|
|
||||||
lowlight,
|
|
||||||
}),
|
|
||||||
Document,
|
|
||||||
Dropcursor,
|
|
||||||
Gapcursor,
|
|
||||||
HardBreak.extend({
|
|
||||||
addKeyboardShortcuts() {
|
|
||||||
return {
|
|
||||||
'Shift-Enter': () => this.editor.commands.setHardBreak(),
|
|
||||||
'Mod-Enter': () => {
|
|
||||||
if (contentHasChanged.value) {
|
|
||||||
bubbleSave()
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
Heading,
|
|
||||||
History,
|
|
||||||
HorizontalRule,
|
|
||||||
Italic,
|
|
||||||
ListItem,
|
|
||||||
OrderedList,
|
|
||||||
Paragraph,
|
|
||||||
Strike,
|
|
||||||
Text,
|
|
||||||
|
|
||||||
Placeholder.configure({
|
|
||||||
placeholder: ({editor}) => {
|
|
||||||
if (!isEditing.value) {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
|
|
||||||
if (editor.getText() !== '' && !editor.isFocused) {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
|
|
||||||
return placeholder !== ''
|
|
||||||
? placeholder
|
|
||||||
: t('input.editor.placeholder')
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
Typography,
|
|
||||||
Underline,
|
|
||||||
Link.configure({
|
|
||||||
openOnClick: false,
|
|
||||||
validate: (href: string) => /^https?:\/\//.test(href),
|
|
||||||
}),
|
|
||||||
Table.configure({
|
|
||||||
resizable: true,
|
|
||||||
}),
|
|
||||||
TableRow,
|
|
||||||
TableHeader,
|
|
||||||
// Custom TableCell with backgroundColor attribute
|
|
||||||
CustomTableCell,
|
|
||||||
|
|
||||||
CustomImage,
|
|
||||||
|
|
||||||
TaskList,
|
|
||||||
TaskItem.configure({
|
|
||||||
nested: true,
|
|
||||||
onReadOnlyChecked: (node: Node, checked: boolean): boolean => {
|
|
||||||
if (!isEditEnabled) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// The following is a workaround for this bug:
|
|
||||||
// https://github.com/ueberdosis/tiptap/issues/4521
|
|
||||||
// https://github.com/ueberdosis/tiptap/issues/3676
|
|
||||||
|
|
||||||
editor.value!.state.doc.descendants((subnode, pos) => {
|
|
||||||
if (node.eq(subnode)) {
|
|
||||||
const {tr} = editor.value!.state
|
|
||||||
tr.setNodeMarkup(pos, undefined, {
|
|
||||||
...node.attrs,
|
|
||||||
checked,
|
|
||||||
})
|
|
||||||
editor.value!.view.dispatch(tr)
|
|
||||||
bubbleSave()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
return true
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
|
|
||||||
Commands.configure({
|
|
||||||
suggestion: suggestionSetup(t),
|
|
||||||
}),
|
|
||||||
BubbleMenu,
|
|
||||||
],
|
|
||||||
onUpdate: () => {
|
onUpdate: () => {
|
||||||
bubbleNow()
|
bubbleNow()
|
||||||
},
|
},
|
||||||
@ -461,12 +484,27 @@ function bubbleNow() {
|
|||||||
|
|
||||||
function bubbleSave() {
|
function bubbleSave() {
|
||||||
bubbleNow()
|
bubbleNow()
|
||||||
emit('save', editor.value?.getHTML())
|
lastSavedState = editor.value?.getHTML() ?? ''
|
||||||
|
emit('save', lastSavedState)
|
||||||
if (isEditing.value) {
|
if (isEditing.value) {
|
||||||
internalMode.value = 'preview'
|
internalMode.value = 'preview'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function exitEditMode() {
|
||||||
|
editor.value?.commands.setContent(lastSavedState, false)
|
||||||
|
if (isEditing.value) {
|
||||||
|
internalMode.value = 'preview'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setEditIfApplicable() {
|
||||||
|
if (!isEditEnabled) return
|
||||||
|
if (isEditing.value) return
|
||||||
|
|
||||||
|
setEdit()
|
||||||
|
}
|
||||||
|
|
||||||
function setEdit(focus: boolean = true) {
|
function setEdit(focus: boolean = true) {
|
||||||
internalMode.value = 'edit'
|
internalMode.value = 'edit'
|
||||||
if (focus) {
|
if (focus) {
|
||||||
|
@ -85,6 +85,7 @@
|
|||||||
:upload-enabled="true"
|
:upload-enabled="true"
|
||||||
:bottom-actions="actions[c.id]"
|
:bottom-actions="actions[c.id]"
|
||||||
:show-save="true"
|
:show-save="true"
|
||||||
|
:enable-discard-shortcut="true"
|
||||||
initial-mode="preview"
|
initial-mode="preview"
|
||||||
@update:modelValue="
|
@update:modelValue="
|
||||||
() => {
|
() => {
|
||||||
|
@ -30,6 +30,7 @@
|
|||||||
:placeholder="$t('task.description.placeholder')"
|
:placeholder="$t('task.description.placeholder')"
|
||||||
:show-save="true"
|
:show-save="true"
|
||||||
edit-shortcut="e"
|
edit-shortcut="e"
|
||||||
|
:enable-discard-shortcut="true"
|
||||||
@update:modelValue="saveWithDelay"
|
@update:modelValue="saveWithDelay"
|
||||||
@save="save"
|
@save="save"
|
||||||
/>
|
/>
|
||||||
|
Loading…
x
Reference in New Issue
Block a user