Unify compose + edit onto one surface (modal-only editor)
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 9s
CI & Build / Build & push image (push) Successful in 30s

Live-pass feedback: compose and edit still felt like different surfaces.
They already shared one component (task 1920), but rendered as two frames —
an inline in-flow box (compose) vs a modal overlay (edit) — which read as
two designs. Per operator choice, make BOTH the modal.

NoteEditor is now modal-only (rule 22 — the inline frame is fully removed):
dropped the `inline`/`autofocus` props, the collapsed "Take a note" frame,
`expanded`, `open()`, `commitInline`, `autoGrow`, and the outside-click
commit. Compose vs edit is purely note=null vs a note. Esc / Ctrl+Enter /
backdrop / Done all commit-and-close (create in compose, save in edit);
Shift+Enter still saves & starts a fresh note in compose (now gated on
isCreate, not the frame). Edit-only sections gate on !isCreate.

BoardView: the always-expanded inline composer becomes a slim "Take a
note…" trigger bar that opens the SAME modal with an empty note; the `c`
shortcut does likewise. One surface for capture and editing — and the
seam the card→editor grow animation (1914) will hook into.

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-23 12:57:03 -04:00
co-authored by Claude Opus 4.8
parent d1bc91a54a
commit f9f1b77d37
2 changed files with 47 additions and 111 deletions
+33 -105
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { computed, nextTick, onMounted, ref, watch } from "vue";
import { api } from "../api/client";
import { useNotesStore } from "../stores/notes";
import { useConfigStore } from "../stores/config";
@@ -13,15 +13,13 @@ import { fromLocalInput, toLocalInput } from "../notes/datetime";
import type { Note, NoteLabel, NoteRevision } from "../stores/notes";
import { LABEL_CHIP_CLASSES, type NoteColor } from "../notes/colors";
// 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 }>(), {
// One modal editor for BOTH composing and editing — a single surface (the board's
// "Take a note…" bar just opens this in compose mode). `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 }>(), {
note: null,
inline: false,
autofocus: false,
});
const emit = defineEmits<{ (e: "close"): void; (e: "navigate", id: string): void }>();
const notes = useNotesStore();
@@ -34,7 +32,6 @@ 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);
@@ -166,60 +163,29 @@ function resetCompose(): void {
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;
}
// ---- commit / close ----
// Compose only: save the current note and start a fresh one (rapid capture).
async function commitAndContinue(): Promise<void> {
if (isCreate.value && !hasContent.value) return;
if (!hasContent.value) return;
await flush();
resetCompose();
await nextTick();
bodyInput.value?.focus();
autoGrow();
}
// ---- modal (edit) frame ----
// Persist (create in compose, save in edit) and close the editor.
async function close(): Promise<void> {
await flush();
emit("close");
}
function onEsc(): void {
if (props.inline) void commitInline();
else void close();
void close();
}
function onMetaEnter(): void {
// Ctrl/Cmd+Enter = finish & close, in both frames (matches email/chat "send").
if (props.inline) void commitInline();
else void close();
// Ctrl/Cmd+Enter = finish & close (matches email/chat "send").
void close();
}
function onBackdropMousedown(): void {
if (!props.inline) void close();
}
function onDocClick(e: MouseEvent): void {
// Commit the inline composer on an OUTSIDE *click* (bubble phase, not mousedown):
// the clicked target's own handler fires first, so clicking another card's toolbar
// performs its action, THEN the composer collapses — no wasted first click.
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`;
void close();
}
async function loadBacklinks(): Promise<void> {
@@ -239,15 +205,9 @@ watch(() => noteId.value, loadBacklinks);
onMounted(async () => {
void titles.load();
void loadBacklinks();
if (props.inline) {
document.addEventListener("click", onDocClick);
if (props.autofocus) void open();
} else {
await nextTick();
bodyInput.value?.focus();
}
await nextTick();
bodyInput.value?.focus();
});
onBeforeUnmount(() => document.removeEventListener("click", onDocClick));
// ---- outgoing links (edit mode) ----
const outgoingLinks = computed(() => {
@@ -299,7 +259,6 @@ function refreshLinkMatches() {
}
function onBodyInput() {
if (props.inline) autoGrow();
const el = bodyInput.value;
if (!el) return;
const caret = el.selectionStart ?? 0;
@@ -338,7 +297,7 @@ function insertLink(t: 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) {
if (isCreate.value && e.key === "Enter" && e.shiftKey) {
e.preventDefault();
void commitAndContinue();
return;
@@ -365,9 +324,8 @@ function onBodyKeydown(e: KeyboardEvent) {
}
function onTitleEnter(e: KeyboardEvent) {
if (!props.inline) return;
e.preventDefault();
if (e.shiftKey) void commitAndContinue();
if (e.shiftKey && isCreate.value) void commitAndContinue();
else bodyInput.value?.focus();
}
@@ -417,7 +375,6 @@ 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;
@@ -560,44 +517,19 @@ function revPreview(rev: NoteRevision): string {
if (!s) return "(empty)";
return s.length > 80 ? `${s.slice(0, 80)}` : s;
}
defineExpose({ open });
</script>
<template>
<div
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]'
"
class="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) -->
<!-- One editor card for BOTH compose (empty note) and edit a single surface. -->
<div
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'"
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.stop="onEsc"
@keydown.enter.meta.prevent="onMetaEnter"
@keydown.enter.ctrl.prevent="onMetaEnter"
@@ -696,13 +628,9 @@ defineExpose({ open });
<textarea
ref="bodyInput"
v-model="body"
:rows="inline ? undefined : 8"
rows="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'
"
class="w-full resize-none bg-transparent text-sm leading-relaxed outline-none placeholder:text-neutral-400"
@input="onBodyInput"
@keydown="onBodyKeydown"
/>
@@ -796,7 +724,7 @@ defineExpose({ open });
</div>
<div
v-if="!inline && !isCreate && (outgoingLinks.length || backlinks.length)"
v-if="!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">
@@ -836,7 +764,7 @@ defineExpose({ open });
</div>
<div
v-if="!inline && !isCreate && showHistory"
v-if="!isCreate && showHistory"
class="flex flex-col gap-1 border-t border-neutral-100 pt-2 dark:border-neutral-800"
>
<p class="text-xs font-semibold uppercase tracking-wide text-neutral-400">History</p>
@@ -890,7 +818,7 @@ defineExpose({ open });
@update:model-value="onLabelsChange"
/>
<button
v-if="!inline && !isCreate"
v-if="!isCreate"
type="button"
class="icon-btn"
:class="showHistory ? 'text-brand-700 dark:text-brand' : ''"
@@ -901,7 +829,7 @@ defineExpose({ open });
>
<Icon name="history" />
</button>
<template v-if="!inline && !isCreate && !liveNote.trashed">
<template v-if="!isCreate && !liveNote.trashed">
<button
type="button"
class="icon-btn"
@@ -928,7 +856,7 @@ defineExpose({ open });
<Icon name="trash" />
</button>
</template>
<template v-else-if="!inline && !isCreate && liveNote.trashed">
<template v-else-if="!isCreate && liveNote.trashed">
<button type="button" class="icon-btn" title="Restore" @click="act(() => notes.restore(liveNote.id))">
<Icon name="restore" />
</button>
@@ -945,9 +873,9 @@ defineExpose({ open });
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="inline ? commitInline() : close()"
@click="close()"
>
{{ inline ? "Done" : "Close" }}
{{ isCreate ? "Done" : "Close" }}
</button>
</div>
</div>
+14 -6
View File
@@ -14,14 +14,14 @@ const ui = useUiStore();
const router = useRouter();
const editing = ref<Note | null>(null);
const composer = ref<InstanceType<typeof NoteEditor> | null>(null);
const composing = ref(false); // compose modal open (a new, empty note)
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).
// Compose and edit are ONE surface: the "Take a note…" bar and the global `c`
// shortcut both open the same modal editor with an empty note (editing = null).
watch(
() => ui.composeTick,
() => composer.value?.open(),
() => (composing.value = true),
);
// The command palette opens a note by navigating here with ?open=<id>.
@@ -146,6 +146,7 @@ function openEditor(note: Note) {
}
function closeEditor() {
editing.value = null;
composing.value = false;
}
async function onNavigate(id: string) {
@@ -182,7 +183,14 @@ async function onDrop(target: Note) {
<template>
<div class="mx-auto w-full max-w-6xl px-4 py-6">
<NoteEditor v-if="isMainBoard" ref="composer" inline autofocus class="mb-6" />
<button
v-if="isMainBoard"
type="button"
class="mx-auto mb-6 block w-full max-w-xl rounded-xl border border-neutral-200 bg-white px-4 py-3 text-left text-sm text-neutral-500 shadow-sm transition hover:shadow-md focus:outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-400"
@click="composing = true"
>
Take a note
</button>
<FilterBar v-if="isMainBoard" />
<div v-if="notes.loading" class="py-24 text-center text-sm text-neutral-400">Loading</div>
@@ -258,7 +266,7 @@ async function onDrop(target: Note) {
</template>
</div>
<template v-if="editing">
<template v-if="composing || editing">
<NoteEditor :note="editing" @close="closeEditor" @navigate="onNavigate" />
</template>
</template>