mirror of
https://github.com/epistemic-technology/co-intelligence.git
synced 2026-07-22 06:45:17 +00:00
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:
parent
d35b0a083f
commit
c8ff7fc78d
2 changed files with 102 additions and 2 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue