diff --git a/src/components/ai-edition/RightPanes.tsx b/src/components/ai-edition/RightPanes.tsx index fe1f00bf6..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 @@ -1771,7 +1774,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 +1828,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 +1873,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 +1904,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 +1944,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 +1974,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..5afd738d4 --- /dev/null +++ b/src/components/ai-edition/TranscriptPane.restore.test.tsx @@ -0,0 +1,230 @@ +// @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. +// +// 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 { act, cleanup, fireEvent, render } from "@testing-library/react"; +import { useState } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { I18nProvider } from "@/contexts/I18nContext"; +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: {} } })); +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(); + }); +}); + +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 {