From 0539891c2373e43a34e1697eaee44fd2eba81783 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Tue, 6 Oct 2026 11:28:54 +0200 Subject: [PATCH 1/2] fix(transcript): keep a skipped word's restore control beside it The hover chips of the word stream (restore a cut word, revert a correction, delete an inserted word) were inserted into the text flow while the word was hovered. After a word ending its line the chip did not fit and wrapped to the start of the next line, so the pointer could only reach it by leaving the word, which took it away. It also reflowed the paragraph on every hover. The chip is now laid over the stream just past the word, with the gap as its own padding so the way onto it never leaves the word. The cut word's restore button now goes through WordChipButton instead of a copy of it. Refs #1012 --- src/components/ai-edition/RightPanes.tsx | 85 ++++----- .../TranscriptPane.restore.test.tsx | 168 ++++++++++++++++++ 2 files changed, 213 insertions(+), 40 deletions(-) create mode 100644 src/components/ai-edition/TranscriptPane.restore.test.tsx diff --git a/src/components/ai-edition/RightPanes.tsx b/src/components/ai-edition/RightPanes.tsx index fe1f00bf6..92ab665a7 100644 --- a/src/components/ai-edition/RightPanes.tsx +++ b/src/components/ai-edition/RightPanes.tsx @@ -1771,7 +1771,11 @@ const TranscriptWord = memo(function TranscriptWord({ data-end-sec={cw.word.endSec} data-inserted="true" data-skip-id={cw.trimIds[0] ?? undefined} - style={{ display: "inline", opacity: removed ? 0.6 : 1 }} + style={{ + display: "inline", + position: hover ? "relative" : undefined, + opacity: removed ? 0.6 : 1, + }} onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)} onDoubleClick={(e) => { @@ -1821,7 +1825,7 @@ const TranscriptWord = memo(function TranscriptWord({ data-start-sec={cw.word.startSec} data-end-sec={cw.word.endSec} data-blanked="true" - style={{ display: "inline" }} + style={{ display: "inline", position: hover ? "relative" : undefined }} onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)} onDoubleClick={(e) => { @@ -1866,6 +1870,9 @@ const TranscriptWord = memo(function TranscriptWord({ title={corrected ? ts("transcript.correctedWord", { original }) : undefined} style={{ display: "inline", + // Anchors the hover chip (`WordChipButton`). Only while hovered: a long transcript + // would otherwise be thousands of positioned spans. + position: hover ? "relative" : undefined, // A cut word stays the loudest thing about itself: when a word is both cut and // corrected, the strike-through wins and the correction mark steps aside. color: removed ? "var(--danger)" : corrected ? "var(--accent)" : "var(--fg)", @@ -1894,13 +1901,10 @@ const TranscriptWord = memo(function TranscriptWord({ filler_or_hesitation reason when generating suggestions). */} {cw.word.text}{" "} {removed && hover && cw.trimIds.length > 0 ? ( - + ) : null} {/* A cut word's bin already restores it — showing the revert beside it would put two undos for two different things one pixel apart. */} @@ -1952,7 +1941,14 @@ function RevertWordButton({ label, onRevert }: { label: string; onRevert: () => /** The one hover control shape the word stream uses, in whichever colour says what it does. * `contentEditable={false}` keeps it out of the enclosing editable block, and the click is - * stopped so it never reaches the seek handler underneath. */ + * stopped so it never reaches the seek handler underneath. + * + * Laid over the stream, just past the word, rather than in it (#1012). In the flow it + * reflowed the paragraph on every hover, and after a word ending its line it wrapped to + * the start of the next one: reaching it meant leaving the word, which took it away. So + * the host word must be `position: relative` while it shows one. The gap before the chip + * is the button's own padding, not a margin: a margin is a strip that belongs to neither, + * and crossing it un-hovers the word. */ function WordChipButton({ label, tone, @@ -1975,22 +1971,31 @@ function WordChipButton({ onPress(); }} style={{ - display: "inline-flex", - alignItems: "center", - justifyContent: "center", - width: 18, - height: 18, - marginLeft: 4, - padding: 0, + position: "absolute", + left: "100%", + top: "50%", + transform: "translateY(-50%)", + display: "flex", + padding: "0 0 0 4px", border: 0, - borderRadius: 4, - background: tone, - color: "white", + background: "transparent", cursor: "pointer", - verticalAlign: "middle", }} > - {children} + + {children} + ); } diff --git a/src/components/ai-edition/TranscriptPane.restore.test.tsx b/src/components/ai-edition/TranscriptPane.restore.test.tsx new file mode 100644 index 000000000..6b7ffa24f --- /dev/null +++ b/src/components/ai-edition/TranscriptPane.restore.test.tsx @@ -0,0 +1,168 @@ +// @vitest-environment jsdom +// Bringing back a word cut from the transcript (#1012). +// +// Backspace strikes a word through and lays a trim over it; the bin that hovering the word +// reveals takes the trim away again. The bin exists only while the word is hovered, so it has +// to be reachable FROM the word. It used to sit in the text flow, where after a word ending +// its line it wrapped to the start of the next one: getting there meant leaving the word, +// and leaving the word took the bin away. jsdom has no layout, so these tests pin what keeps +// the bin beside the word (out of the flow, inside the hovered word) and the gesture itself. + +import "@testing-library/jest-dom"; +import { cleanup, fireEvent, render } from "@testing-library/react"; +import { useState } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { I18nProvider } from "@/contexts/I18nContext"; +import type { + AxcutAsset, + AxcutClip, + AxcutTranscript, + AxcutTrimRange, + AxcutWord, +} from "@/lib/ai-edition/schema"; +import { TranscriptPane } from "./RightPanes"; + +vi.mock("@/native/client", () => ({ nativeBridgeClient: { aiEdition: {} } })); +vi.mock("sonner", () => ({ toast: { error: vi.fn() } })); + +const ASSET: AxcutAsset = { + id: "asset_1", + kind: "video", + label: "recording.mp4", + originalPath: "/rec.mp4", + durationSec: 3, + cameraTrack: null, +}; + +const CLIP: AxcutClip = { + id: "clip_1", + assetId: "asset_1", + sourceStartSec: 0, + sourceEndSec: 3, + timelineStartSec: 0, + timelineEndSec: 3, + wordRefs: [], + origin: "user", + reason: "", +}; + +// Contiguous, so no `[silence]` pill lands between them. +const WORDS: AxcutWord[] = [ + { id: "w1", segmentId: "s", startSec: 0, endSec: 1, text: "Bonjour" }, + { id: "w2", segmentId: "s", startSec: 1, endSec: 2, text: "Kubernetes" }, + { id: "w3", segmentId: "s", startSec: 2, endSec: 3, text: "tout" }, +]; + +const TRANSCRIPT: AxcutTranscript = { + assetId: "asset_1", + language: "fr", + segments: [], + words: WORDS, +}; + +// What Backspace on "Kubernetes" leaves behind. +const CUT: AxcutTrimRange = { + id: "trim_1", + assetId: "asset_1", + startSec: 1, + endSec: 2, + reason: "", + origin: "user", +}; + +function Pane({ + trimRanges, + onRemoveTrimRanges, +}: { + trimRanges: AxcutTrimRange[]; + onRemoveTrimRanges: (ids: string[]) => void; +}) { + return ( + + ); +} + +function wordEl(container: HTMLElement, id: string): HTMLElement { + const el = container.querySelector(`[data-word-id="clip_1:${id}"]`); + if (!el) throw new Error(`word ${id} not rendered`); + return el; +} + +afterEach(cleanup); + +describe("restoring a cut word with the mouse", () => { + // The shell drops the trims it is handed; holding them in state is the same round trip. + function renderCut() { + const onRemoveTrimRanges = vi.fn(); + function Harness() { + const [trims, setTrims] = useState([CUT]); + return ( + { + onRemoveTrimRanges(ids); + setTrims((current) => current.filter((trim) => !ids.includes(trim.id))); + }} + /> + ); + } + const view = render( + + + , + ); + return { ...view, onRemoveTrimRanges }; + } + + it("shows the restore control on hover, keeps it on the way onto it, and restores", () => { + const view = renderCut(); + const word = wordEl(view.container, "w2"); + expect(word).toHaveAttribute("data-skip-id", "trim_1"); + expect(view.queryByRole("button", { name: 'Restore "Kubernetes"' })).toBeNull(); + + fireEvent.mouseOver(word); + const restore = view.getByRole("button", { name: 'Restore "Kubernetes"' }); + // Laid over the text beside the word, not inserted into it: in the flow it wrapped to + // the next line after a word ending its line, out of the pointer's reach. + expect(restore).toHaveStyle({ position: "absolute" }); + expect(word).toHaveStyle({ position: "relative" }); + expect(word).toContainElement(restore); + + // The pointer leaving the word FOR the control is not leaving the word. + fireEvent.mouseOut(word, { relatedTarget: restore }); + fireEvent.mouseOver(restore, { relatedTarget: word }); + expect(restore).toBeInTheDocument(); + + fireEvent.click(restore); + expect(view.onRemoveTrimRanges).toHaveBeenCalledWith(["trim_1"]); + expect(wordEl(view.container, "w2")).not.toHaveAttribute("data-skip-id"); + expect(view.queryByRole("button", { name: 'Restore "Kubernetes"' })).toBeNull(); + }); + + it("takes the control away once the pointer moves on to another word", () => { + const view = renderCut(); + const word = wordEl(view.container, "w2"); + fireEvent.mouseOver(word); + const restore = view.getByRole("button", { name: 'Restore "Kubernetes"' }); + + fireEvent.mouseOut(restore, { relatedTarget: wordEl(view.container, "w3") }); + expect(view.queryByRole("button", { name: 'Restore "Kubernetes"' })).toBeNull(); + expect(view.onRemoveTrimRanges).not.toHaveBeenCalled(); + }); +}); From 0b57a13e717474eaaeda9c08166d71860933124b Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Tue, 6 Oct 2026 11:30:11 +0200 Subject: [PATCH 2/2] fix(transcript): let Ctrl/Cmd+Z in the transcript undo a cut The undo shortcut treated every contentEditable as a text field and left it to the browser's text undo. The transcript is contentEditable only to carry a caret: its edits are document writes (Backspace is a trim) that the browser's history never sees, so Ctrl+Z there, and Cmd+Z through the Edit menu on macOS, did nothing at all. A block marked data-document-undo now gets the document undo. The word editor and other fields inside it keep the browser's text undo. Refs #1012 --- src/components/ai-edition/RightPanes.tsx | 3 + .../TranscriptPane.restore.test.tsx | 78 +++++++++++++++++-- src/lib/ai-edition/store/undo.ts | 10 ++- 3 files changed, 82 insertions(+), 9 deletions(-) diff --git a/src/components/ai-edition/RightPanes.tsx b/src/components/ai-edition/RightPanes.tsx index 92ab665a7..8bdb078f9 100644 --- a/src/components/ai-edition/RightPanes.tsx +++ b/src/components/ai-edition/RightPanes.tsx @@ -1474,6 +1474,9 @@ const TranscriptClipBlock = memo(function TranscriptClipBlock({ role="textbox" tabIndex={0} contentEditable={!busy} + // Ctrl/Cmd+Z here undoes the document: the browser's text history never sees + // a transcript edit (see `undo.ts`). + data-document-undo="true" aria-busy={busy} aria-readonly={busy} suppressContentEditableWarning diff --git a/src/components/ai-edition/TranscriptPane.restore.test.tsx b/src/components/ai-edition/TranscriptPane.restore.test.tsx index 6b7ffa24f..5afd738d4 100644 --- a/src/components/ai-edition/TranscriptPane.restore.test.tsx +++ b/src/components/ai-edition/TranscriptPane.restore.test.tsx @@ -7,19 +7,26 @@ // its line it wrapped to the start of the next one: getting there meant leaving the word, // and leaving the word took the bin away. jsdom has no layout, so these tests pin what keeps // the bin beside the word (out of the flow, inside the hovered word) and the gesture itself. +// +// Ctrl/Cmd+Z is the other way back, and the transcript used to hand it to the browser's text +// undo, where a cut never lands. import "@testing-library/jest-dom"; -import { cleanup, fireEvent, render } from "@testing-library/react"; +import { act, cleanup, fireEvent, render } from "@testing-library/react"; import { useState } from "react"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { I18nProvider } from "@/contexts/I18nContext"; -import type { - AxcutAsset, - AxcutClip, - AxcutTranscript, - AxcutTrimRange, - AxcutWord, +import { + type AxcutAsset, + type AxcutClip, + type AxcutTranscript, + type AxcutTrimRange, + type AxcutWord, + createEmptyDocument, } from "@/lib/ai-edition/schema"; +import { useProjectStore } from "@/lib/ai-edition/store/projectStore"; +import { clearHistory, useUndoRedoShortcuts } from "@/lib/ai-edition/store/undo"; +import { pushHistory } from "@/lib/ai-edition/store/undoStack"; import { TranscriptPane } from "./RightPanes"; vi.mock("@/native/client", () => ({ nativeBridgeClient: { aiEdition: {} } })); @@ -166,3 +173,58 @@ describe("restoring a cut word with the mouse", () => { expect(view.onRemoveTrimRanges).not.toHaveBeenCalled(); }); }); + +describe("restoring a cut word with Ctrl/Cmd+Z in the transcript", () => { + const PROJECT_ID = "project_1"; + const empty = createEmptyDocument({ projectId: PROJECT_ID, title: "t" }); + const cut = { ...empty, timeline: { ...empty.timeline, trimRanges: [CUT] } }; + + beforeEach(() => { + useProjectStore.getState().clear(); + clearHistory(); + // The Backspace that made the cut, as the store records it. + pushHistory({ projectId: PROJECT_ID, doc: empty }); + useProjectStore.setState({ projectId: PROJECT_ID, document: cut }); + }); + + // The shell's wiring: the trims come from the document, undo walks the document. + function renderFromStore() { + let undoHandlers: ReturnType | undefined; + function Harness() { + undoHandlers = useUndoRedoShortcuts(() => undefined); + const trims = useProjectStore((s) => s.document?.timeline.trimRanges ?? []); + return ; + } + const view = render( + + + , + ); + const editor = view.getByRole("textbox"); + // jsdom does not implement `isContentEditable`; a browser reports it for the block, + // and it is what used to hand the shortcut to the browser's text undo, which never + // sees a transcript edit. + Object.defineProperty(editor, "isContentEditable", { value: true }); + editor.focus(); + if (!undoHandlers) throw new Error("undo handlers not mounted"); + return { ...view, editor, runUndo: undoHandlers.runUndo }; + } + + it("undoes the cut from the keyboard (Windows and Linux)", () => { + const view = renderFromStore(); + expect(wordEl(view.container, "w2")).toHaveAttribute("data-skip-id", "trim_1"); + + fireEvent.keyDown(view.editor, { key: "z", ctrlKey: true }); + + expect(wordEl(view.container, "w2")).not.toHaveAttribute("data-skip-id"); + }); + + it("undoes the cut from the Edit menu, Cmd+Z's only route on macOS", () => { + const view = renderFromStore(); + expect(window.document.activeElement).toBe(view.editor); + + act(() => view.runUndo()); + + expect(wordEl(view.container, "w2")).not.toHaveAttribute("data-skip-id"); + }); +}); diff --git a/src/lib/ai-edition/store/undo.ts b/src/lib/ai-edition/store/undo.ts index bc70ea53b..50b12007c 100644 --- a/src/lib/ai-edition/store/undo.ts +++ b/src/lib/ai-edition/store/undo.ts @@ -75,10 +75,18 @@ export function redo(): boolean { * browser's own text undo is the one the user means. The keydown path checks the * event target, the menu path checks `activeElement` — same rule, two entry * points, so it lives in one function. + * + * A block marked `data-document-undo` is editable without being a text field: the + * transcript is contentEditable only to carry a caret, and its edits are document + * writes (Backspace is a trim) that the browser's text history never sees. Left to + * that history, Ctrl/Cmd+Z there did nothing at all (#1012). The fields inside it + * (the word editor) are still fields. */ function isTextEditingTarget(node: EventTarget | null): boolean { if (node instanceof HTMLTextAreaElement || node instanceof HTMLInputElement) return true; - return node instanceof HTMLElement && node.isContentEditable; + return ( + node instanceof HTMLElement && node.isContentEditable && !node.closest("[data-document-undo]") + ); } export interface UndoRedoHandlers {