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 {