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 { takeMorphOrigin } from "../composables/useEditorMorph";
|
||||||
import { prefersReducedMotion } from "../composables/useReducedMotion";
|
import { prefersReducedMotion } from "../composables/useReducedMotion";
|
||||||
import type { Note, NoteLabel, NoteRevision } from "../stores/notes";
|
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 { canEditText, isOwnNote } from "../notes/sharing";
|
||||||
import {
|
import {
|
||||||
afterEnter,
|
afterEnter,
|
||||||
@@ -46,7 +48,15 @@ const body = computed(() => joinBlocks(blocks.value));
|
|||||||
function setBody(text: string): void {
|
function setBody(text: string): void {
|
||||||
blocks.value = splitBlocks(text);
|
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] : []);
|
const labelList = ref<NoteLabel[]>(props.note ? [...props.note.labels] : []);
|
||||||
|
function takeLabels(saved: Note): void {
|
||||||
|
labelList.value = [...saved.labels];
|
||||||
|
}
|
||||||
const saving = ref(false);
|
const saving = ref(false);
|
||||||
const root = ref<HTMLElement | null>(null);
|
const root = ref<HTMLElement | null>(null);
|
||||||
// One element per block, keyed by the block's id — the only thing about a block that
|
// 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)
|
? (notes.items.find((n) => n.id === noteId.value) ?? props.note ?? draftNote.value)
|
||||||
: 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
|
// 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 —
|
// 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 });
|
const created = await notes.create({ body: body.value });
|
||||||
noteId.value = created.id;
|
noteId.value = created.id;
|
||||||
baseline.value = { body: created.body };
|
baseline.value = { body: created.body };
|
||||||
|
takeLabels(created);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure a persisted note exists (for rich actions mid-compose). Returns its id, or
|
// 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;
|
if (!changed) return;
|
||||||
saving.value = true;
|
saving.value = true;
|
||||||
try {
|
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 };
|
baseline.value = { body: nextBody };
|
||||||
|
takeLabels(saved);
|
||||||
} finally {
|
} finally {
|
||||||
saving.value = false;
|
saving.value = false;
|
||||||
}
|
}
|
||||||
@@ -355,6 +368,89 @@ function onProseInput(index: number, e: Event): void {
|
|||||||
const el = e.target as HTMLTextAreaElement;
|
const el = e.target as HTMLTextAreaElement;
|
||||||
setText(index, el.value);
|
setText(index, el.value);
|
||||||
grow(el);
|
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.
|
* however many lines became checkboxes and would otherwise keep its old height.
|
||||||
*/
|
*/
|
||||||
function onProseBlur(index: number): void {
|
function onProseBlur(index: number): void {
|
||||||
|
closeTagMenu();
|
||||||
if (!editable.value) return;
|
if (!editable.value) return;
|
||||||
const promoted = promoteTasks(blocks.value, index);
|
const promoted = promoteTasks(blocks.value, index);
|
||||||
if (promoted === blocks.value) return;
|
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). */
|
/** Compose: Shift+Enter saves the note and starts a fresh one (rapid capture). */
|
||||||
function onProseKeydown(e: KeyboardEvent): void {
|
function onProseKeydown(e: KeyboardEvent): void {
|
||||||
|
if (onTagKey(e)) return;
|
||||||
if (isCreate.value && e.key === "Enter" && e.shiftKey) {
|
if (isCreate.value && e.key === "Enter" && e.shiftKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
void commitAndContinue();
|
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. */
|
/** Enter on an item makes the next one; on an EMPTY item it ends the list. */
|
||||||
function onTaskEnter(index: number): void {
|
function onTaskEnter(index: number): void {
|
||||||
if (!editable.value) return;
|
if (!editable.value) return;
|
||||||
@@ -436,9 +544,11 @@ async function onLabelsChange(next: NoteLabel[]) {
|
|||||||
const tagIds = new Set(tagLabels.map((lb) => lb.id));
|
const tagIds = new Set(tagLabels.map((lb) => lb.id));
|
||||||
const merged = [...next.filter((lb) => !tagIds.has(lb.id)), ...tagLabels];
|
const merged = [...next.filter((lb) => !tagIds.has(lb.id)), ...tagLabels];
|
||||||
labelList.value = merged;
|
labelList.value = merged;
|
||||||
await notes.setLabels(
|
takeLabels(
|
||||||
id,
|
await notes.setLabels(
|
||||||
merged.map((lb) => lb.id),
|
id,
|
||||||
|
merged.map((lb) => lb.id),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
async function removeLabel(id: string) {
|
async function removeLabel(id: string) {
|
||||||
@@ -526,6 +636,7 @@ async function restoreRevisionAt(revId: string) {
|
|||||||
if (!id) return;
|
if (!id) return;
|
||||||
const updated = await notes.restoreRevision(id, revId);
|
const updated = await notes.restoreRevision(id, revId);
|
||||||
setBody(updated.body);
|
setBody(updated.body);
|
||||||
|
takeLabels(updated);
|
||||||
baseline.value = { body: updated.body };
|
baseline.value = { body: updated.body };
|
||||||
void loadRevisions(); // the pre-restore state became a new revision
|
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
|
<!-- 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
|
item is a real input; a run of prose is one textarea, so typing a
|
||||||
paragraph still feels like typing a paragraph. -->
|
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">
|
<template v-for="(block, i) in blocks" :key="block.id">
|
||||||
<div v-if="block.checked !== null" class="group/item flex items-center gap-2">
|
<div v-if="block.checked !== null" class="group/item flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
@@ -664,9 +775,11 @@ function revPreview(rev: NoteRevision): string {
|
|||||||
:readonly="!editable"
|
:readonly="!editable"
|
||||||
class="min-w-0 flex-1 bg-transparent text-sm leading-relaxed outline-none"
|
class="min-w-0 flex-1 bg-transparent text-sm leading-relaxed outline-none"
|
||||||
:class="block.checked ? 'text-neutral-400 line-through' : ''"
|
:class="block.checked ? 'text-neutral-400 line-through' : ''"
|
||||||
@input="setText(i, ($event.target as HTMLInputElement).value)"
|
@input="onTaskInput(i, $event)"
|
||||||
@keydown.enter.prevent="onTaskEnter(i)"
|
@keydown="onTaskKeydown(i, $event)"
|
||||||
@keydown.backspace="onTaskBackspace(i, $event)"
|
@keyup="onFieldKeyup(i, $event)"
|
||||||
|
@click="readTag(i, $event.target as HTMLInputElement)"
|
||||||
|
@blur="closeTagMenu"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
v-if="editable"
|
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"
|
class="w-full resize-none overflow-hidden bg-transparent text-sm leading-relaxed outline-none placeholder:text-neutral-400"
|
||||||
@input="onProseInput(i, $event)"
|
@input="onProseInput(i, $event)"
|
||||||
@keydown="onProseKeydown"
|
@keydown="onProseKeydown"
|
||||||
|
@keyup="onFieldKeyup(i, $event)"
|
||||||
|
@click="readTag(i, $event.target as HTMLTextAreaElement)"
|
||||||
@blur="onProseBlur(i)"
|
@blur="onProseBlur(i)"
|
||||||
/>
|
/>
|
||||||
</template>
|
</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>
|
</div>
|
||||||
<!-- No checklist component. The items are blocks above, each one line of the
|
<!-- 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). -->
|
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 {
|
export function foldTag(name: string): string {
|
||||||
return name.toLowerCase();
|
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(
|
async function mutate(
|
||||||
id: string,
|
id: string,
|
||||||
changes: Partial<Pick<Note, "body" | "pinned" | "archived" | "remind_at" | "recurrence">>,
|
changes: Partial<Pick<Note, "body" | "pinned" | "archived" | "remind_at" | "recurrence">>,
|
||||||
): Promise<void> {
|
): Promise<Note> {
|
||||||
reconcile(await repo.notes.update(id, changes));
|
const note = await repo.notes.update(id, changes);
|
||||||
|
reconcile(note);
|
||||||
|
return note;
|
||||||
}
|
}
|
||||||
|
|
||||||
const setPinned = (id: string, pinned: boolean) => mutate(id, { pinned });
|
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));
|
reconcile(await repo.notes.snoozeReminder(id, minutes));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function setLabels(id: string, labelIds: string[]): Promise<void> {
|
async function setLabels(id: string, labelIds: string[]): Promise<Note> {
|
||||||
reconcile(await repo.notes.setLabels(id, labelIds));
|
const note = await repo.notes.setLabels(id, labelIds);
|
||||||
|
reconcile(note);
|
||||||
|
return note;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateItem(id: string, itemId: string, changes: { checked: boolean }): Promise<void> {
|
async function updateItem(id: string, itemId: string, changes: { checked: boolean }): Promise<void> {
|
||||||
|
|||||||
Reference in New Issue
Block a user