M7: unify compose + edit into one NoteEditor (DRY)
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:
@@ -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>
|
||||
|
||||
@@ -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 & 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>
|
||||
Reference in New Issue
Block a user