The tag row under the text was a copy of the note's labels taken when the editor opened. A #tag in the body becomes a tag on the server's side of a save, so the copy never learned of it until the note was reopened. Every save now hands its note back, and the row is refreshed from it. Typing # in a prose block or a checklist item opens the tags that already exist: prefix matches first, busiest first. Arrows move, Enter or Tab puts the tag in, Esc closes only the menu. The grammar and ranking live in notes/tags.ts with tests, so the menu never offers a tag the server would not derive. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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<NoteLabel[]>(props.note ? [...props.note.labels] : []);
|
||||
function takeLabels(saved: Note): void {
|
||||
labelList.value = [...saved.labels];
|
||||
}
|
||||
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
|
||||
@@ -113,7 +123,8 @@ const liveNote = computed<Note>(() =>
|
||||
? (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<void> {
|
||||
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<void> {
|
||||
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 {
|
||||
<!-- 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">
|
||||
<div class="relative 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
|
||||
@@ -664,9 +775,11 @@ function revPreview(rev: NoteRevision): string {
|
||||
:readonly="!editable"
|
||||
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)"
|
||||
@input="onTaskInput(i, $event)"
|
||||
@keydown="onTaskKeydown(i, $event)"
|
||||
@keyup="onFieldKeyup(i, $event)"
|
||||
@click="readTag(i, $event.target as HTMLInputElement)"
|
||||
@blur="closeTagMenu"
|
||||
/>
|
||||
<button
|
||||
v-if="editable"
|
||||
@@ -688,9 +801,44 @@ function revPreview(rev: NoteRevision): string {
|
||||
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"
|
||||
@keyup="onFieldKeyup(i, $event)"
|
||||
@click="readTag(i, $event.target as HTMLTextAreaElement)"
|
||||
@blur="onProseBlur(i)"
|
||||
/>
|
||||
</template>
|
||||
<!-- The tags that match what follows a `#`. mousedown.prevent keeps focus
|
||||
in the field, so picking one doesn't blur it and close this first. -->
|
||||
<ul
|
||||
v-if="tagAt && tagMatches.length"
|
||||
role="listbox"
|
||||
aria-label="Matching tags"
|
||||
class="absolute left-0 z-10 mt-1 max-h-56 w-60 overflow-y-auto rounded-lg border border-neutral-200 bg-white p-1 shadow-lg dark:border-neutral-700 dark:bg-neutral-800"
|
||||
:style="{ top: `${tagMenuTop}px` }"
|
||||
>
|
||||
<li v-for="(t, k) in tagMatches" :key="t.id" role="option" :aria-selected="k === tagPick">
|
||||
<button
|
||||
type="button"
|
||||
tabindex="-1"
|
||||
class="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm"
|
||||
:class="
|
||||
k === tagPick
|
||||
? 'bg-brand/15 text-brand-700 dark:text-brand'
|
||||
: 'hover:bg-neutral-100 dark:hover:bg-neutral-700'
|
||||
"
|
||||
@mousemove="tagPick = k"
|
||||
@mousedown.prevent="pickTag(t.name)"
|
||||
>
|
||||
<span
|
||||
class="h-2.5 w-2.5 shrink-0 rounded-full border border-black/10 dark:border-white/15"
|
||||
:class="labelDotClasses(t)"
|
||||
></span>
|
||||
<span class="truncate">#{{ t.name }}</span>
|
||||
<span v-if="t.count" class="ml-auto shrink-0 text-xs tabular-nums text-neutral-400">{{
|
||||
t.count
|
||||
}}</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<!-- No checklist component. The items are blocks above, each one line of the
|
||||
body, which is what lets a list sit between two paragraphs (M304). -->
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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<T extends { name: string; count?: number }>(
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -158,8 +158,10 @@ export const useNotesStore = defineStore("notes", () => {
|
||||
async function mutate(
|
||||
id: string,
|
||||
changes: Partial<Pick<Note, "body" | "pinned" | "archived" | "remind_at" | "recurrence">>,
|
||||
): Promise<void> {
|
||||
reconcile(await repo.notes.update(id, changes));
|
||||
): Promise<Note> {
|
||||
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<void> {
|
||||
reconcile(await repo.notes.setLabels(id, labelIds));
|
||||
async function setLabels(id: string, labelIds: string[]): Promise<Note> {
|
||||
const note = await repo.notes.setLabels(id, labelIds);
|
||||
reconcile(note);
|
||||
return note;
|
||||
}
|
||||
|
||||
async function updateItem(id: string, itemId: string, changes: { checked: boolean }): Promise<void> {
|
||||
|
||||
Reference in New Issue
Block a user