Pills: explicit Backspace/Delete keymap (atomicRanges wasn't enough)

EditorView.atomicRanges is documented as covering "cursor motion
and deletion" but CM6's default contenteditable delete path still
chips away one bracket at a time — confirmed via test. Add an
explicit, high-precedence Backspace and Delete keymap on the pill
extension:

- Backspace immediately after `]]` (or `#tag`) deletes the whole
  pill range and fires onRemove → the linked-notes context entry
  drops in lockstep with the visible text.
- Delete immediately before `[[` does the same for the other side.
- Both also swallow a single adjacent space so we don't leave a
  double-space hole where the pill was, matching the existing × UX.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Mike Thicke 2026-05-31 10:04:21 -04:00
parent d35b0a083f
commit c8ff7fc78d
2 changed files with 102 additions and 2 deletions

View file

@ -79,6 +79,42 @@ describe("mentionPillExtension", () => {
expect(parent.querySelector(".coi-pill-note")).toBeNull();
});
it("Backspace from just after a pill deletes the whole range and fires onRemove", () => {
const onRemove = vi.fn();
handle = mountComposer({
parent,
initialDoc: "hi [[Foo]] end",
onSubmit: () => true,
extensions: [mentionPillExtension({ onRemove })],
});
// Caret immediately after `]]`
handle.view.dispatch({ selection: { anchor: 10 } });
const event = new KeyboardEvent("keydown", {
key: "Backspace",
bubbles: true,
});
handle.view.contentDOM.dispatchEvent(event);
expect(handle.getValue()).toBe("hi end");
expect(onRemove).toHaveBeenCalledWith("note", "Foo");
});
it("Delete from just before a pill deletes the whole range", () => {
handle = mountComposer({
parent,
initialDoc: "hi [[Foo]] end",
onSubmit: () => true,
extensions: [mentionPillExtension()],
});
// Caret immediately before `[[`
handle.view.dispatch({ selection: { anchor: 3 } });
const event = new KeyboardEvent("keydown", {
key: "Delete",
bubbles: true,
});
handle.view.contentDOM.dispatchEvent(event);
expect(handle.getValue()).toBe("hi end");
});
it("clicking the remove button strips the pill range", () => {
handle = mountComposer({
parent,

View file

@ -1,8 +1,9 @@
import { type Extension, RangeSet, RangeSetBuilder } from "@codemirror/state";
import { Prec, type Extension, RangeSet, RangeSetBuilder } from "@codemirror/state";
import {
Decoration,
type DecorationSet,
EditorView,
keymap,
ViewPlugin,
type ViewUpdate,
WidgetType,
@ -194,5 +195,68 @@ export function mentionPillExtension(
return value?.decorations ?? RangeSet.empty;
});
return [plugin, atomicRanges];
// CM6's default contenteditable delete path doesn't actually honour
// `atomicRanges` for Backspace — it still chips away one bracket at a
// time. Catch Backspace / Delete explicitly so a single press wipes the
// whole pill range and the onRemove hook fires (otherwise pressing × is
// the only way to drop the context entry, which the user has called out
// as wrong).
const deletionKeymap = Prec.high(
keymap.of([
{
key: "Backspace",
run: (view) =>
deletePillAt(view, "before", callbacks),
},
{
key: "Delete",
run: (view) =>
deletePillAt(view, "after", callbacks),
},
]),
);
return [plugin, atomicRanges, deletionKeymap];
}
function deletePillAt(
view: EditorView,
relativeTo: "before" | "after",
callbacks: MentionPillCallbacks,
): boolean {
const sel = view.state.selection.main;
if (!sel.empty) return false;
const doc = view.state.doc.toString();
const matches = findPillMatches(doc);
for (const match of matches) {
const matchesCaret =
relativeTo === "before"
? sel.from === match.to
: sel.from === match.from;
if (!matchesCaret) continue;
// Mirror the × button: also swallow a single adjacent space so we
// don't leave "hi end" after deleting a mid-line pill.
let from = match.from;
let to = match.to;
if (
relativeTo === "before" &&
from > 0 &&
doc[from - 1] === " "
) {
from -= 1;
} else if (
relativeTo === "after" &&
to < doc.length &&
doc[to] === " "
) {
to += 1;
}
view.dispatch({
changes: { from, to, insert: "" },
selection: { anchor: from },
});
callbacks.onRemove?.(match.kind, match.label);
return true;
}
return false;
}