feat(editor): place form-first layout and list builder with Enter-to-add
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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<ListItem[]>([]);
|
||||
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<InstanceType<typeof TiptapEditor> | null>(null);
|
||||
const titleRef = ref<HTMLInputElement | null>(null);
|
||||
const tiptapEditor = computed<Editor | null>(() => {
|
||||
@@ -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());
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ── Place form ───────────────────────────────────────── -->
|
||||
<template v-else-if="noteType === 'place'">
|
||||
<div class="entity-form">
|
||||
<div class="ef-field">
|
||||
<label class="ef-label">Address</label>
|
||||
<input class="ef-input" v-model="entityMeta.address" placeholder="Street, City, State" @input="markDirty" />
|
||||
</div>
|
||||
<div class="ef-field">
|
||||
<label class="ef-label">Phone</label>
|
||||
<input class="ef-input" type="tel" v-model="entityMeta.phone" placeholder="+1 555 000 0000" @input="markDirty" />
|
||||
</div>
|
||||
<div class="ef-field">
|
||||
<label class="ef-label">Hours</label>
|
||||
<input class="ef-input" v-model="entityMeta.hours" placeholder="e.g. Mon–Fri 9am–5pm" @input="markDirty" />
|
||||
</div>
|
||||
<div class="ef-field">
|
||||
<label class="ef-label">Website</label>
|
||||
<input class="ef-input" type="url" v-model="entityMeta.website" placeholder="https://..." @input="markDirty" />
|
||||
</div>
|
||||
<div class="ef-field">
|
||||
<label class="ef-label">Category</label>
|
||||
<input class="ef-input" v-model="entityMeta.category" placeholder="e.g. Restaurant, Office, Doctor" @input="markDirty" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="notes-section">
|
||||
<button class="notes-toggle" @click="notesExpanded = !notesExpanded">
|
||||
{{ notesExpanded ? '▾' : '▸' }} Notes
|
||||
</button>
|
||||
<div v-if="notesExpanded" class="notes-editor-wrap">
|
||||
<MarkdownToolbar v-show="!showPreview" :editor="tiptapEditor" />
|
||||
<TiptapEditor
|
||||
ref="editorRef"
|
||||
:modelValue="body"
|
||||
placeholder="Additional notes, wikilinks, context..."
|
||||
@update:modelValue="onBodyUpdate"
|
||||
@escape="titleRef?.focus()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ── List builder ─────────────────────────────────────── -->
|
||||
<template v-else-if="noteType === 'list'">
|
||||
<div class="list-builder">
|
||||
<div
|
||||
v-for="(item, idx) in listItems"
|
||||
:key="idx"
|
||||
class="lb-item"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="item.checked"
|
||||
@change="toggleListItemCheck(idx)"
|
||||
class="lb-check"
|
||||
tabindex="-1"
|
||||
/>
|
||||
<input
|
||||
:ref="(el) => { listItemRefs[idx] = el as HTMLInputElement | null }"
|
||||
v-model="item.text"
|
||||
class="lb-text"
|
||||
placeholder="List item..."
|
||||
@keydown="onListItemKeydown($event, idx)"
|
||||
@input="onListItemInput(idx)"
|
||||
/>
|
||||
<button class="lb-delete" tabindex="-1" @click="removeListItem(idx)" title="Remove item">×</button>
|
||||
</div>
|
||||
<button class="lb-add" @click="addListItem()">+ Add item</button>
|
||||
</div>
|
||||
<div class="notes-section">
|
||||
<button class="notes-toggle" @click="notesExpanded = !notesExpanded">
|
||||
{{ notesExpanded ? '▾' : '▸' }} Notes
|
||||
</button>
|
||||
<div v-if="notesExpanded" class="notes-editor-wrap">
|
||||
<MarkdownToolbar v-show="!showPreview" :editor="tiptapEditor" />
|
||||
<TiptapEditor
|
||||
ref="editorRef"
|
||||
:modelValue="body"
|
||||
placeholder="Additional notes, context..."
|
||||
@update:modelValue="onBodyUpdate"
|
||||
@escape="titleRef?.focus()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ── Generic note editor ──────────────────────────────── -->
|
||||
<template v-else>
|
||||
<div class="body-tabs-row">
|
||||
@@ -538,22 +708,6 @@ onUnmounted(() => assist.clearSelection());
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Place metadata -->
|
||||
<template v-if="noteType === 'place'">
|
||||
<div class="sb-field">
|
||||
<label class="sb-label">Address</label>
|
||||
<input class="sb-input" v-model="entityMeta.address" placeholder="Street, City" @input="markDirty" />
|
||||
</div>
|
||||
<div class="sb-field">
|
||||
<label class="sb-label">Phone</label>
|
||||
<input class="sb-input" v-model="entityMeta.phone" type="tel" placeholder="+1 555 000 0000" @input="markDirty" />
|
||||
</div>
|
||||
<div class="sb-field">
|
||||
<label class="sb-label">Hours</label>
|
||||
<input class="sb-input" v-model="entityMeta.hours" placeholder="e.g. Mon–Fri 9–5" @input="markDirty" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Link Suggestions -->
|
||||
<div v-if="linkSuggestions.length > 0" class="sb-field link-suggest-field">
|
||||
<div class="sb-label-row">
|
||||
@@ -925,6 +1079,78 @@ onUnmounted(() => assist.clearSelection());
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* ── List builder ───────────────────────────────────────── */
|
||||
.list-builder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 12px 0;
|
||||
}
|
||||
.lb-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.lb-check {
|
||||
flex-shrink: 0;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
accent-color: var(--color-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
.lb-text {
|
||||
flex: 1;
|
||||
padding: 7px 10px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
font-size: 0.9rem;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.lb-text:focus {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
.lb-text::placeholder {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.lb-delete {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 1.1rem;
|
||||
cursor: pointer;
|
||||
padding: 0 4px;
|
||||
line-height: 1;
|
||||
opacity: 0;
|
||||
transition: opacity 0.12s, color 0.12s;
|
||||
}
|
||||
.lb-item:hover .lb-delete,
|
||||
.lb-text:focus ~ .lb-delete {
|
||||
opacity: 1;
|
||||
}
|
||||
.lb-delete:hover {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
.lb-add {
|
||||
background: none;
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: 8px;
|
||||
padding: 7px 12px;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
margin-top: 4px;
|
||||
transition: border-color 0.15s, color 0.15s;
|
||||
}
|
||||
.lb-add:hover {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* Narrow screen: sidebar collapses */
|
||||
@media (max-width: 720px) {
|
||||
.note-body { flex-direction: column; overflow-y: auto; overflow-x: hidden; }
|
||||
|
||||
Reference in New Issue
Block a user