diff --git a/components/path-input-dom.ts b/components/path-input-dom.ts index d8699f0..b352876 100644 --- a/components/path-input-dom.ts +++ b/components/path-input-dom.ts @@ -110,6 +110,18 @@ export function pathInputOffset(root: HTMLElement, node: Node, offset: number): return total; } +/** Validate a browser beforeinput StaticRange before projecting its exact endpoints. */ +export function pathInputStaticRange(root: HTMLElement, range: Pick): TextSelection | null { + function valid(node: Node, offset: number) { + return (node === root || root.contains(node)) && Number.isInteger(offset) && offset >= 0 + && offset <= (node.nodeType === 3 ? (node as Text).length : node.childNodes.length); + } + if (!valid(range.startContainer, range.startOffset) || !valid(range.endContainer, range.endOffset)) return null; + const anchor = pathInputOffset(root, range.startContainer, range.startOffset); + const focus = pathInputOffset(root, range.endContainer, range.endOffset); + return anchor === null || focus === null || anchor >= focus ? null : { anchor, focus }; +} + export function readPathSelection(root: HTMLElement): TextSelection | null { const selection = root.ownerDocument.getSelection?.(); if (!selection?.anchorNode || !selection.focusNode) return null; diff --git a/components/path-input.tsx b/components/path-input.tsx index bb85f1b..574b661 100644 --- a/components/path-input.tsx +++ b/components/path-input.tsx @@ -6,7 +6,7 @@ import { FilePathChip } from "./file-path-chip"; import { cn } from "../lib/utils"; import { adjacentPathRange, atomicPathRange, clampPathSelection, insertFilePaths, - normalizePathEdit, normalizePathInputValue, PathInputHistory, readPathInput, readPathSelection, renderPathInput, + normalizePathEdit, normalizePathInputValue, pathInputStaticRange, PathInputHistory, readPathInput, readPathSelection, renderPathInput, replacePathSelection, restorePathSelection, type PathEditSnapshot, type TextSelection, } from "./path-input-dom"; @@ -123,6 +123,18 @@ export const PathInput = React.forwardRef(functi // The cut handler owns both the full-source clipboard and deletion. // Never perform a second deletion if a browser also sends beforeinput. event.preventDefault(); + } else if (/^delete(?:(?:Word|SoftLine|HardLine)(?:Backward|Forward)|EntireSoftLine)$/.test(event.inputType)) { + // Modifier keys have platform-specific word/visual-line semantics. + // Only the browser knows that extent; never substitute an adjacent chip. + let targets: StaticRange[] = []; + try { targets = event.getTargetRanges?.() ?? []; } catch { /* Native fallback below. */ } + const range = targets.length === 1 ? pathInputStaticRange(root, targets[0]!) : null; + if (range && event.cancelable) { + event.preventDefault(); + // replacePathSelection expands any intersected chip atomically. + change(replacePathSelection(current, range, "")); + } + // Missing/invalid/non-single targets: native edit + input projection owns it. } else if (event.inputType.startsWith("delete")) { const range = adjacentPathRange(current, saved, event.inputType.includes("Backward") ? "backward" : "forward"); if (range) { @@ -151,7 +163,7 @@ export const PathInput = React.forwardRef(functi if ((event.metaKey || event.ctrlKey) && !event.altKey && (event.key.toLowerCase() === "z" || event.key.toLowerCase() === "y")) { event.preventDefault(); travel(event.key.toLowerCase() === "y" || event.shiftKey ? "redo" : "undo"); - } else if (event.key === "Backspace" || event.key === "Delete") { + } else if ((event.key === "Backspace" || event.key === "Delete") && !event.metaKey && !event.ctrlKey && !event.altKey) { const range = adjacentPathRange(current, saved, event.key === "Backspace" ? "backward" : "forward"); if (range) { event.preventDefault(); diff --git a/tests/path-input.test.tsx b/tests/path-input.test.tsx index c1ca6c9..e2980e3 100644 --- a/tests/path-input.test.tsx +++ b/tests/path-input.test.tsx @@ -6,7 +6,7 @@ import { renderToStaticMarkup } from "react-dom/server"; import { PathInput, INSERT_FILE_PATHS_EVENT } from "../components/path-input.tsx"; import { PATH_CARET_ATTRIBUTE, PATH_SOURCE_ATTRIBUTE, PathInputHistory, adjacentPathRange, atomicPathRange, - insertFilePaths, normalizePathEdit, normalizePathInputValue, pathInputOffset, pathInputPoint, readPathInput, + insertFilePaths, normalizePathEdit, normalizePathInputValue, pathInputOffset, pathInputPoint, pathInputStaticRange, readPathInput, readPathSelection, renderPathInput, replacePathSelection, restorePathSelection, } from "../components/path-input-dom.ts"; import { quoteFilePath } from "../src/core/file-paths.ts"; @@ -228,6 +228,144 @@ test("native typing, token insertion/deletion and undo/redo share one history", assert.equal(f.value, "ab"); }); +test("Cmd+Backspace delegates line extent to beforeinput instead of deleting just the final chip", async () => { + const source = "Explain `/tmp/notes.pdf`"; + const f = await fixture(source); + f.select(source.length); + const key = await f.event("keydown", { key: "Backspace", metaKey: true }); + assert.equal(key.defaultPrevented, false); + assert.equal(f.value, source); + const deletion = await f.event("beforeinput", { + inputType: "deleteSoftLineBackward", + getTargetRanges: () => [{ startContainer: f.root.firstChild!, startOffset: 0, endContainer: f.root, endOffset: f.root.childNodes.length }], + }); + assert.equal(deletion.defaultPrevented, true); + assert.equal(f.value, ""); + await f.event("keydown", { key: "z", metaKey: true }); + assert.equal(f.value, source); + assert.deepEqual(readPathSelection(f.root), { anchor: source.length, focus: source.length }); +}); + +test("native word and soft/hard line deletion ranges retain mixed text/chip extents in both directions", async () => { + const source = "keep old `/tmp/notes.pdf` tail\nnext"; + const f = await fixture(source); + const lineEnd = source.indexOf("\n"); + const chipEnd = source.indexOf(" tail"); + const cases = [ + ["deleteWordBackward", 5, lineEnd], ["deleteWordForward", 5, chipEnd], + ["deleteSoftLineBackward", 0, lineEnd], ["deleteSoftLineForward", 5, lineEnd], + ["deleteHardLineBackward", 0, chipEnd], ["deleteHardLineForward", 5, lineEnd], + ] as const; + for (const [inputType, start, end] of cases) { + await f.external(source); + const backward = inputType.endsWith("Backward"); + f.select(backward ? end : start); + const key = await f.event("keydown", { key: backward ? "Backspace" : "Delete", ctrlKey: true }); + assert.equal(key.defaultPrevented, false, inputType); + assert.equal(f.value, source); + const a = pathInputPoint(f.root, start); + const b = pathInputPoint(f.root, end); + const deletion = await f.event("beforeinput", { + inputType, + getTargetRanges: () => [{ startContainer: a.node, startOffset: a.offset, endContainer: b.node, endOffset: b.offset }], + }); + assert.equal(deletion.defaultPrevented, true, inputType); + assert.equal(f.value, source.slice(0, start) + source.slice(end), inputType); + assert.deepEqual(readPathSelection(f.root), { anchor: start, focus: start }); + } +}); + +test("deleteEntireSoftLine honors the browser extent and never substitutes a chip when ranges are absent", async () => { + const source = "before\nExplain `/tmp/notes.pdf` tail\nafter"; + const f = await fixture(source); + const start = source.indexOf("Explain"); + const end = source.indexOf("\nafter"); + f.select(source.indexOf("`")); // Adjacent to a chip, but not a chip-only command. + for (const getTargetRanges of [undefined, () => []]) { + const missing = await f.event("beforeinput", { inputType: "deleteEntireSoftLine", getTargetRanges }); + assert.equal(missing.defaultPrevented, false); + assert.equal(f.value, source); + } + const a = pathInputPoint(f.root, start); + const b = pathInputPoint(f.root, end); + const deletion = await f.event("beforeinput", { + inputType: "deleteEntireSoftLine", + getTargetRanges: () => [{ startContainer: a.node, startOffset: a.offset, endContainer: b.node, endOffset: b.offset }], + }); + assert.equal(deletion.defaultPrevented, true); + assert.equal(f.value, "before\n\nafter"); + assert.deepEqual(readPathSelection(f.root), { anchor: start, focus: start }); +}); + +test("browser target ranges that intersect a visual chip interior expand the complete source atomically", async () => { + const source = "keep old `/tmp/notes.pdf` tail"; + const f = await fixture(source); + f.select(5); + const label = f.root.querySelector(`[${PATH_SOURCE_ATTRIBUTE}] [role=img] span`)!.firstChild!; + const start = pathInputPoint(f.root, 5); + const target = { startContainer: start.node, startOffset: start.offset, endContainer: label, endOffset: 2 }; + const mapped = pathInputStaticRange(f.root, target)!; + assert.ok(mapped.focus > source.indexOf("`")); + assert.ok(mapped.focus < source.lastIndexOf("`") + 1); + await f.event("beforeinput", { inputType: "deleteWordForward", getTargetRanges: () => [target] }); + assert.equal(f.value, "keep tail"); + assert.equal(f.root.querySelector(`[${PATH_SOURCE_ATTRIBUTE}]`), null); +}); + +test("missing or invalid word/line target ranges defer to native editing and preserve owned undo", async () => { + const source = "Explain `/tmp/notes.pdf`"; + const f = await fixture(source); + f.select(source.length); + for (const modifier of ["metaKey", "ctrlKey", "altKey"]) { + for (const key of ["Backspace", "Delete"]) { + const event = await f.event("keydown", { key, [modifier]: true }); + assert.equal(event.defaultPrevented, false); + assert.equal(f.value, source); + } + } + for (const inputType of ["deleteWordBackward", "deleteWordForward", "deleteSoftLineBackward", "deleteSoftLineForward", "deleteHardLineBackward", "deleteHardLineForward"]) { + for (const getTargetRanges of [undefined, () => []]) { + const beforeInput = await f.event("beforeinput", { inputType, getTargetRanges }); + assert.equal(beforeInput.defaultPrevented, false, `${inputType} must not fall through to adjacent-chip deletion`); + assert.equal(f.value, source); + } + } + const valid = { startContainer: f.root, startOffset: 0, endContainer: f.root, endOffset: f.root.childNodes.length }; + for (const getTargetRanges of [ + undefined, () => [], () => [{ ...valid, startContainer: f.doc.body }], + () => [{ ...valid, startOffset: -1 }], () => [{ ...valid, endOffset: 999 }], + () => [{ ...valid, endOffset: 0 }], () => [valid, valid], () => { throw new Error("not available"); }, + ]) { + const beforeInput = await f.event("beforeinput", { inputType: "deleteWordBackward", getTargetRanges }); + assert.equal(beforeInput.defaultPrevented, false); + assert.equal(f.value, source); + } + // With no usable target ranges the browser edits the DOM, then input projects it. + await f.nativeText("", "deleteHardLineBackward"); + assert.equal(f.value, ""); + await f.event("keydown", { key: "z", metaKey: true }); + assert.equal(f.value, source); +}); + +test("ordinary backward/forward chip deletion and selected ranges retain their existing behavior", async () => { + const source = "a `/tmp/notes.pdf` b"; + const f = await fixture(source); + f.select(source.length - 2); + const backspace = await f.event("keydown", { key: "Backspace" }); + assert.equal(backspace.defaultPrevented, true); + assert.equal(f.value, "a b"); + await f.external(source); + f.select(2); + const forward = await f.event("keydown", { key: "Delete" }); + assert.equal(forward.defaultPrevented, true); + assert.equal(f.value, "a b"); + await f.external(source); + f.select(source.length, 0); + const selected = await f.event("keydown", { key: "Backspace" }); + assert.equal(selected.defaultPrevented, true); + assert.equal(f.value, ""); +}); + test("immutable SSR fallback never stomps native edits, controlled updates, chips or reset", async () => { const initial = "Restored draft `/course/original.pdf`"; const f = await fixture(initial);