AGPL-3.0-only, a source link in the app, and everything on dev since #5 #6

Merged
bvandeusen merged 214 commits from dev into main 2026-10-21 21:40:00 -04:00
4 changed files with 296 additions and 14 deletions
Showing only changes of commit 8d67fc0da5 - Show all commits
+158 -10
View File
@@ -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). -->
+62
View File
@@ -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 });
});
});
+68
View File
@@ -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 };
}
+8 -4
View File
@@ -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> {