Frontend: Description Edit Hotkeys #2265

Open
Elscrux wants to merge 5 commits from Elscrux/vikunja:feature/description-edit-hotkeys into main
3 changed files with 143 additions and 103 deletions

View File

@ -67,6 +67,7 @@
class="tiptap__editor"
:class="{'tiptap__editor-is-edit-enabled': isEditing}"
:editor="editor"
@dblclick="setEditIfApplicable()"
Elscrux marked this conversation as resolved Outdated

Where is this event defined?

Where is this event defined?

It seems to be a native event, just like @click

It seems to be a native event, just like @click
@click="focusIfEditing()"
/>
@ -171,7 +172,7 @@ import {OrderedList} from '@tiptap/extension-ordered-list'
import {Paragraph} from '@tiptap/extension-paragraph'
import {Strike} from '@tiptap/extension-strike'
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 Commands from './commands'
@ -189,7 +190,7 @@ import BaseButton from '@/components/base/BaseButton.vue'
import XButton from '@/components/input/button.vue'
import {Placeholder} from '@tiptap/extension-placeholder'
import {eventToHotkeyString} from '@github/hotkey'
import {mergeAttributes} from '@tiptap/core'
import {Extension, mergeAttributes} from '@tiptap/core'
import {isEditorContentEmpty} from '@/helpers/editorContentEmpty'
import inputPrompt from '@/helpers/inputPrompt'
import {setLinkInEditor} from '@/components/input/editor/setLinkInEditor'
@ -202,6 +203,7 @@ const {
showSave = false,
placeholder = '',
editShortcut = '',
discardShortcutEnabled = false,
} = defineProps<{
modelValue: string,
uploadCallback?: UploadCallback,
@ -210,6 +212,7 @@ const {
showSave?: boolean,
placeholder?: string,
editShortcut?: string,
discardShortcutEnabled?: boolean,
}>()
const emit = defineEmits(['update:modelValue', 'save'])
@ -311,6 +314,8 @@ const internalMode = ref<Mode>('preview')
const isEditing = computed(() => internalMode.value === 'edit' && isEditEnabled)
const contentHasChanged = ref<boolean>(false)
let lastSavedState = modelValue
watch(
() => internalMode.value,
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 (discardShortcutEnabled) {
extensions.push(Extension.create({
name: 'escapeKey',
addKeyboardShortcuts() {

Isn't the extra mention of discardShortcutEnabled in the line before redundant?

Isn't the extra mention of `discardShortcutEnabled` in the line before redundant?

Yes discardShortcutEnabled in line 433 is needed, this is part of the ternary operator to include this Extension optionally. Not super fancy but I didn't see a much better option. Let me know if you know a better way.

Yes `discardShortcutEnabled ` in line 433 is needed, this is part of the ternary operator to include this Extension optionally. Not super fancy but I didn't see a much better option. Let me know if you know a better way.

I feel like this is kinda unreadable. Can you rewrite it so that it uses a regular if?

I feel like this is kinda unreadable. Can you rewrite it so that it uses a regular `if`?

There was no pretty way to do it, but I've changed it now, I hope it works for you!

There was no pretty way to do it, but I've changed it now, I hope it works for you!

Looks good, thanks!

Looks good, thanks!
return {
'Escape': () => {
exitEditMode()
return true
},
}
},
}))
}
const editor = useEditor({
// eslint-disable-next-line vue/no-ref-object-destructure
editable: isEditing.value,
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,
],
extensions: extensions,
onUpdate: () => {
bubbleNow()
},
@ -461,12 +484,27 @@ function bubbleNow() {
function bubbleSave() {
bubbleNow()
emit('save', editor.value?.getHTML())
lastSavedState = editor.value?.getHTML() ?? ''

How does this behave when editing a project description? There the editor is not used as a rendering engine, so it should not change the mode to preview.

How does this behave when editing a project description? There the editor is not used as a rendering engine, so it should not change the mode to preview.

Currently it changes it back to the preview mode, which is not what it should be.
I'll add an opt-in feature for this, or what do you think should be done for this?

Currently it changes it back to the preview mode, which is not what it should be. I'll add an opt-in feature for this, or what do you think should be done for this?

Iirc there are props already controlling if it will allow rendering or editing, maybe use those? (Not on my pc right now, so I'm unable to check this in detail)

Iirc there are props already controlling if it will allow rendering or editing, maybe use those? (Not on my pc right now, so I'm unable to check this in detail)

There is isEditEnabled, but it is enabled by default for something like editing the project description as well.
I see that showSave is only used by the two editors, for comments and task description which should get this feature. So functionally the discard feature could be baked into this, but I'm not sure if that makes a lot of sense.

There is `isEditEnabled`, but it is enabled by default for something like editing the project description as well. I see that `showSave` is only used by the two editors, for comments and task description which should get this feature. So functionally the discard feature could be baked into this, but I'm not sure if that makes a lot of sense.

Good point. Let's add a new prop previewAfterExit or something similar and then refactor it later.

Good point. Let's add a new prop `previewAfterExit` or something similar and then refactor it later.
emit('save', lastSavedState)
if (isEditing.value) {
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) {
internalMode.value = 'edit'
if (focus) {

View File

@ -85,6 +85,7 @@
:upload-enabled="true"
:bottom-actions="actions[c.id]"
:show-save="true"
:discard-shortcut-enabled="true"
initial-mode="preview"
@update:modelValue="
() => {

View File

@ -30,6 +30,7 @@
:placeholder="$t('task.description.placeholder')"
:show-save="true"
edit-shortcut="e"
:discard-shortcut-enabled="true"
@update:modelValue="saveWithDelay"
@save="save"
/>