From f8f14eea0fe8ddaac72778c6f38338bed4e0a19a Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 8 Apr 2026 13:30:26 -0400 Subject: [PATCH] feat(editor): place form-first layout and list builder with Enter-to-add Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/views/NoteEditorView.vue | 264 ++++++++++++++++++++++++-- 1 file changed, 245 insertions(+), 19 deletions(-) diff --git a/frontend/src/views/NoteEditorView.vue b/frontend/src/views/NoteEditorView.vue index 862cbbc..524f887 100644 --- a/frontend/src/views/NoteEditorView.vue +++ b/frontend/src/views/NoteEditorView.vue @@ -39,6 +39,80 @@ const saving = ref(false); const showPreview = ref(false); const sidebarOpen = ref(true); const notesExpanded = ref(false); + +// ── List builder ───────────────────────────────────────────────────────────── +interface ListItem { + text: string; + checked: boolean; +} +const listItems = ref([]); +const listItemRefs = ref<(HTMLInputElement | null)[]>([]); + +function parseListFromBody(bodyText: string): { items: ListItem[]; extra: string } { + const lines = bodyText.split('\n'); + const items: ListItem[] = []; + const extraLines: string[] = []; + let pastList = false; + for (const line of lines) { + const stripped = line.trimStart(); + if (!pastList && (stripped.startsWith('- [ ] ') || stripped.startsWith('- [x] ') || stripped.startsWith('- [X] '))) { + items.push({ text: stripped.slice(6), checked: !stripped.startsWith('- [ ] ') }); + } else if (!pastList && stripped === '' && items.length > 0) { + pastList = true; + } else { + pastList = true; + extraLines.push(line); + } + } + return { items, extra: extraLines.join('\n').trim() }; +} + +function serializeListToBody(): string { + const listPart = listItems.value + .map(item => `- [${item.checked ? 'x' : ' '}] ${item.text}`) + .join('\n'); + const extraPart = body.value.trim(); + return extraPart ? `${listPart}\n\n${extraPart}` : listPart; +} + +function addListItem(afterIndex?: number) { + const idx = afterIndex !== undefined ? afterIndex + 1 : listItems.value.length; + listItems.value.splice(idx, 0, { text: '', checked: false }); + markDirty(); + nextTick(() => { + listItemRefs.value[idx]?.focus(); + }); +} + +function removeListItem(index: number) { + if (listItems.value.length <= 1) return; + listItems.value.splice(index, 1); + markDirty(); + nextTick(() => { + const focusIdx = Math.max(0, index - 1); + listItemRefs.value[focusIdx]?.focus(); + }); +} + +function onListItemKeydown(e: KeyboardEvent, index: number) { + if (e.key === 'Enter') { + e.preventDefault(); + addListItem(index); + } else if (e.key === 'Backspace' && listItems.value[index].text === '') { + e.preventDefault(); + removeListItem(index); + } +} + +function onListItemInput(_index: number) { + markDirty(); +} + +function toggleListItemCheck(index: number) { + listItems.value[index].checked = !listItems.value[index].checked; + markDirty(); +} + const editorRef = ref | null>(null); const titleRef = ref(null); const tiptapEditor = computed(() => { @@ -230,6 +304,12 @@ onMounted(async () => { noteType.value = (store.currentNote.note_type as NoteType) || "note"; Object.assign(entityMeta, store.currentNote.metadata || {}); notesExpanded.value = !!(store.currentNote.body || '').trim(); + if (noteType.value === 'list') { + const parsed = parseListFromBody(body.value); + listItems.value = parsed.items.length > 0 ? parsed.items : [{ text: '', checked: false }]; + body.value = parsed.extra; + notesExpanded.value = !!parsed.extra; + } savedTitle = title.value; savedBody = body.value; savedTags = [...tags.value]; @@ -244,6 +324,9 @@ onMounted(async () => { if (qt && ["note", "person", "place", "list"].includes(qt)) { noteType.value = qt as NoteType; } + if (noteType.value === 'list') { + listItems.value = [{ text: '', checked: false }]; + } } // Restore pending draft if any @@ -264,11 +347,12 @@ onMounted(async () => { async function save() { if (saving.value) return; saving.value = true; + const finalBody = noteType.value === 'list' ? serializeListToBody() : body.value; try { if (isEditing.value) { await store.updateNote(noteId.value!, { title: title.value, - body: body.value, + body: finalBody, tags: tags.value, project_id: projectId.value, milestone_id: milestoneId.value, @@ -287,7 +371,7 @@ async function save() { } else { const note = await store.createNote({ title: title.value, - body: body.value, + body: finalBody, tags: tags.value, project_id: projectId.value, milestone_id: milestoneId.value, @@ -328,9 +412,10 @@ async function confirmDelete() { async function doAutoSave() { if (!isEditing.value || saving.value) return; saving.value = true; + const finalBody = noteType.value === 'list' ? serializeListToBody() : body.value; try { await store.updateNote(noteId.value!, { - title: title.value, body: body.value, tags: tags.value, + title: title.value, body: finalBody, tags: tags.value, project_id: projectId.value, milestone_id: milestoneId.value, note_type: noteType.value, metadata: { ...entityMeta }, }); @@ -429,6 +514,91 @@ onUnmounted(() => assist.clearSelection()); + + + + + +