From ec3de6d94162a4be723c33f1625aec24b3e00158 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Wed, 7 Oct 2026 11:48:07 +0200 Subject: [PATCH] fix(transcript): refuse every native edit the block does not handle (#1038) The transcript's beforeinput guard prevented only delete* and insert*, so Ctrl/Cmd+B, I and U (formatBold/Italic/Underline) styled the words in the DOM behind transcript.words. The guard now prevents every input type, then routes delete* to the cut and insert* to the insertion field as before. --- src/components/ai-edition/RightPanes.tsx | 13 +++--- .../TranscriptPane.wordInsert.test.tsx | 40 +++++++++++++++++++ 2 files changed, 48 insertions(+), 5 deletions(-) diff --git a/src/components/ai-edition/RightPanes.tsx b/src/components/ai-edition/RightPanes.tsx index 8bdb078f9..4ff04c60f 100644 --- a/src/components/ai-edition/RightPanes.tsx +++ b/src/components/ai-edition/RightPanes.tsx @@ -1309,8 +1309,14 @@ const TranscriptClipBlock = memo(function TranscriptClipBlock({ // their own typing bubbles here natively — React's `stopPropagation` only ever // stopped the synthetic tree. Their text is theirs. if (event.target instanceof HTMLInputElement) return; + // Default-deny: the block is contentEditable only to carry a caret, so no native + // edit may land in it. Not only the two families handled below: Ctrl/Cmd+B, I and U + // reach here as `formatBold`, `formatItalic` and `formatUnderline`, and let through + // they styled the DOM behind `words`' back (#1038). Undo loses nothing: Ctrl/Cmd+Z + // goes to the document undo (keydown, or the Edit menu on macOS), never to the + // browser's text history, which has nothing of this block's edits in it anyway. + event.preventDefault(); if (event.inputType.startsWith("delete")) { - event.preventDefault(); cutNativeSelection(event.inputType === "deleteContentForward" ? "forward" : "backward"); return; } @@ -1319,10 +1325,7 @@ const TranscriptClipBlock = memo(function TranscriptClipBlock({ // instead is a field beside the word the caret was on, whose commit creates a real // word to hold them. So the gesture is the document one — put the caret somewhere // and type — without the DOM ever getting ahead of `words`. - if (event.inputType.startsWith("insert")) { - event.preventDefault(); - openInsertion(event.data ?? ""); - } + if (event.inputType.startsWith("insert")) openInsertion(event.data ?? ""); }; editor.addEventListener("beforeinput", onBeforeInput); return () => editor.removeEventListener("beforeinput", onBeforeInput); diff --git a/src/components/ai-edition/TranscriptPane.wordInsert.test.tsx b/src/components/ai-edition/TranscriptPane.wordInsert.test.tsx index e51a81113..2723a5ab7 100644 --- a/src/components/ai-edition/TranscriptPane.wordInsert.test.tsx +++ b/src/components/ai-edition/TranscriptPane.wordInsert.test.tsx @@ -291,3 +291,43 @@ describe("a word that was inserted", () => { expect(view.onAddTrimRange).not.toHaveBeenCalled(); }); }); + +describe("the native edits the block does not handle", () => { + /** Dispatch a native `beforeinput`; true when the listener let the browser go ahead. */ + function input(editor: HTMLElement, inputType: string, data?: string) { + return fireEvent( + editor, + new InputEvent("beforeinput", { inputType, data, bubbles: true, cancelable: true }), + ); + } + + // Ctrl/Cmd+B, I and U: let through, they styled the words in the DOM and `words` never + // knew (#1038). Undo and redo belong to the document, never to the browser's text history. + it.each([ + "formatBold", + "formatItalic", + "formatUnderline", + "historyUndo", + "historyRedo", + ])("refuses %s", (inputType) => { + const view = renderPane(); + caretBeforeWordAt(view.editor, 2); + expect(input(view.editor, inputType)).toBe(false); + expect(view.field()).toBeNull(); + expect(view.onAddTrimRange).not.toHaveBeenCalled(); + }); + + it("still cuts on a delete", () => { + const view = renderPane(); + caretBeforeWordAt(view.editor, 2); + expect(input(view.editor, "deleteContentBackward")).toBe(false); + expect(view.onAddTrimRange).toHaveBeenCalledTimes(1); + }); + + it("still opens the field on an insert", () => { + const view = renderPane(); + caretBeforeWordAt(view.editor, 2); + expect(input(view.editor, "insertText", "v")).toBe(false); + expect(view.field()).toHaveValue("v"); + }); +});