From 719d06a991220255b1501dace245d1b4d857c300 Mon Sep 17 00:00:00 2001 From: kolaente Date: Mon, 24 Nov 2025 23:23:58 +0100 Subject: [PATCH] feat(editor): automatically save draft comments locally (#1868) Resolves https://github.com/go-vikunja/vikunja/issues/1867 --- .../src/components/input/editor/TipTap.vue | 38 +++++++++++- .../components/tasks/partials/Comments.vue | 7 +++ frontend/src/helpers/editorDraftStorage.ts | 61 +++++++++++++++++++ 3 files changed, 105 insertions(+), 1 deletion(-) create mode 100644 frontend/src/helpers/editorDraftStorage.ts diff --git a/frontend/src/components/input/editor/TipTap.vue b/frontend/src/components/input/editor/TipTap.vue index 8221ef32d..0c1e40e9d 100644 --- a/frontend/src/components/input/editor/TipTap.vue +++ b/frontend/src/components/input/editor/TipTap.vue @@ -183,6 +183,7 @@ import XButton from '@/components/input/Button.vue' import {isEditorContentEmpty} from '@/helpers/editorContentEmpty' import inputPrompt from '@/helpers/inputPrompt' import {setLinkInEditor} from '@/components/input/editor/setLinkInEditor' +import {saveEditorDraft, loadEditorDraft, clearEditorDraft} from '@/helpers/editorDraftStorage' const props = withDefaults(defineProps<{ modelValue: string, @@ -195,6 +196,7 @@ const props = withDefaults(defineProps<{ enableDiscardShortcut?: boolean, enableMentions?: boolean, mentionProjectId?: number, + storageKey?: string, }>(), { uploadCallback: undefined, isEditEnabled: true, @@ -205,6 +207,7 @@ const props = withDefaults(defineProps<{ enableDiscardShortcut: false, enableMentions: false, mentionProjectId: 0, + storageKey: '', }) const emit = defineEmits(['update:modelValue', 'save']) @@ -571,12 +574,25 @@ function bubbleNow() { } contentHasChanged.value = true - emit('update:modelValue', editor.value?.getHTML()) + const newContent = editor.value?.getHTML() + + // Save to localStorage if storageKey is provided + if (props.storageKey) { + saveEditorDraft(props.storageKey, newContent || '') + } + + emit('update:modelValue', newContent) } function bubbleSave() { bubbleNow() lastSavedState = editor.value?.getHTML() ?? '' + + // Clear draft from localStorage when saved + if (props.storageKey) { + clearEditorDraft(props.storageKey) + } + emit('save', lastSavedState) if (isEditing.value) { internalMode.value = 'preview' @@ -585,6 +601,12 @@ function bubbleSave() { function exitEditMode() { editor.value?.commands.setContent(lastSavedState, {emitUpdate: false}) + + // Clear draft from localStorage when discarding changes + if (props.storageKey) { + clearEditorDraft(props.storageKey) + } + if (isEditing.value) { internalMode.value = 'preview' } @@ -680,6 +702,20 @@ onMounted(async () => { await nextTick() + // Load draft from localStorage if available + if (props.storageKey) { + const draft = loadEditorDraft(props.storageKey) + if (draft && isEditorContentEmpty(props.modelValue)) { + // 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) + return + } + } + setModeAndValue(props.modelValue) }) diff --git a/frontend/src/components/tasks/partials/Comments.vue b/frontend/src/components/tasks/partials/Comments.vue index 519e3b260..2d25e0df4 100644 --- a/frontend/src/components/tasks/partials/Comments.vue +++ b/frontend/src/components/tasks/partials/Comments.vue @@ -172,6 +172,7 @@ :placeholder="$t('task.comment.placeholder')" :enable-mentions="true" :mention-project-id="projectId" + :storage-key="commentStorageKey" @save="addComment()" /> @@ -226,6 +227,7 @@ import type {ITask} from '@/modelTypes/ITask' import {uploadFile} from '@/helpers/attachments' import {success} from '@/message' import {formatDateLong, formatDisplayDate} from '@/helpers/time/formatDate' +import {clearEditorDraft} from '@/helpers/editorDraftStorage' import {fetchAvatarBlobUrl, getDisplayName} from '@/models/user' import type {IUser} from '@/modelTypes/IUser' import {useConfigStore} from '@/stores/config' @@ -299,6 +301,7 @@ const actions = computed(() => { }) const frontendUrl = computed(() => configStore.frontendUrl) +const commentStorageKey = computed(() => `task-comment-${props.taskId}`) const currentPage = ref(1) @@ -377,6 +380,10 @@ async function addComment() { const comment = await taskCommentService.create(newComment) comments.value.push(comment) newCommentText.value = '' + + // Ensure draft is cleared from localStorage + clearEditorDraft(commentStorageKey.value) + success({message: t('task.comment.addedSuccess')}) } finally { creating.value = false diff --git a/frontend/src/helpers/editorDraftStorage.ts b/frontend/src/helpers/editorDraftStorage.ts new file mode 100644 index 000000000..a7e5d49ba --- /dev/null +++ b/frontend/src/helpers/editorDraftStorage.ts @@ -0,0 +1,61 @@ +import {isEditorContentEmpty} from '@/helpers/editorContentEmpty' + +const STORAGE_KEY_PREFIX = 'editorDraft' + +/** + * Save editor content to local storage + */ +export function saveEditorDraft(storageKey: string, content: string) { + if (!storageKey) { + return + } + + const key = `${STORAGE_KEY_PREFIX}-${storageKey}` + + try { + if (!content || isEditorContentEmpty(content)) { + // Remove empty drafts + localStorage.removeItem(key) + return + } + + localStorage.setItem(key, content) + } catch (error) { + console.warn('Failed to save editor draft:', error) + } +} + +/** + * Load editor content from local storage + */ +export function loadEditorDraft(storageKey: string): string | null { + if (!storageKey) { + return null + } + + const key = `${STORAGE_KEY_PREFIX}-${storageKey}` + + try { + return localStorage.getItem(key) + } catch (error) { + console.warn('Failed to load editor draft:', error) + return null + } +} + +/** + * Clear editor content from local storage + */ +export function clearEditorDraft(storageKey: string) { + if (!storageKey) { + return + } + + const key = `${STORAGE_KEY_PREFIX}-${storageKey}` + + try { + localStorage.removeItem(key) + } catch (error) { + console.warn('Failed to clear editor draft:', error) + } +}