M7: unify compose + edit into one NoteEditor (DRY)
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 4s
CI & Build / Python tests (push) Successful in 7s
CI & Build / Build & push image (push) Successful in 40s

The 'Take a note' composer (QuickAdd) and the note editor were two components with duplicated field logic and unequal capabilities — composing lacked [[ links, labels, images, reminders. Merge them into a single NoteEditor with two frames: inline on the board (compose), modal on a card (edit). Same fields, styling, [[ autocomplete and toolbar in both (task 1920).

Compose is now a draft that persists on first real action: on commit-with-content, or on the first label/image/reminder/checklist use (ensureDraft) — so no empty-note litter. Rich controls light up once there's content; note-lifecycle actions (pin/archive/trash, links/backlinks) stay in the modal. notes.create() now returns the created note (for the draft id). QuickAdd.vue deleted; BoardView renders <NoteEditor inline>.

Pure frontend — no backend/DB/migration. Sets up the editor<->card morph (task 1914).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
This commit is contained in:
2026-07-22 14:05:00 -04:00
co-authored by Claude Opus 4.8
parent ae0c748507
commit bb33d5c495
4 changed files with 347 additions and 288 deletions
+340 -113
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, ref, watch } from "vue";
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { api } from "../api/client";
import { useNotesStore } from "../stores/notes";
import { useTitlesStore, type TitleEntry } from "../stores/titles";
@@ -11,67 +11,246 @@ import { fromLocalInput, toLocalInput } from "../notes/datetime";
import type { Note, NoteLabel } from "../stores/notes";
import { LABEL_CHIP_CLASSES, type NoteColor } from "../notes/colors";
const props = defineProps<{ note: Note }>();
// One editor for BOTH composing and editing. `inline` renders the board composer
// frame (collapsible, in-flow); the default renders the modal editor. `note` = the
// note being edited, or null to compose a new one. In compose mode there is no note
// id until a draft is persisted — on commit-with-content, or on the first rich action
// (label / image / reminder / checklist) — so we never litter empty notes.
const props = withDefaults(defineProps<{ note?: Note | null; inline?: boolean; autofocus?: boolean }>(), {
note: null,
inline: false,
autofocus: false,
});
const emit = defineEmits<{ (e: "close"): void; (e: "navigate", id: string): void }>();
const notes = useNotesStore();
const titles = useTitlesStore();
// Read the note reactively from the store so checklist item add/toggle/delete
// (which reconcile a fresh note object) reflect live while the editor is open.
const liveNote = computed(() => notes.items.find((n) => n.id === props.note.id) ?? props.note);
const title = ref(props.note.title ?? "");
const body = ref(props.note.body);
const color = ref<NoteColor>(props.note.color);
const labelList = ref<NoteLabel[]>([...props.note.labels]);
const noteId = ref<string | null>(props.note?.id ?? null);
const title = ref(props.note?.title ?? "");
const body = ref(props.note?.body ?? "");
const color = ref<NoteColor>(props.note?.color ?? "default");
const labelList = ref<NoteLabel[]>(props.note ? [...props.note.labels] : []);
const createKind = ref<"text" | "list">("text"); // compose-only list toggle
const expanded = ref(!props.inline); // modal is always open; inline starts collapsed
const saving = ref(false);
const root = ref<HTMLElement | null>(null);
const bodyInput = ref<HTMLTextAreaElement | null>(null);
const fileInput = ref<HTMLInputElement | null>(null);
const uploadError = ref("");
const backlinks = ref<{ id: string; title: string }[]>([]);
// Baseline for edit-mode change detection (save only when text actually changed).
const baseline = ref<{ title: string | null; body: string; color: NoteColor }>({
title: props.note?.title ?? null,
body: props.note?.body ?? "",
color: (props.note?.color ?? "default") as NoteColor,
});
const isCreate = computed(() => noteId.value === null);
const hasContent = computed(() => title.value.trim() !== "" || body.value.trim() !== "");
// Rich features need a saved note; in compose they light up once there's content.
const richEnabled = computed(() => !isCreate.value || hasContent.value);
// A synthetic note for compose mode (before anything is persisted), so the shared
// template can read attachments/items/kind/remind_at uniformly.
const draftNote = computed<Note>(() => ({
id: "",
title: title.value.trim() || null,
display_title: "",
body: body.value,
color: color.value,
kind: createKind.value,
position: 0,
pinned: false,
archived: false,
trashed: false,
remind_at: null,
labels: labelList.value,
items: [],
attachments: [],
created_at: null,
updated_at: null,
}));
const liveNote = computed<Note>(() =>
noteId.value
? (notes.items.find((n) => n.id === noteId.value) ?? props.note ?? draftNote.value)
: draftNote.value,
);
// Only edit-mode list notes render the interactive checklist; compose-list types
// lines into the textarea (they become items on create).
const showChecklist = computed(() => !isCreate.value && liveNote.value.kind === "list");
const isListMode = computed(() => (isCreate.value ? createKind.value === "list" : liveNote.value.kind === "list"));
const bodyPlaceholder = computed(() =>
isCreate.value && createKind.value === "list" ? "One item per line…" : "Take a note… ([[ to link a note)",
);
// Keep local state in sync when the edited note changes (modal reused for another note).
watch(
() => props.note,
(n) => {
title.value = n.title ?? "";
body.value = n.body;
color.value = n.color;
labelList.value = [...n.labels];
noteId.value = n?.id ?? null;
title.value = n?.title ?? "";
body.value = n?.body ?? "";
color.value = (n?.color ?? "default") as NoteColor;
labelList.value = n ? [...n.labels] : [];
baseline.value = { title: n?.title ?? null, body: n?.body ?? "", color: (n?.color ?? "default") as NoteColor };
},
);
const backlinks = ref<{ id: string; title: string }[]>([]);
// ---- persistence ----
async function createFromFields(): Promise<void> {
let created: Note;
if (createKind.value === "list") {
const items = body.value
.split("\n")
.map((l) => l.trim())
.filter(Boolean);
created = await notes.create({ title: title.value, body: "", color: color.value, kind: "list", items });
body.value = ""; // the lines moved into checklist items
} else {
created = await notes.create({ title: title.value, body: body.value, color: color.value });
}
noteId.value = created.id;
baseline.value = { title: created.title, body: created.body, color: created.color as NoteColor };
}
async function loadBacklinks() {
// Ensure a persisted note exists (for rich actions mid-compose). Returns its id, or
// null when there's nothing to create yet (empty compose — callers just no-op).
async function ensureDraft(): Promise<string | null> {
if (noteId.value) return noteId.value;
if (!hasContent.value) return null;
await createFromFields();
return noteId.value;
}
// Persist current text fields: create in compose, patch in edit.
async function flush(): Promise<void> {
if (saving.value) return;
if (isCreate.value) {
if (!hasContent.value) return;
saving.value = true;
try {
await createFromFields();
} finally {
saving.value = false;
}
return;
}
const b = baseline.value;
const nextBody = showChecklist.value ? b.body : body.value;
const changed = (title.value.trim() || null) !== b.title || nextBody !== b.body || color.value !== b.color;
if (!changed) return;
saving.value = true;
try {
const res = await api.get<{ backlinks: { id: string; title: string }[] }>(
`/api/notes/${props.note.id}/backlinks`,
);
await notes.saveEdit(noteId.value as string, { title: title.value, body: nextBody, color: color.value });
baseline.value = { title: title.value.trim() || null, body: nextBody, color: color.value };
} finally {
saving.value = false;
}
}
function resetCompose(): void {
noteId.value = null;
title.value = "";
body.value = "";
color.value = "default";
labelList.value = [];
createKind.value = "text";
baseline.value = { title: null, body: "", color: "default" };
linkMenu.value = false;
uploadError.value = "";
}
// ---- inline (compose) frame ----
async function open(): Promise<void> {
expanded.value = true;
await nextTick();
bodyInput.value?.focus();
autoGrow();
}
async function commitInline(): Promise<void> {
await flush();
resetCompose();
expanded.value = false;
}
async function commitAndContinue(): Promise<void> {
if (isCreate.value && !hasContent.value) return;
await flush();
resetCompose();
await nextTick();
bodyInput.value?.focus();
autoGrow();
}
// ---- modal (edit) frame ----
async function close(): Promise<void> {
await flush();
emit("close");
}
function onEsc(): void {
if (props.inline) void commitInline();
else void close();
}
function onMetaEnter(): void {
if (!props.inline) void close();
}
function onBackdropMousedown(): void {
if (!props.inline) void close();
}
function onDocMousedown(e: MouseEvent): void {
if (props.inline && expanded.value && root.value && !root.value.contains(e.target as Node)) {
void commitInline();
}
}
// Grow the composer textarea to fit its content (inline only; modal uses fixed rows).
function autoGrow(): void {
if (!props.inline) return;
const el = bodyInput.value;
if (!el) return;
el.style.height = "auto";
el.style.height = `${el.scrollHeight}px`;
}
async function loadBacklinks(): Promise<void> {
if (!noteId.value) {
backlinks.value = [];
return;
}
try {
const res = await api.get<{ backlinks: { id: string; title: string }[] }>(`/api/notes/${noteId.value}/backlinks`);
backlinks.value = res.backlinks;
} catch {
backlinks.value = [];
}
}
watch(() => noteId.value, loadBacklinks);
onMounted(async () => {
void titles.load();
void loadBacklinks();
await nextTick();
bodyInput.value?.focus();
if (props.inline) {
document.addEventListener("mousedown", onDocMousedown);
if (props.autofocus) void open();
} else {
await nextTick();
bodyInput.value?.focus();
}
});
onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown));
watch(
() => props.note.id,
() => void loadBacklinks(),
);
// ---- outgoing links (edit mode) ----
const outgoingLinks = computed(() => {
const re = /\[\[([^[\]]+)\]\]/g;
const seen = new Set<string>();
const out: { title: string; id: string | null }[] = [];
let match: RegExpExecArray | null;
while ((match = re.exec(body.value)) !== null) {
const title = match[1].trim();
const key = title.toLowerCase();
if (title && !seen.has(key)) {
const t = match[1].trim();
const key = t.toLowerCase();
if (t && !seen.has(key)) {
seen.add(key);
out.push({ title, id: titles.resolve(title)?.id ?? null });
out.push({ title: t, id: titles.resolve(t)?.id ?? null });
}
}
return out;
@@ -87,15 +266,11 @@ async function openLink(link: { title: string; id: string | null }) {
emit("navigate", created.id);
}
// --- [[ link autocomplete in the body textarea ---
// ---- [[ link autocomplete in the body textarea ----
const linkMenu = ref(false);
const linkQuery = ref("");
const linkStart = ref(-1); // index of the `[[` that opened the current token
const linkStart = ref(-1);
const linkSelected = ref(0);
// [[ autocomplete searches note NAME *and* body via the server (link-search), so you
// can link by recalling any phrase — not just the exact name. Debounced so we don't
// fire a request on every keystroke.
const linkMatches = ref<TitleEntry[]>([]);
let linkTimer: ReturnType<typeof setTimeout> | undefined;
@@ -104,11 +279,8 @@ function refreshLinkMatches() {
const q = linkQuery.value.trim();
linkTimer = setTimeout(async () => {
try {
const res = await api.get<{ results: TitleEntry[] }>(
`/api/notes/link-search?q=${encodeURIComponent(q)}`,
);
// Never suggest linking a note to itself.
linkMatches.value = res.results.filter((r) => r.id !== props.note.id).slice(0, 8);
const res = await api.get<{ results: TitleEntry[] }>(`/api/notes/link-search?q=${encodeURIComponent(q)}`);
linkMatches.value = res.results.filter((r) => r.id !== noteId.value).slice(0, 8);
} catch {
linkMatches.value = [];
}
@@ -116,9 +288,8 @@ function refreshLinkMatches() {
}, 120);
}
// On every edit, check whether the caret sits inside an unclosed `[[…` token
// and, if so, open the suggestion menu with the partial title as the query.
function onBodyInput() {
if (props.inline) autoGrow();
const el = bodyInput.value;
if (!el) return;
const caret = el.selectionStart ?? 0;
@@ -140,12 +311,12 @@ function onBodyInput() {
refreshLinkMatches();
}
function insertLink(title: string) {
function insertLink(t: string) {
const el = bodyInput.value;
const caret = el?.selectionStart ?? body.value.length;
const before = body.value.slice(0, linkStart.value);
const after = body.value.slice(caret);
const insertion = `[[${title}]]`;
const insertion = `[[${t}]]`;
body.value = before + insertion + after;
linkMenu.value = false;
const pos = before.length + insertion.length;
@@ -156,6 +327,12 @@ function insertLink(title: string) {
}
function onBodyKeydown(e: KeyboardEvent) {
// Compose: Shift+Enter saves the note and starts a fresh one (rapid capture).
if (props.inline && e.key === "Enter" && e.shiftKey) {
e.preventDefault();
void commitAndContinue();
return;
}
if (!linkMenu.value || linkMatches.value.length === 0) return;
if (e.key === "ArrowDown") {
e.preventDefault();
@@ -170,20 +347,32 @@ function onBodyKeydown(e: KeyboardEvent) {
insertLink(m.title);
}
} else if (e.key === "Escape") {
// Close only the menu — don't let it bubble to the dialog's Esc-to-close.
// Close only the menu — don't let Esc bubble to the frame's close/commit.
e.preventDefault();
e.stopPropagation();
linkMenu.value = false;
}
}
const reminderLocal = computed(() => toLocalInput(liveNote.value.remind_at));
function onReminderChange(e: Event) {
void notes.setReminder(props.note.id, fromLocalInput((e.target as HTMLInputElement).value));
function onTitleEnter(e: KeyboardEvent) {
if (!props.inline) return;
e.preventDefault();
if (e.shiftKey) void commitAndContinue();
else bodyInput.value?.focus();
}
// ---- reminder ----
const reminderLocal = computed(() => toLocalInput(liveNote.value.remind_at));
async function onReminderChange(e: Event) {
const id = await ensureDraft();
if (!id) return;
await notes.setReminder(id, fromLocalInput((e.target as HTMLInputElement).value));
}
// ---- labels ----
async function onLabelsChange(next: NoteLabel[]) {
const id = await ensureDraft();
if (!id) return;
// Tag-sourced labels are governed by the note body, not the picker — always keep
// them so a picker save can't strip a label the #tag still mandates.
const tagLabels = labelList.value.filter((lb) => lb.via_tag);
@@ -191,60 +380,63 @@ async function onLabelsChange(next: NoteLabel[]) {
const merged = [...next.filter((lb) => !tagIds.has(lb.id)), ...tagLabels];
labelList.value = merged;
await notes.setLabels(
props.note.id,
id,
merged.map((lb) => lb.id),
);
}
async function removeLabel(id: string) {
await onLabelsChange(labelList.value.filter((lb) => lb.id !== id));
}
function labelChip(color: string): string {
return LABEL_CHIP_CLASSES[color as NoteColor] ?? LABEL_CHIP_CLASSES.default;
function labelChip(c: string): string {
return LABEL_CHIP_CLASSES[c as NoteColor] ?? LABEL_CHIP_CLASSES.default;
}
// ---- kind toggle: compose = local flag, edit = convert the existing note ----
async function toggleKind() {
if (isCreate.value) {
createKind.value = createKind.value === "list" ? "text" : "list";
bodyInput.value?.focus();
void nextTick(autoGrow);
return;
}
const id = noteId.value as string;
if (liveNote.value.kind === "list") {
await notes.setKind(props.note.id, "text");
await notes.setKind(id, "text");
return;
}
// Convert existing body lines into checklist items, then switch to a list.
const lines = body.value
.split("\n")
.map((s) => s.trim())
.filter((s) => s.length > 0);
for (const line of lines) await notes.addItem(props.note.id, line);
for (const line of lines) await notes.addItem(id, line);
if (lines.length > 0) {
body.value = "";
await notes.saveEdit(props.note.id, { title: title.value, body: "", color: color.value });
await notes.saveEdit(id, { title: title.value, body: "", color: color.value });
baseline.value = { title: title.value.trim() || null, body: "", color: color.value };
}
await notes.setKind(props.note.id, "list");
await notes.setKind(id, "list");
}
const fileInput = ref<HTMLInputElement | null>(null);
const uploadError = ref("");
// ---- attachments ----
function pickImage() {
fileInput.value?.click();
}
async function uploadFile(file: File) {
const id = await ensureDraft();
if (!id) return;
uploadError.value = "";
try {
await notes.uploadAttachment(props.note.id, file);
await notes.uploadAttachment(id, file);
} catch (e) {
uploadError.value = (e as { error?: string }).error ?? "Could not upload image.";
}
}
async function onFileChange(e: Event) {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
if (file) await uploadFile(file);
input.value = "";
}
async function onPaste(e: ClipboardEvent) {
const item = Array.from(e.clipboardData?.items ?? []).find((i) => i.type.startsWith("image/"));
const file = item?.getAsFile();
@@ -254,35 +446,52 @@ async function onPaste(e: ClipboardEvent) {
}
}
async function close() {
const changed =
(title.value.trim() || null) !== (props.note.title ?? null) ||
body.value !== props.note.body ||
color.value !== props.note.color;
if (changed) {
await notes.saveEdit(props.note.id, { title: title.value, body: body.value, color: color.value });
}
emit("close");
}
// ---- edit-mode lifecycle actions (pin/archive/trash/restore/delete) ----
async function act(fn: () => Promise<void>) {
await fn();
emit("close");
}
defineExpose({ open });
</script>
<template>
<div
class="fixed inset-0 z-40 flex items-start justify-center overflow-y-auto bg-black/40 p-4 pt-[10vh]"
@mousedown.self="close"
ref="root"
:class="
inline
? 'mx-auto w-full max-w-xl'
: 'fixed inset-0 z-40 flex items-start justify-center overflow-y-auto bg-black/40 p-4 pt-[10vh]'
"
@mousedown.self="onBackdropMousedown"
>
<!-- Collapsed composer (inline, board) -->
<div
class="w-full max-w-lg rounded-xl border border-neutral-200 bg-white shadow-xl dark:border-neutral-700 dark:bg-neutral-900"
role="dialog"
aria-modal="true"
@keydown.esc="close"
@keydown.enter.meta.prevent="close"
@keydown.enter.ctrl.prevent="close"
v-if="inline && !expanded"
class="rounded-xl border border-neutral-200 bg-white shadow-sm dark:border-neutral-700 dark:bg-neutral-900"
>
<button
type="button"
class="w-full rounded-xl px-4 py-3 text-left text-sm text-neutral-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand dark:text-neutral-400"
@click="open"
>
Take a note
</button>
</div>
<!-- Shared editor card: compose (expanded, inline) OR edit (modal) -->
<div
v-else
:class="
inline
? 'w-full rounded-xl border border-neutral-200 bg-white shadow-sm dark:border-neutral-700 dark:bg-neutral-900'
: 'w-full max-w-lg rounded-xl border border-neutral-200 bg-white shadow-xl dark:border-neutral-700 dark:bg-neutral-900'
"
:role="inline ? undefined : 'dialog'"
:aria-modal="inline ? undefined : 'true'"
@keydown.esc="onEsc"
@keydown.enter.meta.prevent="onMetaEnter"
@keydown.enter.ctrl.prevent="onMetaEnter"
@paste="onPaste"
>
<div class="flex flex-col gap-2 p-4">
@@ -293,26 +502,33 @@ async function act(fn: () => Promise<void>) {
type="button"
class="absolute right-1 top-1 rounded-full bg-black/50 px-1.5 text-white opacity-0 transition group-hover/att:opacity-100"
aria-label="Remove image"
@click="notes.deleteAttachment(note.id, att.id)"
@click="notes.deleteAttachment(liveNote.id, att.id)"
>
×
</button>
</div>
</div>
<p v-if="uploadError" class="text-xs text-red-600 dark:text-red-400">{{ uploadError }}</p>
<input
v-model="title"
type="text"
placeholder="Title (optional)"
class="w-full bg-transparent text-base font-semibold outline-none placeholder:text-neutral-400"
@keydown.enter="onTitleEnter"
/>
<div v-if="liveNote.kind === 'text'" class="relative">
<div v-if="!showChecklist" class="relative">
<textarea
ref="bodyInput"
v-model="body"
rows="8"
placeholder="Take a note… ([[ to link a note)"
class="w-full resize-none bg-transparent text-sm leading-relaxed outline-none placeholder:text-neutral-400"
:rows="inline ? undefined : 8"
:placeholder="bodyPlaceholder"
:class="
inline
? 'max-h-64 min-h-[4.5rem] w-full resize-none overflow-y-auto bg-transparent text-sm leading-relaxed outline-none placeholder:text-neutral-400'
: 'w-full resize-none bg-transparent text-sm leading-relaxed outline-none placeholder:text-neutral-400'
"
@input="onBodyInput"
@keydown="onBodyKeydown"
/>
@@ -359,26 +575,26 @@ async function act(fn: () => Promise<void>) {
</span>
</div>
<div class="flex items-center gap-2 pt-1">
<div v-if="richEnabled" class="flex items-center gap-2 pt-1">
<Icon name="bell" class="text-neutral-400" />
<input
type="datetime-local"
:value="reminderLocal"
class="rounded-md border border-neutral-300 bg-white px-2 py-1 text-xs text-neutral-700 outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-200"
class="rounded-md border border-neutral-300 bg-white px-2 py-1 text-xs text-neutral-700 outline-none [color-scheme:light] focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-200 dark:[color-scheme:dark]"
@change="onReminderChange"
/>
<button
v-if="liveNote.remind_at"
type="button"
class="text-xs text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-200"
@click="notes.setReminder(note.id, null)"
@click="notes.setReminder(liveNote.id, null)"
>
Clear
</button>
</div>
<div
v-if="outgoingLinks.length || backlinks.length"
v-if="!inline && !isCreate && (outgoingLinks.length || backlinks.length)"
class="flex flex-col gap-2 border-t border-neutral-100 pt-2 dark:border-neutral-800"
>
<div v-if="outgoingLinks.length">
@@ -422,7 +638,7 @@ async function act(fn: () => Promise<void>) {
<ColorPicker v-model="color" />
<div class="flex items-center gap-0.5">
<button
v-if="!note.trashed"
v-if="richEnabled && !liveNote.trashed"
type="button"
class="icon-btn"
title="Add image"
@@ -439,57 +655,68 @@ async function act(fn: () => Promise<void>) {
@change="onFileChange"
/>
<button
v-if="!note.trashed"
v-if="!liveNote.trashed"
type="button"
class="icon-btn"
:class="liveNote.kind === 'list' ? 'text-brand-700 dark:text-brand' : ''"
:title="liveNote.kind === 'list' ? 'Convert to text note' : 'Convert to checklist'"
:class="isListMode ? 'text-brand-700 dark:text-brand' : ''"
:title="isListMode ? 'Switch to a note' : 'Make a checklist'"
:aria-pressed="isListMode"
@click="toggleKind"
>
<Icon name="checkbox" />
</button>
<LabelPicker v-if="!note.trashed" :model-value="labelList" @update:model-value="onLabelsChange" />
<template v-if="!note.trashed">
<LabelPicker
v-if="richEnabled && !liveNote.trashed"
:model-value="labelList"
@update:model-value="onLabelsChange"
/>
<template v-if="!inline && !isCreate && !liveNote.trashed">
<button
type="button"
class="icon-btn"
:class="note.pinned ? 'text-brand-700 dark:text-brand' : ''"
:title="note.pinned ? 'Unpin' : 'Pin'"
@click="act(() => notes.setPinned(note.id, !note.pinned))"
:class="liveNote.pinned ? 'text-brand-700 dark:text-brand' : ''"
:title="liveNote.pinned ? 'Unpin' : 'Pin'"
@click="act(() => notes.setPinned(liveNote.id, !liveNote.pinned))"
>
<Icon name="pin" />
</button>
<button
type="button"
class="icon-btn"
:title="note.archived ? 'Unarchive' : 'Archive'"
@click="act(() => notes.setArchived(note.id, !note.archived))"
:title="liveNote.archived ? 'Unarchive' : 'Archive'"
@click="act(() => notes.setArchived(liveNote.id, !liveNote.archived))"
>
<Icon name="archive" />
</button>
<button type="button" class="icon-btn" title="Move to trash" @click="act(() => notes.trash(note.id))">
<button
type="button"
class="icon-btn"
title="Move to trash"
@click="act(() => notes.trash(liveNote.id))"
>
<Icon name="trash" />
</button>
</template>
<template v-else>
<button type="button" class="icon-btn" title="Restore" @click="act(() => notes.restore(note.id))">
<template v-else-if="!inline && !isCreate && liveNote.trashed">
<button type="button" class="icon-btn" title="Restore" @click="act(() => notes.restore(liveNote.id))">
<Icon name="restore" />
</button>
<button
type="button"
class="icon-btn"
title="Delete forever"
@click="act(() => notes.deleteForever(note.id))"
@click="act(() => notes.deleteForever(liveNote.id))"
>
<Icon name="trash" />
</button>
</template>
<button
type="button"
class="rounded-md px-3 py-1.5 text-sm font-semibold text-neutral-700 hover:bg-neutral-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand dark:text-neutral-200 dark:hover:bg-neutral-800"
@click="close"
class="rounded-md px-3 py-1.5 text-sm font-semibold text-neutral-700 hover:bg-neutral-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand disabled:opacity-60 dark:text-neutral-200 dark:hover:bg-neutral-800"
:disabled="saving"
@click="inline ? commitInline() : close()"
>
Close
{{ inline ? "Done" : "Close" }}
</button>
</div>
</div>
-169
View File
@@ -1,169 +0,0 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, nextTick, ref } from "vue";
import { useNotesStore } from "../stores/notes";
import ColorPicker from "./ColorPicker.vue";
import Icon from "./Icon.vue";
import type { NoteColor } from "../notes/colors";
const props = withDefaults(defineProps<{ autofocus?: boolean }>(), { autofocus: false });
const notes = useNotesStore();
const expanded = ref(false);
const saving = ref(false);
const title = ref("");
const body = ref("");
const color = ref<NoteColor>("default");
// 'text' = freeform note; 'list' = checklist (each body line becomes an item).
const mode = ref<"text" | "list">("text");
const root = ref<HTMLElement | null>(null);
const bodyInput = ref<HTMLTextAreaElement | null>(null);
function toggleMode() {
mode.value = mode.value === "list" ? "text" : "list";
bodyInput.value?.focus();
void nextTick(autoGrow);
}
async function open() {
expanded.value = true;
await nextTick();
bodyInput.value?.focus();
autoGrow();
}
function hasContent(): boolean {
return title.value.trim() !== "" || body.value.trim() !== "";
}
function clearFields() {
title.value = "";
body.value = "";
color.value = "default";
}
// Grow the composer textarea to fit its content (up to a capped max height).
function autoGrow() {
const el = bodyInput.value;
if (!el) return;
el.style.height = "auto";
el.style.height = `${el.scrollHeight}px`;
}
async function persist(): Promise<void> {
if (!hasContent()) return;
saving.value = true;
try {
if (mode.value === "list") {
// Each non-empty body line becomes a checklist item.
const items = body.value
.split("\n")
.map((l) => l.trim())
.filter(Boolean);
await notes.create({ title: title.value, body: "", color: color.value, kind: "list", items });
} else {
await notes.create({ title: title.value, body: body.value, color: color.value });
}
} finally {
saving.value = false;
}
}
// Close the composer, saving anything typed.
async function commit() {
await persist();
clearFields();
expanded.value = false;
mode.value = "text"; // next capture starts as a plain note
}
// Shift+Enter: save the current note and immediately start a fresh one, staying
// open + focused for rapid-fire capture.
async function commitAndContinue() {
if (!hasContent()) return;
await persist();
clearFields();
await nextTick();
bodyInput.value?.focus();
autoGrow();
}
function onDocumentMousedown(e: MouseEvent) {
if (expanded.value && root.value && !root.value.contains(e.target as Node)) {
void commit();
}
}
onMounted(() => {
document.addEventListener("mousedown", onDocumentMousedown);
// Landing on the board drops the cursor straight into the note input.
if (props.autofocus) void open();
});
onBeforeUnmount(() => document.removeEventListener("mousedown", onDocumentMousedown));
// Let the board (via the global `c` shortcut) reopen + focus the composer.
defineExpose({ open });
</script>
<template>
<div ref="root" class="mx-auto w-full max-w-xl">
<div class="rounded-xl border border-neutral-200 bg-white shadow-sm dark:border-neutral-700 dark:bg-neutral-900">
<button
v-if="!expanded"
type="button"
class="w-full rounded-xl px-4 py-3 text-left text-sm text-neutral-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand dark:text-neutral-400"
@click="open"
>
Take a note
</button>
<div v-else class="flex flex-col gap-2 p-3">
<input
v-model="title"
type="text"
placeholder="Title (optional)"
class="w-full bg-transparent px-1 text-sm font-semibold outline-none placeholder:text-neutral-400"
@keydown.enter.shift.prevent="commitAndContinue"
@keydown.enter.exact.prevent="bodyInput?.focus()"
/>
<textarea
ref="bodyInput"
v-model="body"
:placeholder="
mode === 'list'
? 'One item per line… (Shift+Enter saves)'
: 'Take a note… (Shift+Enter saves &amp; starts a new one)'
"
class="max-h-64 min-h-[4.5rem] w-full resize-none overflow-y-auto bg-transparent px-1 text-sm outline-none placeholder:text-neutral-400"
@input="autoGrow"
@keydown.enter.shift.prevent="commitAndContinue"
@keydown.esc="commit"
/>
<div class="flex items-center justify-between gap-2 pt-1">
<div class="flex items-center gap-1">
<ColorPicker v-model="color" />
<button
type="button"
class="icon-btn"
:class="mode === 'list' ? 'text-brand-700 dark:text-brand' : ''"
:title="mode === 'list' ? 'Switch to a note' : 'Make a checklist'"
:aria-label="mode === 'list' ? 'Switch to a note' : 'Make a checklist'"
:aria-pressed="mode === 'list'"
@click="toggleMode"
>
<Icon name="checkbox" />
</button>
</div>
<button
type="button"
class="rounded-md px-3 py-1.5 text-sm font-semibold text-neutral-700 hover:bg-neutral-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand disabled:opacity-60 dark:text-neutral-200 dark:hover:bg-neutral-800"
:disabled="saving"
@click="commit"
>
Close
</button>
</div>
</div>
</div>
</div>
</template>
+4 -2
View File
@@ -104,8 +104,10 @@ export const useNotesStore = defineStore("notes", () => {
color: NoteColor;
kind?: NoteKind;
items?: string[];
}): Promise<void> {
reconcile(await api.post<Note>("/api/notes", input));
}): Promise<Note> {
const note = await api.post<Note>("/api/notes", input);
reconcile(note);
return note;
}
async function mutate(
+3 -4
View File
@@ -3,7 +3,6 @@ import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useNotesStore, type Note, type NoteView } from "../stores/notes";
import { useUiStore } from "../stores/ui";
import QuickAdd from "../components/QuickAdd.vue";
import NoteCard from "../components/NoteCard.vue";
import NoteEditor from "../components/NoteEditor.vue";
@@ -13,14 +12,14 @@ const ui = useUiStore();
const router = useRouter();
const editing = ref<Note | null>(null);
const quickAdd = ref<InstanceType<typeof QuickAdd> | null>(null);
const composer = ref<InstanceType<typeof NoteEditor> | null>(null);
const loadError = ref("");
// The global `c` shortcut bumps composeTick; reopen the composer when we're
// already on the board (a fresh navigation autofocuses it via the prop).
watch(
() => ui.composeTick,
() => quickAdd.value?.open(),
() => composer.value?.open(),
);
// The command palette opens a note by navigating here with ?open=<id>.
@@ -175,7 +174,7 @@ async function onDrop(target: Note) {
<template>
<div class="mx-auto w-full max-w-6xl px-4 py-6">
<QuickAdd v-if="isMainBoard" ref="quickAdd" autofocus class="mb-8" />
<NoteEditor v-if="isMainBoard" ref="composer" inline autofocus class="mb-8" />
<div v-if="notes.loading" class="py-24 text-center text-sm text-neutral-400">Loading</div>