Unify compose + edit onto one surface (modal-only editor)
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:
@@ -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,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>
|
||||
|
||||
Reference in New Issue
Block a user