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">
|
<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 { api } from "../api/client";
|
||||||
import { useNotesStore } from "../stores/notes";
|
import { useNotesStore } from "../stores/notes";
|
||||||
import { useConfigStore } from "../stores/config";
|
import { useConfigStore } from "../stores/config";
|
||||||
@@ -13,15 +13,13 @@ import { fromLocalInput, toLocalInput } from "../notes/datetime";
|
|||||||
import type { Note, NoteLabel, NoteRevision } from "../stores/notes";
|
import type { Note, NoteLabel, NoteRevision } from "../stores/notes";
|
||||||
import { LABEL_CHIP_CLASSES, type NoteColor } from "../notes/colors";
|
import { LABEL_CHIP_CLASSES, type NoteColor } from "../notes/colors";
|
||||||
|
|
||||||
// One editor for BOTH composing and editing. `inline` renders the board composer
|
// One modal editor for BOTH composing and editing — a single surface (the board's
|
||||||
// frame (collapsible, in-flow); the default renders the modal editor. `note` = the
|
// "Take a note…" bar just opens this in compose mode). `note` = the note being edited,
|
||||||
// note being edited, or null to compose a new one. In compose mode there is no note
|
// or null to compose a new one. In compose mode there is no note id until a draft is
|
||||||
// id until a draft is persisted — on commit-with-content, or on the first rich action
|
// persisted — on commit-with-content, or on the first rich action (label / image /
|
||||||
// (label / image / reminder / checklist) — so we never litter empty notes.
|
// reminder / checklist) — so we never litter empty notes.
|
||||||
const props = withDefaults(defineProps<{ note?: Note | null; inline?: boolean; autofocus?: boolean }>(), {
|
const props = withDefaults(defineProps<{ note?: Note | null }>(), {
|
||||||
note: null,
|
note: null,
|
||||||
inline: false,
|
|
||||||
autofocus: false,
|
|
||||||
});
|
});
|
||||||
const emit = defineEmits<{ (e: "close"): void; (e: "navigate", id: string): void }>();
|
const emit = defineEmits<{ (e: "close"): void; (e: "navigate", id: string): void }>();
|
||||||
const notes = useNotesStore();
|
const notes = useNotesStore();
|
||||||
@@ -34,7 +32,6 @@ const body = ref(props.note?.body ?? "");
|
|||||||
const color = ref<NoteColor>(props.note?.color ?? "default");
|
const color = ref<NoteColor>(props.note?.color ?? "default");
|
||||||
const labelList = ref<NoteLabel[]>(props.note ? [...props.note.labels] : []);
|
const labelList = ref<NoteLabel[]>(props.note ? [...props.note.labels] : []);
|
||||||
const createKind = ref<"text" | "list">("text"); // compose-only list toggle
|
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 saving = ref(false);
|
||||||
const root = ref<HTMLElement | null>(null);
|
const root = ref<HTMLElement | null>(null);
|
||||||
const bodyInput = ref<HTMLTextAreaElement | null>(null);
|
const bodyInput = ref<HTMLTextAreaElement | null>(null);
|
||||||
@@ -166,60 +163,29 @@ function resetCompose(): void {
|
|||||||
uploadError.value = "";
|
uploadError.value = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- inline (compose) frame ----
|
// ---- commit / close ----
|
||||||
async function open(): Promise<void> {
|
// Compose only: save the current note and start a fresh one (rapid capture).
|
||||||
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> {
|
async function commitAndContinue(): Promise<void> {
|
||||||
if (isCreate.value && !hasContent.value) return;
|
if (!hasContent.value) return;
|
||||||
await flush();
|
await flush();
|
||||||
resetCompose();
|
resetCompose();
|
||||||
await nextTick();
|
await nextTick();
|
||||||
bodyInput.value?.focus();
|
bodyInput.value?.focus();
|
||||||
autoGrow();
|
|
||||||
}
|
}
|
||||||
|
// Persist (create in compose, save in edit) and close the editor.
|
||||||
// ---- modal (edit) frame ----
|
|
||||||
async function close(): Promise<void> {
|
async function close(): Promise<void> {
|
||||||
await flush();
|
await flush();
|
||||||
emit("close");
|
emit("close");
|
||||||
}
|
}
|
||||||
function onEsc(): void {
|
function onEsc(): void {
|
||||||
if (props.inline) void commitInline();
|
void close();
|
||||||
else void close();
|
|
||||||
}
|
}
|
||||||
function onMetaEnter(): void {
|
function onMetaEnter(): void {
|
||||||
// Ctrl/Cmd+Enter = finish & close, in both frames (matches email/chat "send").
|
// Ctrl/Cmd+Enter = finish & close (matches email/chat "send").
|
||||||
if (props.inline) void commitInline();
|
void close();
|
||||||
else void close();
|
|
||||||
}
|
}
|
||||||
function onBackdropMousedown(): void {
|
function onBackdropMousedown(): void {
|
||||||
if (!props.inline) void close();
|
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`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadBacklinks(): Promise<void> {
|
async function loadBacklinks(): Promise<void> {
|
||||||
@@ -239,15 +205,9 @@ watch(() => noteId.value, loadBacklinks);
|
|||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
void titles.load();
|
void titles.load();
|
||||||
void loadBacklinks();
|
void loadBacklinks();
|
||||||
if (props.inline) {
|
|
||||||
document.addEventListener("click", onDocClick);
|
|
||||||
if (props.autofocus) void open();
|
|
||||||
} else {
|
|
||||||
await nextTick();
|
await nextTick();
|
||||||
bodyInput.value?.focus();
|
bodyInput.value?.focus();
|
||||||
}
|
|
||||||
});
|
});
|
||||||
onBeforeUnmount(() => document.removeEventListener("click", onDocClick));
|
|
||||||
|
|
||||||
// ---- outgoing links (edit mode) ----
|
// ---- outgoing links (edit mode) ----
|
||||||
const outgoingLinks = computed(() => {
|
const outgoingLinks = computed(() => {
|
||||||
@@ -299,7 +259,6 @@ function refreshLinkMatches() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onBodyInput() {
|
function onBodyInput() {
|
||||||
if (props.inline) autoGrow();
|
|
||||||
const el = bodyInput.value;
|
const el = bodyInput.value;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
const caret = el.selectionStart ?? 0;
|
const caret = el.selectionStart ?? 0;
|
||||||
@@ -338,7 +297,7 @@ function insertLink(t: string) {
|
|||||||
|
|
||||||
function onBodyKeydown(e: KeyboardEvent) {
|
function onBodyKeydown(e: KeyboardEvent) {
|
||||||
// Compose: Shift+Enter saves the note and starts a fresh one (rapid capture).
|
// 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();
|
e.preventDefault();
|
||||||
void commitAndContinue();
|
void commitAndContinue();
|
||||||
return;
|
return;
|
||||||
@@ -365,9 +324,8 @@ function onBodyKeydown(e: KeyboardEvent) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onTitleEnter(e: KeyboardEvent) {
|
function onTitleEnter(e: KeyboardEvent) {
|
||||||
if (!props.inline) return;
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (e.shiftKey) void commitAndContinue();
|
if (e.shiftKey && isCreate.value) void commitAndContinue();
|
||||||
else bodyInput.value?.focus();
|
else bodyInput.value?.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -417,7 +375,6 @@ async function toggleKind() {
|
|||||||
if (isCreate.value) {
|
if (isCreate.value) {
|
||||||
createKind.value = createKind.value === "list" ? "text" : "list";
|
createKind.value = createKind.value === "list" ? "text" : "list";
|
||||||
bodyInput.value?.focus();
|
bodyInput.value?.focus();
|
||||||
void nextTick(autoGrow);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const id = noteId.value as string;
|
const id = noteId.value as string;
|
||||||
@@ -560,44 +517,19 @@ function revPreview(rev: NoteRevision): string {
|
|||||||
if (!s) return "(empty)";
|
if (!s) return "(empty)";
|
||||||
return s.length > 80 ? `${s.slice(0, 80)}…` : s;
|
return s.length > 80 ? `${s.slice(0, 80)}…` : s;
|
||||||
}
|
}
|
||||||
|
|
||||||
defineExpose({ open });
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
ref="root"
|
ref="root"
|
||||||
:class="
|
class="fixed inset-0 z-40 flex items-start justify-center overflow-y-auto bg-black/40 p-4 pt-[10vh]"
|
||||||
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"
|
@mousedown.self="onBackdropMousedown"
|
||||||
>
|
>
|
||||||
<!-- Collapsed composer (inline, board) -->
|
<!-- One editor card for BOTH compose (empty note) and edit — a single surface. -->
|
||||||
<div
|
<div
|
||||||
v-if="inline && !expanded"
|
class="w-full max-w-lg rounded-xl border border-neutral-200 bg-white shadow-xl dark:border-neutral-700 dark:bg-neutral-900"
|
||||||
class="rounded-xl border border-neutral-200 bg-white shadow-sm dark:border-neutral-700 dark:bg-neutral-900"
|
role="dialog"
|
||||||
>
|
aria-modal="true"
|
||||||
<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.stop="onEsc"
|
@keydown.esc.stop="onEsc"
|
||||||
@keydown.enter.meta.prevent="onMetaEnter"
|
@keydown.enter.meta.prevent="onMetaEnter"
|
||||||
@keydown.enter.ctrl.prevent="onMetaEnter"
|
@keydown.enter.ctrl.prevent="onMetaEnter"
|
||||||
@@ -696,13 +628,9 @@ defineExpose({ open });
|
|||||||
<textarea
|
<textarea
|
||||||
ref="bodyInput"
|
ref="bodyInput"
|
||||||
v-model="body"
|
v-model="body"
|
||||||
:rows="inline ? undefined : 8"
|
rows="8"
|
||||||
:placeholder="bodyPlaceholder"
|
:placeholder="bodyPlaceholder"
|
||||||
:class="
|
class="w-full resize-none bg-transparent text-sm leading-relaxed outline-none placeholder:text-neutral-400"
|
||||||
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"
|
@input="onBodyInput"
|
||||||
@keydown="onBodyKeydown"
|
@keydown="onBodyKeydown"
|
||||||
/>
|
/>
|
||||||
@@ -796,7 +724,7 @@ defineExpose({ open });
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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"
|
class="flex flex-col gap-2 border-t border-neutral-100 pt-2 dark:border-neutral-800"
|
||||||
>
|
>
|
||||||
<div v-if="outgoingLinks.length">
|
<div v-if="outgoingLinks.length">
|
||||||
@@ -836,7 +764,7 @@ defineExpose({ open });
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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"
|
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>
|
<p class="text-xs font-semibold uppercase tracking-wide text-neutral-400">History</p>
|
||||||
@@ -890,7 +818,7 @@ defineExpose({ open });
|
|||||||
@update:model-value="onLabelsChange"
|
@update:model-value="onLabelsChange"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
v-if="!inline && !isCreate"
|
v-if="!isCreate"
|
||||||
type="button"
|
type="button"
|
||||||
class="icon-btn"
|
class="icon-btn"
|
||||||
:class="showHistory ? 'text-brand-700 dark:text-brand' : ''"
|
:class="showHistory ? 'text-brand-700 dark:text-brand' : ''"
|
||||||
@@ -901,7 +829,7 @@ defineExpose({ open });
|
|||||||
>
|
>
|
||||||
<Icon name="history" />
|
<Icon name="history" />
|
||||||
</button>
|
</button>
|
||||||
<template v-if="!inline && !isCreate && !liveNote.trashed">
|
<template v-if="!isCreate && !liveNote.trashed">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="icon-btn"
|
class="icon-btn"
|
||||||
@@ -928,7 +856,7 @@ defineExpose({ open });
|
|||||||
<Icon name="trash" />
|
<Icon name="trash" />
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</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))">
|
<button type="button" class="icon-btn" title="Restore" @click="act(() => notes.restore(liveNote.id))">
|
||||||
<Icon name="restore" />
|
<Icon name="restore" />
|
||||||
</button>
|
</button>
|
||||||
@@ -945,9 +873,9 @@ defineExpose({ open });
|
|||||||
type="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"
|
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"
|
:disabled="saving"
|
||||||
@click="inline ? commitInline() : close()"
|
@click="close()"
|
||||||
>
|
>
|
||||||
{{ inline ? "Done" : "Close" }}
|
{{ isCreate ? "Done" : "Close" }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,14 +14,14 @@ const ui = useUiStore();
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const editing = ref<Note | null>(null);
|
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("");
|
const loadError = ref("");
|
||||||
|
|
||||||
// The global `c` shortcut bumps composeTick; reopen the composer when we're
|
// Compose and edit are ONE surface: the "Take a note…" bar and the global `c`
|
||||||
// already on the board (a fresh navigation autofocuses it via the prop).
|
// shortcut both open the same modal editor with an empty note (editing = null).
|
||||||
watch(
|
watch(
|
||||||
() => ui.composeTick,
|
() => ui.composeTick,
|
||||||
() => composer.value?.open(),
|
() => (composing.value = true),
|
||||||
);
|
);
|
||||||
|
|
||||||
// The command palette opens a note by navigating here with ?open=<id>.
|
// The command palette opens a note by navigating here with ?open=<id>.
|
||||||
@@ -146,6 +146,7 @@ function openEditor(note: Note) {
|
|||||||
}
|
}
|
||||||
function closeEditor() {
|
function closeEditor() {
|
||||||
editing.value = null;
|
editing.value = null;
|
||||||
|
composing.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onNavigate(id: string) {
|
async function onNavigate(id: string) {
|
||||||
@@ -182,7 +183,14 @@ async function onDrop(target: Note) {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="mx-auto w-full max-w-6xl px-4 py-6">
|
<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" />
|
<FilterBar v-if="isMainBoard" />
|
||||||
|
|
||||||
<div v-if="notes.loading" class="py-24 text-center text-sm text-neutral-400">Loading…</div>
|
<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>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-if="editing">
|
<template v-if="composing || editing">
|
||||||
<NoteEditor :note="editing" @close="closeEditor" @navigate="onNavigate" />
|
<NoteEditor :note="editing" @close="closeEditor" @navigate="onNavigate" />
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Reference in New Issue
Block a user