Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
Android / Kotlin + Rust (APK) (push) Skipped
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 10s
CI & Build / integration (push) Successful in 22s
CI & Build / Build & push image (push) Successful in 36s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 1m59s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m29s
Desktop (Tauri) / Update manifest (push) Successful in 5s
#2971's engine work was already done and its benefit was never taken up here. Both engines coalesce revision snapshots to one per editing session — `src/thoughtsync/revisions.py::should_snapshot` and `store.rs`'s namesake, the server's applied on the PATCH path AND in `sync.py`, with four integration tests covering it. So a write has cost a write, not a write plus a revision, for some time. But this editor still wrote only on `close()`. That save-on-close existed BECAUSE writes were expensive; with the reason gone, all that was left was the cost — a tab closed mid-paragraph lost the paragraph, which is the one thing a notes app must not do. Android already debounces (`BoardViewModel`); the shared Vue editor did not, so web and desktop kept paying for a trade that had been cancelled. Now: a 1s idle pause writes. EDIT MODE ONLY, deliberately. In compose, `dismiss` discards a note that was never persisted so an accidental keystroke or a type-to-compose never litters the board. An autosave there would create the row and quietly take that behaviour away. Materialising a compose on first keystroke is a separate decision (#2967), not a side effect of this one. Three details that decide whether it is safe rather than merely present: * `flush` returns without writing while a save is in flight, so an autosave landing there would silently drop everything typed since that save began. It RE-ARMS instead of skipping. * Errors are swallowed and retried on the next pause. An autosave that interrupts typing with a message is worse than one that waits, and `close` still surfaces a real failure where the person is looking. * The timer is cancelled by `close`, by `dismiss` and on unmount, so nothing fires through a component during its leave animation or after it is gone. Checked and found harmless rather than assumed: `notes.reconcile` replaces the store's item but never touches `useNoteEditor`'s `editing` ref, so the `watch(() => props.note)` that calls `setBody` does not fire on a save. Were that not true, autosaving would have reset the field and the caret every second. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K3MMqUtzX1TJgA1oypvm1c
869 lines
33 KiB
Vue
869 lines
33 KiB
Vue
<script setup lang="ts">
|
||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||
import { useNotesStore } from "../stores/notes";
|
||
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 { labelChipClasses } 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 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 }>({ body: props.note?.body ?? "" });
|
||
|
||
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,
|
||
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 ?? "");
|
||
labelList.value = n ? [...n.labels] : [];
|
||
baseline.value = { body: n?.body ?? "" };
|
||
},
|
||
);
|
||
|
||
// ---- persistence ----
|
||
async function createFromFields(): Promise<void> {
|
||
const created = await notes.create({ body: body.value });
|
||
noteId.value = created.id;
|
||
baseline.value = { body: created.body };
|
||
}
|
||
|
||
// 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;
|
||
if (!changed) return;
|
||
saving.value = true;
|
||
try {
|
||
await notes.saveEdit(noteId.value as string, { body: nextBody });
|
||
baseline.value = { body: nextBody };
|
||
} finally {
|
||
saving.value = false;
|
||
}
|
||
}
|
||
|
||
// ---- idle autosave ----
|
||
//
|
||
// This editor used to write ONLY on close, and the reason was cost: a body write
|
||
// snapshotted a revision, so saving often meant a version history of thirty
|
||
// snapshots of one paragraph being typed. The price was durability — a tab closed
|
||
// mid-paragraph lost the paragraph, which is the one thing a notes app must not do.
|
||
//
|
||
// That trade is gone. Both engines now coalesce snapshots to one per editing
|
||
// session (`revisions.py` and `store.rs`'s `should_snapshot`, Scribe #2971), so a
|
||
// write costs a write. Writing on an idle pause is what collects the refund; the
|
||
// Android editor already does the same.
|
||
const AUTOSAVE_MS = 1000;
|
||
let autosaveTimer: ReturnType<typeof setTimeout> | null = null;
|
||
|
||
function cancelAutosave(): void {
|
||
if (autosaveTimer !== null) {
|
||
clearTimeout(autosaveTimer);
|
||
autosaveTimer = null;
|
||
}
|
||
}
|
||
|
||
async function autosave(): Promise<void> {
|
||
// `flush` returns without writing while a save is in flight, which would silently
|
||
// drop everything typed since that save began. Re-arming rather than skipping is
|
||
// what keeps that from being a lost paragraph.
|
||
if (saving.value) {
|
||
scheduleAutosave();
|
||
return;
|
||
}
|
||
try {
|
||
await flush();
|
||
} catch {
|
||
// Swallowed on purpose. An autosave that interrupts typing with an error is
|
||
// worse than one that waits for the next pause, and `close` still surfaces a
|
||
// real failure at the moment the person is looking at the editor.
|
||
}
|
||
}
|
||
|
||
function scheduleAutosave(): void {
|
||
cancelAutosave();
|
||
autosaveTimer = setTimeout(() => {
|
||
autosaveTimer = null;
|
||
void autosave();
|
||
}, AUTOSAVE_MS);
|
||
}
|
||
|
||
// EDIT mode only, deliberately. In compose, `dismiss` discards a note that was
|
||
// never persisted, so that an accidental keystroke or a type-to-compose never
|
||
// litters the board — and an autosave that created the row would take that away
|
||
// without anyone asking for it. Materialising a compose on first keystroke is a
|
||
// separate decision (Scribe #2967), not a side effect of this one.
|
||
watch(body, () => {
|
||
if (!isCreate.value) scheduleAutosave();
|
||
});
|
||
|
||
onBeforeUnmount(cancelAutosave);
|
||
|
||
function resetCompose(): void {
|
||
noteId.value = null;
|
||
setBody("");
|
||
labelList.value = [];
|
||
baseline.value = { body: "" };
|
||
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> {
|
||
// Cancelled first: a timer that fires during the leave animation would write
|
||
// through a component on its way out, after `flush` has already saved the same
|
||
// text.
|
||
cancelAutosave();
|
||
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> {
|
||
cancelAutosave();
|
||
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));
|
||
}
|
||
// ---- 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);
|
||
baseline.value = { body: updated.body };
|
||
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="labelChipClasses(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>
|
||
|
||
<!-- `justify-end`, not `justify-between`: the colour picker sat on the left of
|
||
this row until M315 and the row was balanced around it. With one child left,
|
||
`between` would push the actions to the far left of a full-width bar. -->
|
||
<div class="flex items-center justify-end gap-2 border-t border-neutral-100 px-3 py-2 dark:border-neutral-800">
|
||
<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>
|