Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 48 additions & 40 deletions src/components/ai-edition/RightPanes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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)",
Expand Down Expand Up @@ -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 ? (
<button
type="button"
contentEditable={false}
title={ts("transcript.restoreWord", { word: cw.word.text })}
aria-label={ts("transcript.restoreWord", { word: cw.word.text })}
onClick={(e) => {
e.stopPropagation();
<WordChipButton
label={ts("transcript.restoreWord", { word: cw.word.text })}
tone="var(--danger)"
onPress={() =>
// build a minimal TrimRun stub — only the ids are
// read by onRestore.
onRestore({
Expand All @@ -1909,26 +1916,11 @@ const TranscriptWord = memo(function TranscriptWord({
startWordIndex: 0,
endWordIndex: 0,
durationSec: 0,
});
}}
style={{
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
width: 18,
height: 18,
marginLeft: 4,
padding: 0,
border: 0,
borderRadius: 4,
background: "var(--danger)",
color: "white",
cursor: "pointer",
verticalAlign: "middle",
}}
})
}
>
<Trash2 size={12} strokeWidth={1.9} aria-hidden="true" />
</button>
</WordChipButton>
) : 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. */}
Expand All @@ -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,
Expand All @@ -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}
<span
style={{
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
width: 18,
height: 18,
borderRadius: 4,
background: tone,
color: "white",
}}
>
{children}
</span>
</button>
);
}
Expand Down
230 changes: 230 additions & 0 deletions src/components/ai-edition/TranscriptPane.restore.test.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<TranscriptPane
clips={[CLIP]}
audioTracks={[]}
transcripts={[TRANSCRIPT]}
assets={[ASSET]}
trimRanges={trimRanges}
busyAssetIds={[]}
onSeek={vi.fn()}
onTrimTimelineSpan={vi.fn()}
onRemoveTrimRanges={onRemoveTrimRanges}
onSetWordText={vi.fn()}
onInsertWord={vi.fn()}
onRemoveWords={vi.fn()}
onTranscribe={vi.fn()}
canTranscribe
isTranscribing={false}
/>
);
}

function wordEl(container: HTMLElement, id: string): HTMLElement {
const el = container.querySelector<HTMLElement>(`[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 (
<Pane
trimRanges={trims}
onRemoveTrimRanges={(ids) => {
onRemoveTrimRanges(ids);
setTrims((current) => current.filter((trim) => !ids.includes(trim.id)));
}}
/>
);
}
const view = render(
<I18nProvider>
<Harness />
</I18nProvider>,
);
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<typeof useUndoRedoShortcuts> | undefined;
function Harness() {
undoHandlers = useUndoRedoShortcuts(() => undefined);
const trims = useProjectStore((s) => s.document?.timeline.trimRanges ?? []);
return <Pane trimRanges={trims} onRemoveTrimRanges={vi.fn()} />;
}
const view = render(
<I18nProvider>
<Harness />
</I18nProvider>,
);
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");
});
});
Loading
Loading