diff --git a/frontend/src/components/NoteEditor.vue b/frontend/src/components/NoteEditor.vue index 09f440b..30e6973 100644 --- a/frontend/src/components/NoteEditor.vue +++ b/frontend/src/components/NoteEditor.vue @@ -13,7 +13,9 @@ import { RECURRENCES } from "../notes/recurrence"; import { takeMorphOrigin } from "../composables/useEditorMorph"; import { prefersReducedMotion } from "../composables/useReducedMotion"; import type { Note, NoteLabel, NoteRevision } from "../stores/notes"; -import { labelChipClasses } from "../notes/colors"; +import { labelChipClasses, labelDotClasses } from "../notes/colors"; +import { insertTag, suggestTags, tagQueryAt, type TagQuery } from "../notes/tags"; +import { useLabelsStore } from "../stores/labels"; import { canEditText, isOwnNote } from "../notes/sharing"; import { afterEnter, @@ -46,7 +48,15 @@ const body = computed(() => joinBlocks(blocks.value)); function setBody(text: string): void { blocks.value = splitBlocks(text); } +// The tag row. Refreshed from every note the server hands back, not only taken when +// the editor opens: a `#tag` typed into the body becomes a tag on the SERVER's side of +// a save, so the copy taken at mount never learns of it (#5913). The store's own copy +// can't stand in for this, because a note that leaves the current lens on save (a tag +// removed while looking at that tag) is dropped from the store. const labelList = ref(props.note ? [...props.note.labels] : []); +function takeLabels(saved: Note): void { + labelList.value = [...saved.labels]; +} const saving = ref(false); const root = ref(null); // One element per block, keyed by the block's id — the only thing about a block that @@ -113,7 +123,8 @@ const liveNote = computed(() => ? (notes.items.find((n) => n.id === noteId.value) ?? props.note ?? draftNote.value) : draftNote.value, ); -const bodyPlaceholder = "Take a note…"; +// The one hint that `#` does anything, short of typing one (Scribe #2949). +const bodyPlaceholder = "Take a note… # to tag it"; // Sharing (#5174). Someone else's note: at `edit` its text and checklist are theirs to // change and nothing else is; at `view` it is read-only. The server enforces both — @@ -145,6 +156,7 @@ async function createFromFields(): Promise { const created = await notes.create({ body: body.value }); noteId.value = created.id; baseline.value = { body: created.body }; + takeLabels(created); } // Ensure a persisted note exists (for rich actions mid-compose). Returns its id, or @@ -175,8 +187,9 @@ async function flush(): Promise { if (!changed) return; saving.value = true; try { - await notes.saveEdit(noteId.value as string, { body: nextBody }); + const saved = await notes.saveEdit(noteId.value as string, { body: nextBody }); baseline.value = { body: nextBody }; + takeLabels(saved); } finally { saving.value = false; } @@ -355,6 +368,89 @@ function onProseInput(index: number, e: Event): void { const el = e.target as HTMLTextAreaElement; setText(index, el.value); grow(el); + readTag(index, el); +} + +function onTaskInput(index: number, e: Event): void { + const el = e.target as HTMLInputElement; + setText(index, el.value); + readTag(index, el); +} + +// ---- # tag suggestions ---- +// +// Typing `#` in any field opens a list of the tags that already exist, so a note is +// filed under `#groceries` rather than a new `#grocery` beside it. Which text counts +// as a tag being typed, which tags are offered and how one is put in are all in +// notes/tags.ts, where they are tested; this is only the menu. +const labels = useLabelsStore(); +const tagAt = ref<{ index: number; query: TagQuery } | null>(null); +const tagPick = ref(0); +// Where the menu sits: under the field being typed in. Measured against the block +// list, which is the menu's positioned parent. +const tagMenuTop = ref(0); +const tagMatches = computed(() => (tagAt.value ? suggestTags(labels.items, tagAt.value.query.query) : [])); + +function readTag(index: number, el: HTMLTextAreaElement | HTMLInputElement): void { + const caret = el.selectionStart ?? el.value.length; + const query = editable.value && el.selectionEnd === caret ? tagQueryAt(el.value, caret) : null; + if (!query) { + tagAt.value = null; + return; + } + // The capture window has no shell around it to load the tags first. + if (!labels.loaded) void labels.load(); + if (tagAt.value?.index !== index || tagAt.value.query.start !== query.start) tagPick.value = 0; + tagAt.value = { index, query }; + tagMenuTop.value = el.offsetTop + el.offsetHeight; +} + +/** Moving the caret without typing can leave a tag or enter one. */ +function onFieldKeyup(index: number, e: KeyboardEvent): void { + if (["ArrowLeft", "ArrowRight", "Home", "End"].includes(e.key)) { + readTag(index, e.target as HTMLTextAreaElement | HTMLInputElement); + } +} + +function pickTag(name: string): void { + const at = tagAt.value; + if (!at) return; + const block = blocks.value[at.index]; + const next = insertTag(block.text, at.query, name); + setText(at.index, next.text); + tagAt.value = null; + const el = blockEls.get(block.id); + void nextTick(() => { + if (!el) return; + el.focus(); + el.setSelectionRange(next.caret, next.caret); + if (el instanceof HTMLTextAreaElement) grow(el); + }); +} + +/** + * The menu's keys, taken before the field's own. True when the key was the menu's. + * + * Esc closes only the menu: stopped here, it never reaches the panel's Esc, which + * would close the whole editor. Enter with a modifier is left alone, since those are + * the editor's save shortcuts. + */ +function onTagKey(e: KeyboardEvent): boolean { + const options = tagMatches.value; + if (!tagAt.value || !options.length) return false; + if (e.key === "ArrowDown") tagPick.value = (tagPick.value + 1) % options.length; + else if (e.key === "ArrowUp") tagPick.value = (tagPick.value - 1 + options.length) % options.length; + else if ((e.key === "Enter" || e.key === "Tab") && !e.shiftKey && !e.ctrlKey && !e.metaKey) + pickTag(options[tagPick.value].name); + else if (e.key === "Escape") tagAt.value = null; + else return false; + e.preventDefault(); + e.stopPropagation(); + return true; +} + +function closeTagMenu(): void { + tagAt.value = null; } /** @@ -368,6 +464,7 @@ function onProseInput(index: number, e: Event): void { * however many lines became checkboxes and would otherwise keep its old height. */ function onProseBlur(index: number): void { + closeTagMenu(); if (!editable.value) return; const promoted = promoteTasks(blocks.value, index); if (promoted === blocks.value) return; @@ -377,12 +474,23 @@ function onProseBlur(index: number): void { /** Compose: Shift+Enter saves the note and starts a fresh one (rapid capture). */ function onProseKeydown(e: KeyboardEvent): void { + if (onTagKey(e)) return; if (isCreate.value && e.key === "Enter" && e.shiftKey) { e.preventDefault(); void commitAndContinue(); } } +function onTaskKeydown(index: number, e: KeyboardEvent): void { + if (onTagKey(e)) return; + if (e.key === "Enter") { + e.preventDefault(); + onTaskEnter(index); + } else if (e.key === "Backspace") { + onTaskBackspace(index, e); + } +} + /** Enter on an item makes the next one; on an EMPTY item it ends the list. */ function onTaskEnter(index: number): void { if (!editable.value) return; @@ -436,9 +544,11 @@ async function onLabelsChange(next: NoteLabel[]) { 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), + takeLabels( + await notes.setLabels( + id, + merged.map((lb) => lb.id), + ), ); } async function removeLabel(id: string) { @@ -526,6 +636,7 @@ async function restoreRevisionAt(revId: string) { if (!id) return; const updated = await notes.restoreRevision(id, revId); setBody(updated.body); + takeLabels(updated); baseline.value = { body: updated.body }; void loadRevisions(); // the pre-restore state became a new revision } @@ -646,7 +757,7 @@ function revPreview(rev: NoteRevision): string { -
+
+ +
    +
  • + +
  • +
diff --git a/frontend/src/notes/tags.test.ts b/frontend/src/notes/tags.test.ts new file mode 100644 index 0000000..02cae9e --- /dev/null +++ b/frontend/src/notes/tags.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; + +import { insertTag, suggestTags, tagQueryAt } from "./tags"; + +describe("the tag being typed", () => { + it("is found at the caret, with what has been typed so far", () => { + expect(tagQueryAt("buy milk #gro", 13)).toEqual({ start: 9, end: 13, query: "gro" }); + }); + + it("counts a bare # at the start or after a space", () => { + expect(tagQueryAt("#", 1)).toEqual({ start: 0, end: 1, query: "" }); + expect(tagQueryAt("milk #", 6)).toEqual({ start: 5, end: 6, query: "" }); + }); + + it("runs on past the caret to the end of the word", () => { + expect(tagQueryAt("#grocery list", 3)).toEqual({ start: 0, end: 8, query: "gr" }); + }); + + it("is not a tag where the grammar says it is not", () => { + expect(tagQueryAt("a#b", 3)).toBeNull(); // no space before the # + expect(tagQueryAt("#2024", 5)).toBeNull(); // starts with a digit + expect(tagQueryAt("##x", 3)).toBeNull(); + expect(tagQueryAt("# heading", 9)).toBeNull(); // a space ends it + expect(tagQueryAt("plain words", 5)).toBeNull(); + }); +}); + +describe("which tags are offered", () => { + const tags = [ + { name: "grocery", count: 3 }, + { name: "groceries", count: 9 }, + { name: "hungry", count: 1 }, + { name: "to do", count: 50 }, + { name: "Café", count: 2 }, + ]; + + it("puts names that start with the query first, busiest first", () => { + expect(suggestTags(tags, "gr").map((t) => t.name)).toEqual(["groceries", "grocery", "hungry"]); + }); + + it("matches without regard to case", () => { + expect(suggestTags(tags, "CAF").map((t) => t.name)).toEqual(["Café"]); + }); + + it("leaves out a tag already typed in full, and names the body can't hold", () => { + expect(suggestTags(tags, "grocery").map((t) => t.name)).toEqual([]); + expect(suggestTags(tags, "").map((t) => t.name)).not.toContain("to do"); + }); +}); + +describe("inserting a picked tag", () => { + it("replaces the whole token and leaves the caret after a space", () => { + const text = "buy #gr milk"; + const at = tagQueryAt(text, 7)!; + expect(insertTag(text, at, "groceries")).toEqual({ text: "buy #groceries milk", caret: 14 }); + }); + + it("adds a space at the end of the text", () => { + const at = tagQueryAt("#gr", 3)!; + expect(insertTag("#gr", at, "grocery")).toEqual({ text: "#grocery ", caret: 9 }); + }); +}); diff --git a/frontend/src/notes/tags.ts b/frontend/src/notes/tags.ts index 7fa64b2..5db74fe 100644 --- a/frontend/src/notes/tags.ts +++ b/frontend/src/notes/tags.ts @@ -4,3 +4,71 @@ export function foldTag(name: string): string { return name.toLowerCase(); } + +// The body-tag grammar, as `line_tags` in core/src/local/derive.rs defines it and +// `INLINE_RE` in markdown.ts mirrors it: a `#` at the start or after whitespace, a +// letter, then letters, digits, `_` and `-`. +const TAG_CHAR = /[\p{Alphabetic}\p{N}_-]/u; +const WHOLE_TAG = /^\p{Alphabetic}[\p{Alphabetic}\p{N}_-]*$/u; + +/** The `#tag` the caret is in the middle of typing: where its `#` is, where the + * token ends (it may run on past the caret), and the part typed so far. */ +export interface TagQuery { + start: number; + end: number; + query: string; +} + +/** + * The tag being typed at `caret`, or null when the caret is not in one. + * + * A bare `#` counts, with an empty query: that is the moment a suggestion list is + * most use, because it is the moment someone has not yet decided what to type. + * Anything the grammar would not derive (`a#b`, `#2024`, `##x`) is not a tag here + * either, so the menu never offers to finish something the server would ignore. + */ +export function tagQueryAt(text: string, caret: number): TagQuery | null { + let i = caret; + while (i > 0 && TAG_CHAR.test(text[i - 1])) i--; + if (i === 0 || text[i - 1] !== "#") return null; + const hash = i - 1; + if (hash > 0 && !/\s/.test(text[hash - 1])) return null; + const query = text.slice(i, caret); + if (query && !/^\p{Alphabetic}/u.test(query)) return null; + let end = caret; + while (end < text.length && TAG_CHAR.test(text[end])) end++; + return { start: hash, end, query }; +} + +/** + * The existing tags worth offering for `query`, best first. + * + * Names that start with what was typed come before names that merely contain it; + * within each, the tag on the most notes first. A tag already typed out in full is + * left out, so Enter after a finished `#grocery` is a new line rather than a + * pointless completion. A tag whose name the body grammar can't hold (one made in + * the picker as "to do") is left out too: inserting `#to do` would derive `#to`. + */ +export function suggestTags( + tags: readonly T[], + query: string, + limit = 8, +): T[] { + const q = foldTag(query); + const prefix = (t: T) => (foldTag(t.name).startsWith(q) ? 0 : 1); + return tags + .filter((t) => { + const f = foldTag(t.name); + return WHOLE_TAG.test(t.name) && f !== q && f.includes(q); + }) + .sort((a, b) => prefix(a) - prefix(b) || (b.count ?? 0) - (a.count ?? 0) || a.name.localeCompare(b.name)) + .slice(0, limit); +} + +/** `text` with the tag at `at` replaced by `#name`, and where the caret goes after + * it. A space follows unless one is already there, so typing simply continues. */ +export function insertTag(text: string, at: TagQuery, name: string): { text: string; caret: number } { + const after = text.slice(at.end); + const insertion = `#${name}${/^\s/.test(after) ? "" : " "}`; + return { text: text.slice(0, at.start) + insertion + after, caret: at.start + insertion.length }; +} diff --git a/frontend/src/stores/notes.ts b/frontend/src/stores/notes.ts index 9a7af2c..745b8b2 100644 --- a/frontend/src/stores/notes.ts +++ b/frontend/src/stores/notes.ts @@ -158,8 +158,10 @@ export const useNotesStore = defineStore("notes", () => { async function mutate( id: string, changes: Partial>, - ): Promise { - reconcile(await repo.notes.update(id, changes)); + ): Promise { + const note = await repo.notes.update(id, changes); + reconcile(note); + return note; } const setPinned = (id: string, pinned: boolean) => mutate(id, { pinned }); @@ -179,8 +181,10 @@ export const useNotesStore = defineStore("notes", () => { reconcile(await repo.notes.snoozeReminder(id, minutes)); } - async function setLabels(id: string, labelIds: string[]): Promise { - reconcile(await repo.notes.setLabels(id, labelIds)); + async function setLabels(id: string, labelIds: string[]): Promise { + const note = await repo.notes.setLabels(id, labelIds); + reconcile(note); + return note; } async function updateItem(id: string, itemId: string, changes: { checked: boolean }): Promise {