Phase 4: mention-pill extension

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 <noreply@anthropic.com>
This commit is contained in:
Mike Thicke 2026-05-30 22:31:16 -04:00
parent 5624e005a3
commit f736673fea
3 changed files with 307 additions and 0 deletions

View file

@ -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<HTMLButtonElement>(
".coi-pill-remove",
);
expect(remove).not.toBeNull();
remove!.click();
expect(handle.getValue()).toBe("hi end");
});
});

View file

@ -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<Decoration>();
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,
},
);

View file

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