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); +}