Skip to content

Commit f7fe890

Browse files
committed
refactor(editor): remove the transport seek bar
The ruler right below already seeks by click and drag, and the arrow keys step a frame. Dropping the bar frees the timeline header.
1 parent 7aa0093 commit f7fe890

21 files changed

Lines changed: 31 additions & 436 deletions

File tree

‎src/components/ai-edition/NewEditorShell.module.css‎

Lines changed: 0 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -764,8 +764,6 @@
764764
display: flex;
765765
align-items: center;
766766
gap: 6px;
767-
flex: 1;
768-
min-width: 0;
769767
}
770768
.transport .tbtn {
771769
width: 32px;
@@ -2260,57 +2258,6 @@ textarea.control {
22602258
.editClipGrip[data-edge="end"]::before { right: 9px; }
22612259
.editClipGrip:hover::before { transform: scaleY(1.25); }
22622260

2263-
.scrubBar {
2264-
flex: 1;
2265-
min-width: 0;
2266-
position: relative;
2267-
height: 24px;
2268-
display: flex;
2269-
align-items: center;
2270-
cursor: pointer;
2271-
}
2272-
.scrubTrack {
2273-
position: absolute;
2274-
left: 0;
2275-
right: 0;
2276-
height: 6px;
2277-
background: var(--border-hi);
2278-
border-radius: 9999px;
2279-
overflow: hidden;
2280-
}
2281-
/* The playhead's violet, not mint: this bar IS the playhead, seen from afar. */
2282-
.scrubProgress {
2283-
height: 100%;
2284-
background: var(--playhead);
2285-
border-radius: 9999px;
2286-
}
2287-
.scrubInput {
2288-
position: absolute;
2289-
inset: 0;
2290-
width: 100%;
2291-
height: 100%;
2292-
opacity: 0;
2293-
cursor: pointer;
2294-
z-index: 10;
2295-
margin: 0;
2296-
}
2297-
.scrubThumb {
2298-
position: absolute;
2299-
width: 12px;
2300-
height: 12px;
2301-
background: #ffffff;
2302-
border-radius: 50%;
2303-
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
2304-
pointer-events: none;
2305-
transform: translate(-50%, -50%);
2306-
top: 50%;
2307-
opacity: 1;
2308-
transition: transform 100ms ease;
2309-
}
2310-
.scrubBar:hover .scrubThumb {
2311-
transform: translate(-50%, -50%) scale(1.2);
2312-
}
2313-
23142261
/* ─── AI provider settings (axcut parity) ────────────────────────── */
23152262

23162263
.providerGrid {
Lines changed: 3 additions & 171 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// @vitest-environment jsdom
22
import "@testing-library/jest-dom";
3-
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
3+
import { act, cleanup, render, screen } from "@testing-library/react";
44
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
55
import { I18nProvider } from "@/contexts/I18nContext";
66
import { useProjectStore } from "@/lib/ai-edition/store/projectStore";
@@ -45,13 +45,7 @@ describe("TransportBar reads the playhead from the store", () => {
4545
function Parent() {
4646
parentRenders++;
4747
return (
48-
<TransportBar
49-
playing={false}
50-
overrideTimeSec={null}
51-
clips={clips}
52-
onTogglePlay={noop}
53-
onSeek={noop}
54-
/>
48+
<TransportBar playing={false} overrideTimeSec={null} clips={clips} onTogglePlay={noop} />
5549
);
5650
}
5751

@@ -77,171 +71,9 @@ describe("TransportBar reads the playhead from the store", () => {
7771
useProjectStore.setState({ currentTimeSec: 12.3 });
7872
render(
7973
<I18nProvider>
80-
<TransportBar
81-
playing={false}
82-
overrideTimeSec={4.5}
83-
clips={clips}
84-
onTogglePlay={noop}
85-
onSeek={noop}
86-
/>
74+
<TransportBar playing={false} overrideTimeSec={4.5} clips={clips} onTogglePlay={noop} />
8775
</I18nProvider>,
8876
);
8977
expect(screen.getByText("0:04.5")).toBeInTheDocument();
9078
});
9179
});
92-
93-
describe("le drag de la barre de progression est coalescé en rAF", () => {
94-
beforeEach(() => {
95-
useProjectStore.setState({ currentTimeSec: 0 });
96-
});
97-
98-
afterEach(() => {
99-
cleanup();
100-
vi.clearAllMocks();
101-
vi.unstubAllGlobals();
102-
});
103-
104-
/** rAF piloté à la main : les callbacks ne partent que sur `flush()`, ce qui permet de
105-
* compter ce qui se passe DANS une frame — impossible avec le vrai rAF en test. */
106-
function stubRaf() {
107-
const pending: FrameRequestCallback[] = [];
108-
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {
109-
pending.push(cb);
110-
return pending.length;
111-
});
112-
vi.stubGlobal("cancelAnimationFrame", () => {
113-
// rien à annuler : `flush()` ne rejoue que ce qui reste en attente
114-
});
115-
return () => {
116-
const due = pending.splice(0, pending.length);
117-
for (const cb of due) cb(0);
118-
};
119-
}
120-
121-
// C'est LE défaut que ce chemin avait : `onChange` d'un `<input type="range">` se
122-
// déclenche à la cadence du pointeur (jusqu'à 1000 Hz) et appelait `onSeek` à chaque
123-
// fois. Or `onSeek` repose un `seekTarget` dans l'état de la racine de l'éditeur, donc
124-
// re-rend tout et fait poser `<video>.currentTime`. La timeline fait les mêmes appels
125-
// mais une fois par frame ; ce test verrouille cette parité.
126-
it("n'émet qu'un seul seek par frame, quel que soit le nombre d'événements", () => {
127-
const flush = stubRaf();
128-
const onSeek = vi.fn();
129-
render(
130-
<I18nProvider>
131-
<TransportBar
132-
playing={false}
133-
overrideTimeSec={null}
134-
clips={clips}
135-
onTogglePlay={noop}
136-
onSeek={onSeek}
137-
/>
138-
</I18nProvider>,
139-
);
140-
const input = screen.getByLabelText(/seek/i) as HTMLInputElement;
141-
142-
act(() => {
143-
fireEvent.pointerDown(input);
144-
});
145-
// Dix mouvements dans la même frame : un seul seek doit en sortir, et c'est le
146-
// DERNIER qui compte — sans quoi la tête accuserait un retard permanent.
147-
act(() => {
148-
for (const value of [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) {
149-
fireEvent.change(input, { target: { value: String(value) } });
150-
}
151-
});
152-
expect(onSeek).not.toHaveBeenCalled();
153-
154-
act(() => flush());
155-
expect(onSeek).toHaveBeenCalledTimes(1);
156-
expect(onSeek).toHaveBeenLastCalledWith(10);
157-
});
158-
159-
// Le mouvement compris entre le dernier rAF et le relâchement serait perdu sans commit
160-
// final : la tête s'arrêterait un cran avant le doigt.
161-
it("pose la dernière position au relâchement", () => {
162-
stubRaf();
163-
const onSeek = vi.fn();
164-
render(
165-
<I18nProvider>
166-
<TransportBar
167-
playing={false}
168-
overrideTimeSec={null}
169-
clips={clips}
170-
onTogglePlay={noop}
171-
onSeek={onSeek}
172-
/>
173-
</I18nProvider>,
174-
);
175-
const input = screen.getByLabelText(/seek/i) as HTMLInputElement;
176-
177-
act(() => {
178-
fireEvent.pointerDown(input);
179-
fireEvent.change(input, { target: { value: "7" } });
180-
fireEvent.pointerUp(input);
181-
});
182-
expect(onSeek).toHaveBeenCalledWith(7);
183-
});
184-
185-
// Hors drag il n'y a rien à coalescer : une flèche du clavier est un saut unique et
186-
// doit prendre effet immédiatement, sans attendre une frame.
187-
it("applique immédiatement un changement hors drag", () => {
188-
stubRaf();
189-
const onSeek = vi.fn();
190-
render(
191-
<I18nProvider>
192-
<TransportBar
193-
playing={false}
194-
overrideTimeSec={null}
195-
clips={clips}
196-
onTogglePlay={noop}
197-
onSeek={onSeek}
198-
/>
199-
</I18nProvider>,
200-
);
201-
const input = screen.getByLabelText(/seek/i) as HTMLInputElement;
202-
203-
act(() => {
204-
fireEvent.change(input, { target: { value: "3" } });
205-
});
206-
expect(onSeek).toHaveBeenCalledTimes(1);
207-
expect(onSeek).toHaveBeenCalledWith(3);
208-
});
209-
210-
// Le remplissage et le curseur étaient positionnés uniquement par React depuis le store,
211-
// donc en retard d'un commit à chaque mouvement — alors que la tête de lecture de la
212-
// timeline, elle, est écrite directement dans le DOM et colle au pointeur. Ce test
213-
// verrouille la parité : le visuel bouge AVANT le rAF, donc sans attendre React.
214-
it("déplace le curseur dans le DOM avant tout rendu React", () => {
215-
stubRaf();
216-
const onSeek = vi.fn();
217-
const { container } = render(
218-
<I18nProvider>
219-
<TransportBar
220-
playing={false}
221-
overrideTimeSec={null}
222-
clips={clips}
223-
onTogglePlay={noop}
224-
onSeek={onSeek}
225-
/>
226-
</I18nProvider>,
227-
);
228-
const input = screen.getByLabelText(/seek/i) as HTMLInputElement;
229-
// Les deux éléments décorés : le remplissage (largeur) et le curseur (position).
230-
const styled = Array.from(container.querySelectorAll("div")).filter(
231-
(el) => el.style.width !== "" || el.style.left !== "",
232-
);
233-
expect(styled.length).toBeGreaterThanOrEqual(2);
234-
235-
act(() => {
236-
fireEvent.pointerDown(input);
237-
// La moitié de la durée (clip de 30 s) → 50 %.
238-
fireEvent.change(input, { target: { value: "15" } });
239-
});
240-
241-
// Aucun rAF n'a été vidé, donc aucune écriture au store et aucun rendu React : ce qui
242-
// a bougé ne peut venir que de l'écriture DOM directe.
243-
expect(onSeek).not.toHaveBeenCalled();
244-
const moved = styled.filter((el) => el.style.width === "50%" || el.style.left === "50%");
245-
expect(moved.length).toBeGreaterThanOrEqual(2);
246-
});
247-
});

0 commit comments

Comments
 (0)