Files
thoughtsync/frontend/src/components/NoteEditor.vue
T
bvandeusenandClaude Opus 5 1a49ae7ea9
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 12s
CI & Build / Python lint (push) Successful in 2s
CI & Build / Python tests (push) Successful in 11s
CI & Build / integration (push) Successful in 18s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m15s
Android / Kotlin + Rust (APK) (push) Failing after 5m25s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m31s
Desktop (Tauri) / Update manifest (push) Successful in 4s
editor: a - [ ] typed by hand becomes a real item when you leave the line
`splitBlocks` runs once, when the editor opens. After that the blocks ARE the
state and nothing reads the body again — every edit travels the other way,
through `joinBlocks`. So a marker typed by hand stayed literal text on screen
until the note was closed and reopened, even though it was already a real item
in storage and the card was already drawing a checkbox for it. The editor was
the only place that disagreed with itself. (#3024)

On BLUR, and only the block being left. There is no good moment to convert
while someone is typing: re-splitting on a keystroke moves the caret out of the
word being written, and converting the instant `- [ ]` is complete does it
before the item has any text. Blur is the one moment the person has
demonstrably finished with the block.

`promotingTasks` / `promoteTasks` return the SAME list when there was nothing
to promote, and both call sites compare by identity. Without that, every blur
would re-key every field below it — including the blur that fires on first
composition, before a field has ever held focus.

Both surfaces in one commit, deliberately: blocks.ts is a line-by-line mirror
of EditorBlock.kt, and the reason that mirror is worth keeping is that the two
editors behave identically. Fixing one would spend its whole value.

Non-canonical markers (`- [X]`, an odd bullet) come back canonical — the only
case where this changes the body rather than just how it is drawn, and exactly
what reopening the note already did.

No unit test: `splitBlocks` reaches the core over uniffi for the grammar, so it
needs the native library and cannot run in the JVM lane. No existing Android
test touches the core for the same reason.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 12:50:43 -04:00

821 lines
32 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, nextTick, onMounted, ref, watch } from "vue";
import { useNotesStore } from "../stores/notes";
import ColorPicker from "./ColorPicker.vue";
import Icon from "./Icon.vue";
import LabelPicker from "./LabelPicker.vue";
import LinkPreview from "./LinkPreview.vue";
import { fromLocalInput, toLocalInput } from "../notes/datetime";
import { takeMorphOrigin } from "../composables/useEditorMorph";
import { prefersReducedMotion } from "../composables/useReducedMotion";
import type { Note, NoteLabel, NoteRevision } from "../stores/notes";
import { LABEL_CHIP_CLASSES, resolveLabelColor, type NoteColor } from "../notes/colors";
import {
afterEnter,
type EditorBlock,
joinBlocks,
plusTask,
promoteTasks,
splitBlocks,
withoutIndex,
} from "../notes/blocks";
// 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; initialBody?: string }>(), {
note: null,
initialBody: "",
});
const emit = defineEmits<{ (e: "close"): void; (e: "navigate", id: string): void }>();
const notes = useNotesStore();
const noteId = ref<string | null>(props.note?.id ?? null);
// BLOCKS rather than one string, because a checklist item is drawn as a real checkbox
// and a widget cannot live inside a <textarea>. The note is still one markdown body —
// see notes/blocks.ts — and `body` is what every save, baseline and draft still reads.
const blocks = ref<EditorBlock[]>(splitBlocks(props.note?.body ?? props.initialBody));
const body = computed(() => joinBlocks(blocks.value));
function setBody(text: string): void {
blocks.value = splitBlocks(text);
}
const color = ref<NoteColor>(props.note?.color ?? "default");
const labelList = ref<NoteLabel[]>(props.note ? [...props.note.labels] : []);
// Whether this editor is showing the checklist. A note HAS a checklist (M13 step 2)
// rather than BEING one, so this is a view flag, not a property of the note: it turns
// on when the note already carries items, and when someone asks for one.
const saving = ref(false);
const root = ref<HTMLElement | null>(null);
// One element per block, keyed by the block's id — the only thing about a block that
// survives one being inserted above it. Focus is asked for by id and honoured after
// the render that created the field, since an element that does not exist yet cannot
// take it.
const blockEls = new Map<number, HTMLTextAreaElement | HTMLInputElement>();
function setBlockEl(id: number, el: unknown): void {
if (el) blockEls.set(id, el as HTMLTextAreaElement);
else blockEls.delete(id);
}
async function focusBlock(id: number | null): Promise<void> {
if (id === null) return;
await nextTick();
const el = blockEls.get(id);
el?.focus();
if (el) el.selectionStart = el.selectionEnd = el.value.length;
}
/** A prose field sized to its text. `rows="1"` plus this beats guessing a row count,
* which is wrong the moment a line wraps. */
function grow(el: HTMLTextAreaElement): void {
el.style.height = "auto";
el.style.height = `${el.scrollHeight}px`;
}
function growAll(): void {
for (const el of blockEls.values()) {
if (el instanceof HTMLTextAreaElement) grow(el);
}
}
const fileInput = ref<HTMLInputElement | null>(null);
const uploadError = ref("");
// Baseline for edit-mode change detection (save only when text actually changed).
const baseline = ref<{ body: string; color: NoteColor }>({
body: props.note?.body ?? "",
color: (props.note?.color ?? "default") as NoteColor,
});
const isCreate = computed(() => noteId.value === null);
const hasContent = computed(() => 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/remind_at uniformly.
const draftNote = computed<Note>(() => ({
id: "",
display_title: "",
body: body.value,
color: color.value,
position: 0,
pinned: false,
archived: false,
trashed: false,
deleted_at: null,
remind_at: null,
recurrence: null,
labels: labelList.value,
items: [],
attachments: [],
previews: [],
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,
);
const bodyPlaceholder = "Take a note…";
// Keep local state in sync when the edited note changes (modal reused for another note).
watch(
() => props.note,
(n) => {
noteId.value = n?.id ?? null;
setBody(n?.body ?? "");
color.value = (n?.color ?? "default") as NoteColor;
labelList.value = n ? [...n.labels] : [];
baseline.value = { body: n?.body ?? "", color: (n?.color ?? "default") as NoteColor };
},
);
// ---- persistence ----
async function createFromFields(): Promise<void> {
const created = await notes.create({ body: body.value, color: color.value });
noteId.value = created.id;
baseline.value = { body: created.body, color: created.color as NoteColor };
}
// 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 = body.value;
const changed = nextBody !== b.body || color.value !== b.color;
if (!changed) return;
saving.value = true;
try {
await notes.saveEdit(noteId.value as string, { body: nextBody, color: color.value });
baseline.value = { body: nextBody, color: color.value };
} finally {
saving.value = false;
}
}
function resetCompose(): void {
noteId.value = null;
setBody("");
color.value = "default";
labelList.value = [];
baseline.value = { body: "", color: "default" };
uploadError.value = "";
}
// ---- commit / close ----
// Compose only: save the current note and start a fresh one (rapid capture).
async function commitAndContinue(): Promise<void> {
if (!hasContent.value) return;
await flush();
resetCompose();
await nextTick();
growAll();
const first = blocks.value[0];
await focusBlock(first ? first.id : null);
}
// ---- open/close animation (M7) ----
//
// Owned here rather than by each of the five views that render this component: the
// leave has to play BEFORE the host unmounts us, so the component has to control
// its own visibility and tell the host afterwards.
// Starts TRUE, with `appear` driving the entry animation. Starting false and
// flipping it on mount would be the obvious shape, but the panel lives inside this
// v-if — it wouldn't exist yet to measure.
const visible = ref(true);
const panel = ref<HTMLElement | null>(null);
/** Matches .editor-leave-active in style.css. */
const LEAVE_MS = 140;
onMounted(() => {
const from = takeMorphOrigin();
// Grow from the card that was clicked. Set as a transform-origin rather than
// animating between two rects — see useEditorMorph for why. No origin (compose,
// or a card that scrolled out of view) simply grows from its own centre.
if (!from || !panel.value) return;
// offsetLeft/offsetTop, NOT getBoundingClientRect: the enter-from class has
// already applied scale(0.94) by now, so the bounding rect is of the SHRUNKEN
// panel and the origin would land a few pixels off. Offsets are layout geometry
// and ignore transforms. The offset parent is the inset-0 backdrop, so these are
// effectively viewport coordinates — which is what the captured point is in.
panel.value.style.transformOrigin =
`${from.x - panel.value.offsetLeft}px ${from.y - panel.value.offsetTop}px`;
});
/** Play the leave, then let the host unmount us. */
async function finish(): Promise<void> {
if (prefersReducedMotion()) {
emit("close");
return;
}
visible.value = false;
await new Promise((resolve) => setTimeout(resolve, LEAVE_MS));
emit("close");
}
// Persist (create in compose, save in edit) and close the editor.
async function close(): Promise<void> {
await flush();
await finish();
}
// Esc / click-away DISMISS. A brand-new, not-yet-persisted note is DISCARDED — so an
// accidental keystroke or type-to-compose never litters the board. To keep a new note,
// commit it explicitly (Done, Ctrl/Cmd+Enter, or Shift+Enter). An existing note, or a
// compose already persisted by a rich action, closes normally (saving its text).
async function dismiss(): Promise<void> {
if (isCreate.value) {
await finish();
return;
}
await close();
}
function onEsc(): void {
void dismiss();
}
function onMetaEnter(): void {
// Ctrl/Cmd+Enter = finish & close, an explicit commit (matches email/chat "send").
void close();
}
function onBackdropMousedown(): void {
void dismiss();
}
onMounted(async () => {
await nextTick();
growAll();
// The LAST block, with the caret after its text: opening a note means continuing it,
// and type-to-compose seeds text that should be typed straight on from.
const last = blocks.value[blocks.value.length - 1];
await focusBlock(last ? last.id : null);
});
/** Editing one block: replace its text, leave every other block alone. */
function setText(index: number, text: string): void {
const out = [...blocks.value];
out[index] = { ...out[index], text };
blocks.value = out;
}
function setChecked(index: number, checked: boolean): void {
const out = [...blocks.value];
out[index] = { ...out[index], checked };
blocks.value = out;
}
function onProseInput(index: number, e: Event): void {
const el = e.target as HTMLTextAreaElement;
setText(index, el.value);
grow(el);
}
/**
* Leaving a prose block is when a `- [ ] ` typed by hand becomes a real item.
*
* See notes/blocks.ts for why blur is the only safe moment. The identity check is the
* contract `promoteTasks` offers: an untouched array back means nothing to promote, and
* reassigning the ref anyway would re-key every field below this one for no reason.
*
* `growAll` after the DOM settles, because the textarea being left is now shorter by
* however many lines became checkboxes and would otherwise keep its old height.
*/
function onProseBlur(index: number): void {
const promoted = promoteTasks(blocks.value, index);
if (promoted === blocks.value) return;
blocks.value = promoted;
void nextTick().then(growAll);
}
/** Compose: Shift+Enter saves the note and starts a fresh one (rapid capture). */
function onProseKeydown(e: KeyboardEvent): void {
if (isCreate.value && e.key === "Enter" && e.shiftKey) {
e.preventDefault();
void commitAndContinue();
}
}
/** Enter on an item makes the next one; on an EMPTY item it ends the list. */
function onTaskEnter(index: number): void {
const next = afterEnter(blocks.value, index);
blocks.value = next.blocks;
void focusBlock(next.focus);
}
/**
* Backspace at the very start of an EMPTY item removes it.
*
* Worth having on the web where Android's is not: a browser sends a real keydown for
* Backspace, while an Android soft keyboard sends an IME delete that never surfaces as
* one. Enter-on-empty ends a list on both surfaces; this is the extra way out that
* only one of them can offer.
*/
function onTaskBackspace(index: number, e: KeyboardEvent): void {
const el = e.target as HTMLInputElement;
if (el.value !== "" || el.selectionStart !== 0) return;
e.preventDefault();
removeBlock(index);
}
function removeBlock(index: number): void {
const next = withoutIndex(blocks.value, index);
blocks.value = next.blocks;
void focusBlock(next.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));
}
async function onRecurrenceChange(e: Event) {
const id = await ensureDraft();
if (!id) return;
await notes.setRecurrence(id, (e.target as HTMLSelectElement).value || null);
}
async function completeReminder() {
if (noteId.value) await notes.completeReminder(noteId.value);
}
async function snoozeReminder(minutes: number) {
if (noteId.value) await notes.snoozeReminder(noteId.value, minutes);
}
// ---- 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);
const tagIds = new Set(tagLabels.map((lb) => lb.id));
const merged = [...next.filter((lb) => !tagIds.has(lb.id)), ...tagLabels];
labelList.value = merged;
await notes.setLabels(
id,
merged.map((lb) => lb.id),
);
}
async function removeLabel(id: string) {
await onLabelsChange(labelList.value.filter((lb) => lb.id !== id));
}
// Takes the label, not its colour — a tag nobody has coloured derives one from its
// name. Must match NoteCard's chip exactly: the same tag either side of opening a
// note changing colour would be worse than both being grey.
function labelChip(label: { name: string; color: string }): string {
return LABEL_CHIP_CLASSES[resolveLabelColor(label)] ?? LABEL_CHIP_CLASSES.default;
}
// ---- add a checklist ----
//
// Appends an empty item and puts the caret in it. Unlike every other toolbar button
// this one needs NO persisted note to hang anything off — a checklist is part of the
// body (M304), so it works on an empty compose box the moment it opens.
//
// Appends rather than inserting at the caret because a block editor has no single
// caret to insert at: the field that had focus may not be the one being looked at by
// the time this runs.
function addChecklist(): void {
const next = plusTask(blocks.value);
blocks.value = next.blocks;
void focusBlock(next.focus);
}
// ---- attachments ----
function pickFile() {
fileInput.value?.click();
}
function attKind(mime: string): "image" | "audio" | "file" {
if (mime.startsWith("image/")) return "image";
if (mime.startsWith("audio/")) return "audio";
return "file";
}
function fmtSize(bytes?: number): string {
if (!bytes) return "";
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
async function uploadFile(file: File) {
const id = await ensureDraft();
if (!id) return;
uploadError.value = "";
try {
await notes.uploadAttachment(id, file);
} catch (e) {
uploadError.value = (e as { error?: string }).error ?? "Could not upload file.";
}
}
// ---- link previews ----
//
// Nothing to trigger any more: the server unfurls a note's URLs in the background
// after each save (`unfurl_queue.py`) and the preview arrives on a later read. What
// is left here is removing one you don't want — the editor is the only place with
// room to offer that, and the card deliberately doesn't.
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();
if (file) {
e.preventDefault();
await uploadFile(file);
}
}
// ---- edit-mode lifecycle actions (pin/archive/trash/restore/delete) ----
async function act(fn: () => Promise<void>) {
await fn();
await finish();
}
// ---- version history (modal edit only) ----
const showHistory = ref(false);
const revisions = ref<NoteRevision[]>([]);
async function loadRevisions() {
if (!noteId.value) {
revisions.value = [];
return;
}
try {
revisions.value = await notes.fetchRevisions(noteId.value);
} catch {
revisions.value = [];
}
}
function toggleHistory() {
showHistory.value = !showHistory.value;
if (showHistory.value) void loadRevisions();
}
async function restoreRevisionAt(revId: string) {
const id = noteId.value;
if (!id) return;
const updated = await notes.restoreRevision(id, revId);
setBody(updated.body);
color.value = updated.color;
baseline.value = { body: updated.body, color: updated.color };
void loadRevisions(); // the pre-restore state became a new revision
}
function revLabel(iso: string | null): string {
if (!iso) return "";
return new Date(iso).toLocaleString(undefined, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" });
}
function revPreview(rev: NoteRevision): string {
const s = rev.body.trim().replace(/\s+/g, " ");
if (!s) return "(empty)";
return s.length > 80 ? `${s.slice(0, 80)}…` : s;
}
</script>
<template>
<!-- `appear` because we mount already-open: the host renders us with v-if, so the
enter has to fire on the first frame rather than on a later state change. -->
<Transition name="editor" appear>
<div
v-if="visible"
ref="root"
class="fixed inset-0 z-40 flex items-start justify-center overflow-y-auto bg-black/40 p-4 pt-[10vh]"
@mousedown.self="onBackdropMousedown"
>
<!-- One editor card for BOTH compose (empty note) and edit a single surface.
`editor-panel` is the animation's handle: the backdrop only fades, while
this scales from the card that opened it (see useEditorMorph). -->
<div
ref="panel"
class="editor-panel 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"
@paste="onPaste"
>
<div class="flex flex-col gap-2 p-4">
<div v-if="liveNote.attachments.length" class="flex flex-wrap items-center gap-2">
<template v-for="att in liveNote.attachments" :key="att.id">
<!-- Image → inline thumbnail -->
<div v-if="attKind(att.mime) === 'image'" class="group/att relative">
<img :src="att.url" alt="" loading="lazy" decoding="async" class="h-24 w-24 rounded-lg object-cover" />
<button
type="button"
class="hover-reveal 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 attachment"
@click="notes.deleteAttachment(liveNote.id, att.id)"
>
×
</button>
</div>
<!-- Audio → inline player -->
<div
v-else-if="attKind(att.mime) === 'audio'"
class="flex items-center gap-2 rounded-lg border border-neutral-200 px-2 py-1 dark:border-neutral-700"
>
<audio controls :src="att.url" class="h-8 max-w-[220px]"></audio>
<button
type="button"
class="text-neutral-400 hover:text-red-500"
aria-label="Remove attachment"
@click="notes.deleteAttachment(liveNote.id, att.id)"
>
×
</button>
</div>
<!-- Any other file → download chip -->
<a
v-else
:href="att.url"
download
class="flex items-center gap-2 rounded-lg border border-neutral-200 px-3 py-2 text-xs hover:bg-neutral-50 dark:border-neutral-700 dark:hover:bg-neutral-800"
>
<Icon name="paperclip" />
<span class="max-w-[160px] truncate">{{ att.filename || "file" }}</span>
<span v-if="att.size" class="text-neutral-400">{{ fmtSize(att.size) }}</span>
<button
type="button"
class="ml-1 text-neutral-400 hover:text-red-500"
aria-label="Remove attachment"
@click.prevent.stop="notes.deleteAttachment(liveNote.id, att.id)"
>
×
</button>
</a>
</template>
</div>
<p v-if="uploadError" class="text-xs text-red-600 dark:text-red-400">{{ uploadError }}</p>
<!-- Fetched automatically after each save; removable here and nowhere else. -->
<div v-if="liveNote.previews.length" class="flex flex-col gap-2">
<LinkPreview
v-for="p in liveNote.previews"
:key="p.id"
:preview="p"
:removable="!liveNote.trashed"
@remove="notes.deletePreview(liveNote.id, p.id)"
/>
</div>
<!-- The body, as fields and checkboxes rather than as markup. A checklist
item is a real input; a run of prose is one textarea, so typing a
paragraph still feels like typing a paragraph. -->
<div class="flex flex-col gap-1">
<template v-for="(block, i) in blocks" :key="block.id">
<div v-if="block.checked !== null" class="group/item flex items-center gap-2">
<input
type="checkbox"
class="h-4 w-4 shrink-0 accent-brand"
:checked="block.checked"
:aria-label="block.text || 'Checklist item'"
@change="setChecked(i, ($event.target as HTMLInputElement).checked)"
/>
<input
:ref="(el) => setBlockEl(block.id, el)"
:value="block.text"
type="text"
class="min-w-0 flex-1 bg-transparent text-sm leading-relaxed outline-none"
:class="block.checked ? 'text-neutral-400 line-through' : ''"
@input="setText(i, ($event.target as HTMLInputElement).value)"
@keydown.enter.prevent="onTaskEnter(i)"
@keydown.backspace="onTaskBackspace(i, $event)"
/>
<button
type="button"
class="hover-reveal shrink-0 text-neutral-300 opacity-0 hover:text-neutral-600 focus:opacity-100 group-hover/item:opacity-100 dark:hover:text-neutral-200"
aria-label="Delete item"
@click="removeBlock(i)"
>
×
</button>
</div>
<textarea
v-else
:ref="(el) => setBlockEl(block.id, el)"
:value="block.text"
rows="1"
:placeholder="i === 0 ? bodyPlaceholder : ''"
class="w-full resize-none overflow-hidden bg-transparent text-sm leading-relaxed outline-none placeholder:text-neutral-400"
@input="onProseInput(i, $event)"
@keydown="onProseKeydown"
@blur="onProseBlur(i)"
/>
</template>
</div>
<!-- No checklist component. The items are lines of the textarea above, which
is what lets a list sit between two paragraphs (M304). -->
<div v-if="labelList.length" class="flex flex-wrap gap-1.5 pt-1">
<span
v-for="lb in labelList"
:key="lb.id"
class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs"
:class="labelChip(lb)"
>
<!-- `#` on every chip, matching the card. This row still lists the tags
the BODY owns too — it is the control surface, and `via_tag` is what
decides whether there is a cross to remove one with. -->
#{{ lb.name }}
<button
v-if="!lb.via_tag"
type="button"
class="text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-100"
:aria-label="`Remove ${lb.name}`"
@click="removeLabel(lb.id)"
>
×
</button>
</span>
</div>
<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 [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(liveNote.id, null)"
>
Clear
</button>
</div>
<div v-if="richEnabled && liveNote.remind_at" class="flex flex-wrap items-center gap-2 pl-6 text-xs">
<label class="text-neutral-400">Repeat</label>
<select
:value="liveNote.recurrence ?? ''"
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"
@change="onRecurrenceChange"
>
<option value="">Does not repeat</option>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
<option value="monthly">Monthly</option>
<option value="yearly">Yearly</option>
</select>
<button
type="button"
class="rounded-md border border-neutral-300 px-2 py-1 text-neutral-600 hover:bg-neutral-100 dark:border-neutral-700 dark:text-neutral-300 dark:hover:bg-neutral-800"
@click="completeReminder"
>
Done
</button>
<button type="button" class="text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-200" @click="snoozeReminder(60)">
Snooze 1h
</button>
<button type="button" class="text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-200" @click="snoozeReminder(1440)">
1d
</button>
</div>
</div>
<div
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>
<p v-if="!revisions.length" class="text-xs text-neutral-400">
No earlier versions yet — your edits will show up here.
</p>
<ul v-else class="flex max-h-40 flex-col gap-0.5 overflow-y-auto">
<li v-for="rev in revisions" :key="rev.id" class="flex items-center gap-2 rounded-md px-1 py-1 text-xs">
<span class="shrink-0 tabular-nums text-neutral-400">{{ revLabel(rev.created_at) }}</span>
<span class="min-w-0 flex-1 truncate text-neutral-600 dark:text-neutral-300">{{ revPreview(rev) }}</span>
<button
type="button"
class="shrink-0 rounded px-1.5 py-0.5 font-medium text-brand-700 hover:bg-brand/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand dark:text-brand"
@click="restoreRevisionAt(rev.id)"
>
Restore
</button>
</li>
</ul>
</div>
</div>
<div class="flex items-center justify-between gap-2 border-t border-neutral-100 px-3 py-2 dark:border-neutral-800">
<ColorPicker v-model="color" />
<div class="flex items-center gap-0.5">
<button
v-if="richEnabled && !liveNote.trashed"
type="button"
class="icon-btn"
title="Attach a file"
aria-label="Attach a file"
@click="pickFile"
>
<Icon name="paperclip" />
</button>
<input ref="fileInput" type="file" class="hidden" @change="onFileChange" />
<button
v-if="!liveNote.trashed"
type="button"
class="icon-btn"
title="Add a checklist"
aria-label="Add a checklist"
@click="addChecklist"
>
<Icon name="checkbox" />
</button>
<LabelPicker
v-if="richEnabled && !liveNote.trashed"
:model-value="labelList"
@update:model-value="onLabelsChange"
/>
<button
v-if="!isCreate"
type="button"
class="icon-btn"
:class="showHistory ? 'text-brand-700 dark:text-brand' : ''"
title="Version history"
aria-label="Version history"
:aria-pressed="showHistory"
@click="toggleHistory"
>
<Icon name="history" />
</button>
<template v-if="!isCreate && !liveNote.trashed">
<button
type="button"
class="icon-btn"
: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="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(liveNote.id))"
>
<Icon name="trash" />
</button>
</template>
<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>
<button
type="button"
class="icon-btn"
title="Delete forever"
@click="act(() => notes.deleteForever(liveNote.id))"
>
<Icon name="trash" />
</button>
</template>
<button
type="button"
:class="
isCreate
? 'rounded-md bg-brand px-3 py-1.5 text-sm font-semibold text-neutral-900 hover:brightness-95 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand disabled:opacity-60'
: '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="close()"
>
{{ isCreate ? "Add note" : "Close" }}
</button>
</div>
</div>
</div>
</Transition>
</template>