mirror of
https://github.com/epistemic-technology/co-intelligence.git
synced 2026-07-22 06:45:17 +00:00
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:
parent
5624e005a3
commit
f736673fea
3 changed files with 307 additions and 0 deletions
97
src/input/extensions/__tests__/mention-pill.test.ts
Normal file
97
src/input/extensions/__tests__/mention-pill.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
168
src/input/extensions/mention-pill.ts
Normal file
168
src/input/extensions/mention-pill.ts
Normal 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,
|
||||
},
|
||||
);
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue