From f736673fea417169fa21798ebef52991f880963c Mon Sep 17 00:00:00 2001 From: Mike Thicke Date: Sat, 30 May 2026 22:31:16 -0400 Subject: [PATCH] Phase 4: mention-pill extension MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit src/input/extensions/mention-pill.ts — CM6 ViewPlugin that scans the doc for [[Note]] and #tag ranges and replaces them with atomic pill widgets via Decoration.replace. Each pill renders a label + × button; clicking removes the range (and swallows the leading space when one is present so the doc doesn't end up with double spaces). The range the caret is currently inside is left untouched so the user can keep editing — it pillifies once the caret moves away. findPillMatches is exported as a pure function for unit testing; the wikilink/tag patterns live there. Tag rule matches at line start or after whitespace so URL fragments like `foo#bar` don't pillify. CSS uses Obsidian colour variables; tag and note get distinct backgrounds so the user can tell them apart at a glance. Closes #53. Co-Authored-By: Claude Opus 4.7 --- .../extensions/__tests__/mention-pill.test.ts | 97 ++++++++++ src/input/extensions/mention-pill.ts | 168 ++++++++++++++++++ src/styles.css | 42 +++++ 3 files changed, 307 insertions(+) create mode 100644 src/input/extensions/__tests__/mention-pill.test.ts create mode 100644 src/input/extensions/mention-pill.ts diff --git a/src/input/extensions/__tests__/mention-pill.test.ts b/src/input/extensions/__tests__/mention-pill.test.ts new file mode 100644 index 0000000..7ac48d0 --- /dev/null +++ b/src/input/extensions/__tests__/mention-pill.test.ts @@ -0,0 +1,97 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { mountComposer, type ComposerHandle } from "@/input/composer"; +import { + findPillMatches, + mentionPillExtension, +} from "@/input/extensions/mention-pill"; + +describe("findPillMatches", () => { + it("finds wikilinks", () => { + const m = findPillMatches("hello [[Foo]] world"); + expect(m).toEqual([ + { from: 6, to: 13, kind: "note", label: "Foo" }, + ]); + }); + + it("finds tags after spaces and at line start", () => { + const m = findPillMatches("#one and #two-tag"); + expect(m.map((p) => p.label)).toEqual(["#one", "#two-tag"]); + }); + + it("doesn't match # mid-word (e.g. a URL fragment)", () => { + const m = findPillMatches("foo#bar"); + expect(m).toEqual([]); + }); + + it("sorts results by position", () => { + const m = findPillMatches("#a [[Foo]] #b"); + expect(m.map((p) => [p.from, p.label])).toEqual([ + [0, "#a"], + [3, "Foo"], + [11, "#b"], + ]); + }); + + it("returns an empty set for plain text", () => { + expect(findPillMatches("nothing here")).toEqual([]); + }); +}); + +describe("mentionPillExtension", () => { + let parent: HTMLDivElement; + let handle: ComposerHandle; + + beforeEach(() => { + parent = document.createElement("div"); + document.body.appendChild(parent); + }); + + afterEach(() => { + handle?.destroy(); + parent.remove(); + }); + + it("renders a pill widget for [[Foo]] when caret is elsewhere", () => { + handle = mountComposer({ + parent, + initialDoc: "hi [[Foo]]", + onSubmit: () => true, + extensions: [mentionPillExtension], + }); + // Move caret away from the wikilink range. + handle.view.dispatch({ selection: { anchor: 0 } }); + const pill = parent.querySelector(".coi-pill-note"); + expect(pill).not.toBeNull(); + expect(pill?.querySelector(".coi-pill-label")?.textContent).toBe( + "Foo", + ); + }); + + it("doesn't pillify the range the caret is inside", () => { + handle = mountComposer({ + parent, + initialDoc: "[[Foo]]", + onSubmit: () => true, + extensions: [mentionPillExtension], + }); + handle.view.dispatch({ selection: { anchor: 3 } }); + expect(parent.querySelector(".coi-pill-note")).toBeNull(); + }); + + it("clicking the remove button strips the pill range", () => { + handle = mountComposer({ + parent, + initialDoc: "hi [[Foo]] end", + onSubmit: () => true, + extensions: [mentionPillExtension], + }); + handle.view.dispatch({ selection: { anchor: 0 } }); + const remove = parent.querySelector( + ".coi-pill-remove", + ); + expect(remove).not.toBeNull(); + remove!.click(); + expect(handle.getValue()).toBe("hi end"); + }); +}); diff --git a/src/input/extensions/mention-pill.ts b/src/input/extensions/mention-pill.ts new file mode 100644 index 0000000..977de2b --- /dev/null +++ b/src/input/extensions/mention-pill.ts @@ -0,0 +1,168 @@ +import { type Extension, RangeSetBuilder } from "@codemirror/state"; +import { + Decoration, + type DecorationSet, + EditorView, + ViewPlugin, + type ViewUpdate, + WidgetType, +} from "@codemirror/view"; + +type PillKind = "note" | "tag"; + +interface PillMatch { + from: number; + to: number; + kind: PillKind; + label: string; +} + +const wikilinkPattern = /\[\[([^\]\r\n]+)\]\]/g; +const tagPattern = /(^|\s)(#[A-Za-z][\w/-]*)/g; + +/** + * Pure function: given the full document text, returns all `[[note]]` and + * `#tag` ranges suitable for decoration. Exported for unit testing — call + * sites should use {@link mentionPillExtension}. + */ +export function findPillMatches(doc: string): PillMatch[] { + const out: PillMatch[] = []; + + wikilinkPattern.lastIndex = 0; + for (let m = wikilinkPattern.exec(doc); m; m = wikilinkPattern.exec(doc)) { + out.push({ + from: m.index, + to: m.index + m[0].length, + kind: "note", + label: m[1], + }); + } + + tagPattern.lastIndex = 0; + for (let m = tagPattern.exec(doc); m; m = tagPattern.exec(doc)) { + const tagStart = m.index + m[1].length; + out.push({ + from: tagStart, + to: tagStart + m[2].length, + kind: "tag", + label: m[2], + }); + } + + out.sort((a, b) => a.from - b.from); + return out; +} + +class PillWidget extends WidgetType { + constructor( + private readonly kind: PillKind, + private readonly label: string, + private readonly from: number, + private readonly to: number, + ) { + super(); + } + + eq(other: PillWidget): boolean { + return ( + other.kind === this.kind && + other.label === this.label && + other.from === this.from && + other.to === this.to + ); + } + + toDOM(view: EditorView): HTMLElement { + const el = document.createElement("span"); + el.className = `coi-pill coi-pill-${this.kind}`; + el.dataset.from = String(this.from); + el.dataset.to = String(this.to); + + const labelEl = document.createElement("span"); + labelEl.className = "coi-pill-label"; + labelEl.textContent = this.label; + el.appendChild(labelEl); + + const removeEl = document.createElement("button"); + removeEl.className = "coi-pill-remove"; + removeEl.type = "button"; + removeEl.setAttribute("aria-label", `Remove ${this.label}`); + removeEl.textContent = "×"; + removeEl.addEventListener("mousedown", (event) => { + event.preventDefault(); + }); + removeEl.addEventListener("click", (event) => { + event.preventDefault(); + event.stopPropagation(); + removePillRange(view, this.from, this.to); + }); + el.appendChild(removeEl); + + return el; + } + + ignoreEvent(): boolean { + // Let our own click handlers fire, but tell CM6 to ignore everything + // else (so the widget really is atomic). + return false; + } +} + +function removePillRange(view: EditorView, from: number, to: number): void { + // Also swallow a leading space when the pill sits mid-line, so the doc + // doesn't end up with a double space after removal. + const before = view.state.doc.sliceString(Math.max(0, from - 1), from); + const adjustedFrom = before === " " ? from - 1 : from; + view.dispatch({ + changes: { from: adjustedFrom, to, insert: "" }, + selection: { anchor: adjustedFrom }, + }); + view.focus(); +} + +function buildDecorations(view: EditorView): DecorationSet { + const builder = new RangeSetBuilder(); + const doc = view.state.doc.toString(); + const sel = view.state.selection.main; + for (const match of findPillMatches(doc)) { + // Don't pill-ify the range the caret is inside — would feel hostile + // mid-edit. The user finishes the bracket / tag and then it becomes + // a pill. + if (sel.from >= match.from && sel.from <= match.to) continue; + builder.add( + match.from, + match.to, + Decoration.replace({ + widget: new PillWidget( + match.kind, + match.label, + match.from, + match.to, + ), + }), + ); + } + return builder.finish(); +} + +/** + * CodeMirror extension that renders `[[Note]]` and `#tag` ranges as atomic + * pill widgets with a click-to-remove button. Lives in {@link buildDecorations} + * — rebuilt on every doc / selection change so the widgets follow edits. + */ +export const mentionPillExtension: Extension = ViewPlugin.fromClass( + class { + decorations: DecorationSet; + constructor(view: EditorView) { + this.decorations = buildDecorations(view); + } + update(update: ViewUpdate): void { + if (update.docChanged || update.selectionSet) { + this.decorations = buildDecorations(update.view); + } + } + }, + { + decorations: (v) => v.decorations, + }, +); diff --git a/src/styles.css b/src/styles.css index 4ff02be..d7812de 100644 --- a/src/styles.css +++ b/src/styles.css @@ -535,3 +535,45 @@ li button.coi-add-context-menu-option:focus-visible { justify-content: flex-end; margin-top: 1rem; } + +.coi-pill { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0 0.4rem; + margin: 0 1px; + border-radius: var(--radius-s); + background: var(--background-modifier-hover); + color: var(--text-normal); + font-size: 0.9em; + line-height: 1.4; + vertical-align: baseline; + cursor: default; +} + +.coi-pill-note { + background: var(--background-modifier-success); +} + +.coi-pill-tag { + background: var(--background-modifier-border-focus); +} + +.coi-pill-label { + user-select: none; +} + +.coi-pill-remove { + background: none; + border: none; + padding: 0; + margin: 0; + color: var(--text-muted); + cursor: pointer; + font-size: 1em; + line-height: 1; +} + +.coi-pill-remove:hover { + color: var(--text-normal); +}