diff --git a/src/components/ai-edition/NewEditorShell.tsx b/src/components/ai-edition/NewEditorShell.tsx index fabfc3acb..465951874 100644 --- a/src/components/ai-edition/NewEditorShell.tsx +++ b/src/components/ai-edition/NewEditorShell.tsx @@ -1698,6 +1698,19 @@ export function NewEditorShell() { ) : null} + enqueueTimelineWrite(async () => { + const doc = useProjectStore.getState().document; + const range = doc ? resolveRange(doc) : null; + if (range) await tl.applyClipEdit(clipId, range.start, range.end); + }) + } setCurrentTime={handleSeek} variant={mode === "media" ? "media" : "edit"} onDropAsset={handleDropAsset} diff --git a/src/components/ai-edition/v4/EditorShellV4.module.css b/src/components/ai-edition/v4/EditorShellV4.module.css index 4b456274d..4e08a43e9 100644 --- a/src/components/ai-edition/v4/EditorShellV4.module.css +++ b/src/components/ai-edition/v4/EditorShellV4.module.css @@ -1876,6 +1876,76 @@ exactly that. Nothing fits inside it, so while it is SELECTED its controls step out to the right and float over whatever follows. Selection-only, so the timeline is never littered, and no layout above or below has to make room for them. */ +/* Trim grips. They sit INSIDE the card rather than straddling its edge, because + .tlClip is overflow:hidden and anything hanging outside would be cut off — and + because a grip that overhangs would cover the neighbouring clip's own grip in a + back-to-back row. 10px is the smallest that stays reliably hittable without + eating a narrow card whole, which is why they are hidden below NARROW_CLIP_PX + in the first place. */ +.tlClipEdge { + position: absolute; + top: 0; + bottom: 0; + width: 10px; + padding: 0; + border: 0; + background: transparent; + cursor: ew-resize; + z-index: 3; + touch-action: none; +} +.tlClipEdge[data-edge="start"] { + left: 0; +} +.tlClipEdge[data-edge="end"] { + right: 0; +} +/* The grip is invisible until the pointer is on the card: a clip row is a dense + place, and two permanent bars per card would read as clip boundaries rather + than as controls. */ +.tlClipEdge::after { + content: ""; + position: absolute; + top: 50%; + left: 3px; + right: 3px; + height: min(60%, 26px); + transform: translateY(-50%); + border-radius: 2px; + background: var(--fg-emphasis); + opacity: 0; + transition: opacity var(--motion-fast) var(--ease); +} +.tlClip:hover .tlClipEdge::after { + opacity: 0.3; +} +.tlClipEdge:hover::after { + opacity: 0.75; +} +/* Keyboard focus is NOT the hover state. The shared ring the rest of the editor + uses sits at outline-offset 2px, which .tlClip's overflow:hidden would clip away + on a grip flush against the card edge — so the indicator is drawn inside the + grip instead: the accent colour rather than the neutral bar, full height, and + opaque. That is a change of hue, size and weight at once, none of which hover + does, which is what keeps the two states from reading alike. */ +.tlClipEdge:focus-visible { + outline: none; +} +.tlClipEdge:focus-visible::after { + opacity: 1; + height: 100%; + left: 2px; + right: 2px; + background: var(--accent); + box-shadow: var(--focus-ring); +} +/* The siblings a live trim pushes along have to track the pointer, not glide + 150ms behind it. .tlClip's transform transition is right for a reorder, where + the neighbours settle into a gap; here it reads as rubber. */ +.tlClipRippling { + transition: none; +} + .tlClipNarrow .tlClipLabel { display: none; } diff --git a/src/components/ai-edition/v4/V4Timeline.geometry.test.tsx b/src/components/ai-edition/v4/V4Timeline.geometry.test.tsx index 01269a5c5..be180bd1b 100644 --- a/src/components/ai-edition/v4/V4Timeline.geometry.test.tsx +++ b/src/components/ai-edition/v4/V4Timeline.geometry.test.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom import "@testing-library/jest-dom"; import { act, fireEvent, render, screen } from "@testing-library/react"; -import { Profiler, type ProfilerOnRenderCallback } from "react"; +import { type ComponentProps, Profiler, type ProfilerOnRenderCallback } from "react"; import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; // The regression under test is geometric, so the environment has to have a size: @@ -25,10 +25,13 @@ vi.mock("@/hooks/useAudioPeaks", () => ({ useAudioPeaks: () => null })); const measureText = vi.fn((text: string) => ({ width: text.length * 6 })); import { ShortcutsProvider } from "@/contexts/ShortcutsContext"; +import type { AxcutDocument } from "@/lib/ai-edition/schema"; import type { useTimeline } from "@/lib/ai-edition/store/useTimeline"; import { DEFAULT_SHORTCUTS, formatBinding } from "@/lib/shortcuts"; import { V4Timeline } from "./V4Timeline"; +type OnApplyClipEdit = ComponentProps["onApplyClipEdit"]; + beforeAll(() => { globalThis.ResizeObserver = class { // jsdom has none, and the width it would report is stubbed below anyway. @@ -71,7 +74,19 @@ afterEach(() => { measureText.mockImplementation((text: string) => ({ width: text.length * 6 })); }); -function clip(startSec: number, endSec: number) { +/** `sourceEndSec` is OPTIONAL here because it is optional in the schema: a clip whose + * asset was never probed carries none, and the trim has to cope with that. Spelling the + * return type out is what lets a fixture leave it off. */ +type TestClip = { + id: string; + assetId: string; + timelineStartSec: number; + timelineEndSec: number; + sourceStartSec: number; + sourceEndSec?: number; +}; + +function clip(startSec: number, endSec: number): TestClip { return { id: `c@${startSec}`, assetId: "a1", @@ -92,6 +107,9 @@ function renderTimeline( annotation = { id: "ann1", startMs: 10_000, endMs: 11_000 }, assets: Array> = [NO_CAMERA_ASSET], onRender?: ProfilerOnRenderCallback, + /** Overrides for the props the shell owns. Only the write callback needs it so far: + * a test that wants to see WHERE the commit goes has to be handed its own spy. */ + overrides: { onApplyClipEdit?: OnApplyClipEdit } = {}, ) { const tl = { clips, @@ -119,6 +137,23 @@ function renderTimeline( addZoom: vi.fn(async () => { /* the toolbar only awaits it */ }), + applyClipEdit: vi.fn(async (_clipId: string, _startSec: number, _endSec: number) => { + /* the edge trim only awaits it */ + }), + }; + // A stand-in for the shell's write queue, faithful in the one way the trim depends on: + // the range is resolved when the write runs, against the document the previous write + // left, and the save lands in that document. The rendered `tl` above never re-renders, + // which is the point: every keydown lands in the same stale render, as the repeats of + // a held key do. Clips are copied so a test mutating the document leaves its fixtures be. + const shellDoc = { timeline: { clips: clips.map((c) => ({ ...c })) }, assets }; + const applyThroughShell: OnApplyClipEdit = async (clipId, resolveRange) => { + const range = resolveRange(shellDoc as unknown as AxcutDocument); + if (!range) return; + shellDoc.timeline.clips = shellDoc.timeline.clips.map((c) => + c.id === clipId ? { ...c, sourceStartSec: range.start, sourceEndSec: range.end } : c, + ); + await tl.applyClipEdit(clipId, range.start, range.end); }; const setCurrentTime = vi.fn(); const timeline = ( @@ -133,11 +168,12 @@ function renderTimeline( onPrevClip={vi.fn()} onNextClip={vi.fn()} onEditClip={vi.fn()} + onApplyClipEdit={overrides.onApplyClipEdit ?? applyThroughShell} onAddVoiceover={vi.fn()} /> ); - render( + const view = render( onRender ? ( {timeline} @@ -150,7 +186,11 @@ function renderTimeline( pill: screen.getByTitle("toolbar.newAnnotation"), clipEls: Array.from(document.querySelectorAll("[data-clip-id]")), tl, + shellDoc, setCurrentTime, + // A drag keeps its listeners on `window`, so a test can outlive the component + // on purpose and see what the gesture does without one. + unmount: view.unmount, }; } @@ -158,20 +198,29 @@ afterEach(() => { vi.unstubAllGlobals(); }); +/** One pointer's event, carrying the id that ties it to the gesture it belongs to. + * jsdom's `fireEvent.pointerDown` defaults `pointerId` to 0 while a hand-built + * `MouseEvent` leaves it undefined, so a handler that filters on the id — the edge + * trim does, or a second finger could end someone else's drag — would ignore a + * sequence dispatched as plain mouse events. */ +const pointerEvent = (type: string, clientX: number, pointerId = 0) => + new PointerEvent(type, { clientX, pointerId, bubbles: true }); + /** Drag a handle by `dxPx`. The move/up listeners live on `window`, so the drag * is driven by pointer deltas alone — the handle may re-mount under it. */ -function dragHandle(handle: Element, dxPx: number) { - fireEvent.pointerDown(handle, { clientX: 0 }); - window.dispatchEvent(new MouseEvent("pointermove", { clientX: dxPx })); - window.dispatchEvent(new MouseEvent("pointerup", { clientX: dxPx })); +function dragHandle(handle: Element, dxPx: number, pointerId = 0) { + fireEvent.pointerDown(handle, { clientX: 0, pointerId }); + window.dispatchEvent(pointerEvent("pointermove", dxPx, pointerId)); + window.dispatchEvent(pointerEvent("pointerup", dxPx, pointerId)); } /** Ctrl+wheel up = zoom in; the handler is a native listener, so dispatch real events. * Takes the target element so a test can prove the listener isn't confined to the * lanes — it fires from wherever in the pane the cursor happens to be. */ function wheelZoomOn(el: HTMLElement, notches: number) { - for (let i = 0; i < notches; i++) { - fireEvent.wheel(el, { ctrlKey: true, deltaY: -100, clientX: 0 }); + // Negative notches zoom back out. + for (let i = 0; i < Math.abs(notches); i++) { + fireEvent.wheel(el, { ctrlKey: true, deltaY: notches < 0 ? 100 : -100, clientX: 0 }); } } function zoomIn(notches: number) { @@ -610,6 +659,7 @@ describe("V4Timeline audio lane drag", () => { onPrevClip={vi.fn()} onNextClip={vi.fn()} onEditClip={vi.fn()} + onApplyClipEdit={vi.fn()} onAddVoiceover={props.onAddVoiceover ?? vi.fn()} /> , @@ -779,3 +829,509 @@ describe("V4Timeline audio lane drag", () => { expect(placement.endMs - placement.startMs).toBeLessThan(60_000); }); }); + +// Trimming a clip by dragging its own edge in the row, rather than opening the +// Edit modal to move the same two numbers. The document work is shared with that +// modal (applyClipEdit → setClipSourceRange); what is new here is turning pointer +// travel into a source range, and refusing the ranges that are not edits. +// +// The arithmetic below rests on pxPerSec: VIEWPORT_PX / total. With the default +// 1800s timeline that is 0.5px per second, so 1px of travel is 2 seconds. +describe("V4Timeline clip edge trim", () => { + const gripsOf = (clipEl: Element) => + Array.from(clipEl.querySelectorAll("[data-edge]")); + const gripFor = (clipEl: Element, edge: "start" | "end") => + clipEl.querySelector(`[data-edge="${edge}"]`) as HTMLElement; + + it("takes the tail in when the end grip is dragged left", () => { + const { clipEls, tl } = renderTimeline(); + dragHandle(gripFor(clipEls[0], "end"), -100); + expect(tl.applyClipEdit).toHaveBeenCalledWith("c@0", 0, 1600); + }); + + it("takes the head in when the start grip is dragged right", () => { + const { clipEls, tl } = renderTimeline(); + dragHandle(gripFor(clipEls[0], "start"), 100); + expect(tl.applyClipEdit).toHaveBeenCalledWith("c@0", 200, 1800); + }); + + // The clip is 900s of an 1800s file, so there is real footage to give back. + it("lets the tail back out into footage the file still has", () => { + const { clipEls, tl } = renderTimeline([clip(0, 900)]); + // One clip spanning the timeline: pxPerSec is 1 here, not 0.5. + dragHandle(gripFor(clipEls[0], "end"), 100); + expect(tl.applyClipEdit).toHaveBeenCalledWith("c@0", 0, 1000); + }); + + // The asset is 1800s and the clip already ends there, so there is nothing to + // give back. Inventing footage past the end of the file is the failure this + // clamp exists to prevent. + it("refuses to pull the tail past the end of the file", () => { + const { clipEls, tl } = renderTimeline(); + dragHandle(gripFor(clipEls[0], "end"), 400); + expect(tl.applyClipEdit).not.toHaveBeenCalled(); + }); + + it("refuses to push the head before the start of the file", () => { + const { clipEls, tl } = renderTimeline(); + dragHandle(gripFor(clipEls[0], "start"), -400); + expect(tl.applyClipEdit).not.toHaveBeenCalled(); + }); + + // Dragged clean through its own start: the clip stops at the floor rather than + // inverting, which would hand setClipSourceRange a backwards range. + it("stops at the minimum length instead of turning the clip inside out", () => { + const { clipEls, tl } = renderTimeline([clip(0, 900)]); + dragHandle(gripFor(clipEls[0], "end"), -2000); + expect(tl.applyClipEdit).toHaveBeenCalledWith("c@0", 0, 0.05); + }); + + // A grip is a plausible thing to click by accident on the way to selecting a + // clip, and an empty step on the undo stack is the tell that it happened. + it("writes nothing for a press that never moved", () => { + const { clipEls, tl } = renderTimeline(); + const grip = gripFor(clipEls[0], "end"); + fireEvent.pointerDown(grip, { clientX: 0 }); + window.dispatchEvent(pointerEvent("pointerup", 0)); + expect(tl.applyClipEdit).not.toHaveBeenCalled(); + }); + + // At this zoom a pixel is two seconds, so the jitter of an ordinary click on a grip is + // several seconds of timeline. Without a dead zone that committed a real trim, and an + // undo step, from a press that was never meant as a drag. + it("writes nothing for a press that wobbles inside the drag dead zone", () => { + const { clipEls, tl } = renderTimeline(); + fireEvent.pointerDown(gripFor(clipEls[0], "end"), { clientX: 0 }); + window.dispatchEvent(pointerEvent("pointermove", -1)); + window.dispatchEvent(pointerEvent("pointermove", -3)); + window.dispatchEvent(pointerEvent("pointerup", -3)); + expect(tl.applyClipEdit).not.toHaveBeenCalled(); + }); + + // Past the dead zone the move counts from the press, so the edge does not trail the + // pointer by the width of the zone. + it("measures a drag from the press once it leaves the dead zone", () => { + const { clipEls, tl } = renderTimeline(); + fireEvent.pointerDown(gripFor(clipEls[0], "end"), { clientX: 0 }); + window.dispatchEvent(pointerEvent("pointermove", -3)); + window.dispatchEvent(pointerEvent("pointermove", -10)); + window.dispatchEvent(pointerEvent("pointerup", -10)); + expect(tl.applyClipEdit).toHaveBeenCalledWith("c@0", 0, 1780); + }); + + // Two grips on a clip a few pixels wide would cover it entirely and leave no + // body to grab for a reorder. The pencil stays the way in at that size. + it("keeps its grips off a clip too narrow to hold them", () => { + const { clipEls } = renderTimeline([clip(0, 1790), clip(1790, 1800)]); + expect(gripsOf(clipEls[0])).toHaveLength(2); + expect(gripsOf(clipEls[1])).toHaveLength(0); + }); + + // Ctrl+wheel zooms the row with the pointer still down. The drag used to keep the scale + // it was pressed at, so after a zoom the same travel still counted two seconds a pixel + // while the row was being drawn at the new scale, and the edge came off the cursor. + it("reads the zoom a drag is at on every move, not the one it was pressed at", () => { + const { clipEls, tl } = renderTimeline(); + fireEvent.pointerDown(gripFor(clipEls[0], "end"), { clientX: 0, pointerId: 1 }); + window.dispatchEvent(pointerEvent("pointermove", -100, 1)); + zoomIn(10); + window.dispatchEvent(pointerEvent("pointermove", -100, 1)); + window.dispatchEvent(pointerEvent("pointerup", -100, 1)); + expect(tl.applyClipEdit).toHaveBeenCalledTimes(1); + const end = tl.applyClipEdit.mock.calls[0][2]; + // Zoomed in, a pixel is less than two seconds: the same 100px trims less than 200s. + expect(end).toBeGreaterThan(1600); + expect(end).toBeLessThan(1800); + }); + + // Whether a card is wide enough for grips is decided by a width that changes under the + // grip in use: a nudge shortens the clip, a Ctrl+wheel zooms the row. Unmounting the + // focused grip dropped focus to the body mid-edit. Zoomed in, the short clip has room + // for grips; zoomed back out it has not, and the one holding focus has to survive. + it("keeps a focused grip mounted when its card narrows under it", () => { + const { clipEls } = renderTimeline([clip(0, 1750), clip(1750, 1800)]); + expect(gripsOf(clipEls[1])).toHaveLength(0); + zoomIn(40); + const grip = gripFor(clipEls[1], "end"); + act(() => { + grip.focus(); + }); + wheelZoomOn(document.querySelector("[class*=tlTracks]") as HTMLElement, -40); + expect(grip.isConnected).toBe(true); + expect(document.activeElement).toBe(grip); + + // Moving to the other grip of the same card keeps both; leaving the card lets them go. + const other = gripFor(clipEls[1], "start"); + act(() => { + other.focus(); + }); + expect(document.activeElement).toBe(other); + act(() => { + other.blur(); + }); + expect(gripsOf(clipEls[1])).toHaveLength(0); + }); + + // A grip is a button, which carries no value, so a nudge changes nothing a screen + // reader hears. A polite live region says the length while a grip has focus. + it("speaks the clip's length while one of its grips has focus", () => { + const { clipEls } = renderTimeline([clip(0, 900)]); + const live = () => clipEls[0].querySelector('[aria-live="polite"]'); + expect(live()).toBeNull(); + act(() => { + gripFor(clipEls[0], "end").focus(); + }); + expect(live()?.textContent).toBe("15:00.0"); + }); + + // The grips are focusable buttons, so they owe the keyboard an answer. + it("nudges by a tenth with an arrow, and by a second with shift", () => { + const { clipEls, tl } = renderTimeline(); + const grip = gripFor(clipEls[0], "end"); + fireEvent.keyDown(grip, { key: "ArrowLeft" }); + expect(tl.applyClipEdit).toHaveBeenLastCalledWith("c@0", 0, 1799.9); + fireEvent.keyDown(grip, { key: "ArrowLeft", shiftKey: true }); + expect(tl.applyClipEdit).toHaveBeenLastCalledWith("c@0", 0, 1798.9); + }); + + const committedEnds = (tl: { applyClipEdit: { mock: { calls: unknown[][] } } }) => + tl.applyClipEdit.mock.calls.map((call) => call[2] as number); + + // A held arrow repeats about thirty times a second, far faster than a save comes back + // and the row re-renders, so every repeat lands in the same stale render. Each step has + // to build on the one the queue committed before it: computed from the render, all of + // them named the same range, and a held key moved the edge a tenth however long it + // was held while still pushing an undo step per repeat. + it("builds each repeat of a held arrow on the step before it, not on the render", () => { + const { clipEls, tl } = renderTimeline(); + const grip = gripFor(clipEls[0], "end"); + fireEvent.keyDown(grip, { key: "ArrowLeft" }); + fireEvent.keyDown(grip, { key: "ArrowLeft" }); + fireEvent.keyDown(grip, { key: "ArrowLeft" }); + const ends = committedEnds(tl); + expect(ends).toHaveLength(3); + expect(ends[0]).toBeCloseTo(1799.9, 6); + expect(ends[1]).toBeCloseTo(1799.8, 6); + expect(ends[2]).toBeCloseTo(1799.7, 6); + }); + + // "Against the stop" is a fact about the document, not the render. From the render, the + // step back out after a nudge in looked like a no-op (the render still ends at the file's + // end) and was dropped, while a step past the stop would have been written. + it("judges the stop against the latest document, and saves nothing past it", () => { + const { clipEls, tl } = renderTimeline(); + const grip = gripFor(clipEls[0], "end"); + fireEvent.keyDown(grip, { key: "ArrowLeft" }); + fireEvent.keyDown(grip, { key: "ArrowRight" }); + fireEvent.keyDown(grip, { key: "ArrowRight" }); + const ends = committedEnds(tl); + expect(ends).toHaveLength(2); + expect(ends[0]).toBeCloseTo(1799.9, 6); + expect(ends[1]).toBeCloseTo(1800, 6); + }); + + // The document can change under a drag that is still held: Ctrl+Z with the pointer + // down, or a queued write landing. The preview is the committed length plus the move, + // so the move is what gets committed, applied to the clip as it is at release. The + // range the drag worked out at pointerdown would put the undone trim back. + it("commits a drag's move against the clip as it is on release", () => { + const { clipEls, tl, shellDoc } = renderTimeline(); + fireEvent.pointerDown(gripFor(clipEls[0], "end"), { clientX: 0, pointerId: 1 }); + window.dispatchEvent(pointerEvent("pointermove", -100, 1)); + shellDoc.timeline.clips[0] = { ...shellDoc.timeline.clips[0], sourceEndSec: 1000 }; + window.dispatchEvent(pointerEvent("pointerup", -100, 1)); + expect(tl.applyClipEdit).toHaveBeenCalledTimes(1); + expect(tl.applyClipEdit).toHaveBeenCalledWith("c@0", 0, 800); + }); + + // `sourceEndSec` is optional in the schema — an unprobed asset carries none — and + // the waveform painter has always stood in the clip's timeline length for it. The + // trim handlers defaulted to 0 instead, which puts the out-point BEFORE the + // in-point: `setClipSourceRange` then orders the pair and commits a clip collapsed + // to the minimum rather than the trim that was asked for. + const unprobed = () => ({ ...clip(0, TOTAL_SEC), sourceEndSec: undefined }); + + it("trims a clip whose out-point was never probed against the length it occupies", () => { + const { clipEls, tl } = renderTimeline([unprobed()]); + dragHandle(gripFor(clipEls[0], "end"), -100); + expect(tl.applyClipEdit).toHaveBeenCalledWith("c@0", 0, 1600); + }); + + it("nudges an unprobed clip against that same length", () => { + const { clipEls, tl } = renderTimeline([unprobed()]); + fireEvent.keyDown(gripFor(clipEls[0], "end"), { key: "ArrowLeft" }); + expect(tl.applyClipEdit).toHaveBeenLastCalledWith("c@0", 0, 1799.9); + }); + + // A palm rejection, a system gesture or a lost capture takes the pointer away and + // sends no `pointerup` at all. The drag has to end there: cancelled means abandoned, + // and a drag left live would commit on whatever release came next. + it("abandons the trim when the browser cancels the pointer", () => { + const { clipEls, tl } = renderTimeline(); + const grip = gripFor(clipEls[0], "end"); + fireEvent.pointerDown(grip, { clientX: 0 }); + window.dispatchEvent(pointerEvent("pointermove", -100)); + window.dispatchEvent(pointerEvent("pointercancel", -100)); + expect(tl.applyClipEdit).not.toHaveBeenCalled(); + + // And the listeners went with it, so a later, unrelated release is not the + // cancelled trim's to commit. + window.dispatchEvent(pointerEvent("pointermove", -300)); + window.dispatchEvent(pointerEvent("pointerup", -300)); + expect(tl.applyClipEdit).not.toHaveBeenCalled(); + }); + + // `window` hears every pointer on the device. On a touchscreen a second finger is an + // ordinary thing to put down mid-drag, and it used to end the first one's trim — + // committing a range from a release that happened somewhere else entirely, and taking + // the `{ once: true }` listeners with it so the finger still dragging ended up + // attached to nothing. + it("lets a second finger come and go without ending the first one's trim", () => { + const { clipEls, tl } = renderTimeline(); + const grip = gripFor(clipEls[0], "end"); + fireEvent.pointerDown(grip, { clientX: 0, pointerId: 1 }); + window.dispatchEvent(pointerEvent("pointermove", -50, 1)); + + // Another pointer lands far away, moves, and lifts. + window.dispatchEvent(pointerEvent("pointermove", 400, 2)); + window.dispatchEvent(pointerEvent("pointerup", 400, 2)); + expect(tl.applyClipEdit).not.toHaveBeenCalled(); + + // The trim is still live, still the first pointer's, and still tracking only it. + window.dispatchEvent(pointerEvent("pointermove", -100, 1)); + window.dispatchEvent(pointerEvent("pointerup", -100, 1)); + expect(tl.applyClipEdit).toHaveBeenCalledTimes(1); + expect(tl.applyClipEdit).toHaveBeenCalledWith("c@0", 0, 1600); + }); + + // Same for a cancel: the browser taking another pointer away says nothing about this one. + it("keeps the trim when a different pointer is cancelled", () => { + const { clipEls, tl } = renderTimeline(); + const grip = gripFor(clipEls[0], "end"); + fireEvent.pointerDown(grip, { clientX: 0, pointerId: 1 }); + window.dispatchEvent(pointerEvent("pointermove", -100, 1)); + window.dispatchEvent(pointerEvent("pointercancel", 0, 2)); + window.dispatchEvent(pointerEvent("pointerup", -100, 1)); + expect(tl.applyClipEdit).toHaveBeenCalledWith("c@0", 0, 1600); + }); + + // The card resizes live under the drag; the duration printed inside it has to go + // with it. It is the precise half of the preview, and the keyboard step is a tenth + // precisely because this is printed to a tenth. + it("counts the duration down as the clip is dragged shorter", () => { + const { clipEls, tl } = renderTimeline([clip(0, 900)]); + const durationOf = () => document.querySelector('[class*="tlClipDuration"]')?.textContent; + expect(durationOf()).toBe("15:00.0"); + + // `act` because these go straight to `window`, unlike fireEvent: the preview is + // React state, and an unflushed render would read as the bug this guards. + // pxPerSec is 1 on a single clip spanning the timeline, so 100px is 100s. + fireEvent.pointerDown(gripFor(clipEls[0], "end"), { clientX: 0, pointerId: 1 }); + act(() => { + window.dispatchEvent(pointerEvent("pointermove", -100, 1)); + }); + expect(durationOf()).toBe("13:20.0"); + + // And back to the committed value once the gesture is abandoned. + act(() => { + window.dispatchEvent(pointerEvent("pointercancel", -100, 1)); + }); + expect(durationOf()).toBe("15:00.0"); + expect(tl.applyClipEdit).not.toHaveBeenCalled(); + }); + + // The save is async, and the row only shows the trimmed length once the store holds it. + // Dropping the preview on release put the card back at its old length for as long as + // the save took, then jumped it to the new one: the reorder keeps its preview up through + // its save for exactly this, and the trim has to as well. + it("keeps the trimmed length on screen until the save has landed", async () => { + let landSave = () => { + /* replaced below, once the write is queued */ + }; + const onApplyClipEdit = vi.fn( + () => + new Promise((resolve) => { + landSave = resolve; + }), + ); + const { clipEls } = renderTimeline([clip(0, 900)], undefined, undefined, undefined, { + onApplyClipEdit, + }); + const durationOf = () => document.querySelector('[class*="tlClipDuration"]')?.textContent; + + fireEvent.pointerDown(gripFor(clipEls[0], "end"), { clientX: 0, pointerId: 1 }); + act(() => { + window.dispatchEvent(pointerEvent("pointermove", -100, 1)); + }); + await act(async () => { + window.dispatchEvent(pointerEvent("pointerup", -100, 1)); + }); + expect(onApplyClipEdit).toHaveBeenCalledTimes(1); + expect(durationOf()).toBe("13:20.0"); + + // This mock timeline never re-renders with the trimmed clip, so once the preview is + // down the card reads the committed length again: that is how its release shows. + await act(async () => { + landSave(); + }); + expect(durationOf()).toBe("15:00.0"); + }); + + // A save that lands late takes down its own preview, not whichever one is up by then. + it("leaves a newer trim's preview alone when an older save lands", async () => { + let landSave = () => { + /* replaced below, once the write is queued */ + }; + const onApplyClipEdit = vi.fn( + () => + new Promise((resolve) => { + landSave = resolve; + }), + ); + const { clipEls } = renderTimeline([clip(0, 900)], undefined, undefined, undefined, { + onApplyClipEdit, + }); + const durationOf = () => document.querySelector('[class*="tlClipDuration"]')?.textContent; + + fireEvent.pointerDown(gripFor(clipEls[0], "end"), { clientX: 0, pointerId: 1 }); + act(() => { + window.dispatchEvent(pointerEvent("pointermove", -100, 1)); + }); + await act(async () => { + window.dispatchEvent(pointerEvent("pointerup", -100, 1)); + }); + const landFirstSave = landSave; + + fireEvent.pointerDown(gripFor(clipEls[0], "start"), { clientX: 0, pointerId: 2 }); + act(() => { + window.dispatchEvent(pointerEvent("pointermove", 50, 2)); + }); + expect(durationOf()).toBe("14:10.0"); + + await act(async () => { + landFirstSave(); + }); + expect(durationOf()).toBe("14:10.0"); + }); + + // Two grips per clip, all carrying the same label: "Adjust clip start" names one + // button per clip in the row and says nothing about which. The card's own name + // element is what tells them apart. + it("tells the grips of one clip apart from another clip's", () => { + const { clipEls } = renderTimeline([clip(0, 900), clip(900, 1800)]); + const described = (el: Element, edge: "start" | "end") => + gripFor(el, edge).getAttribute("aria-describedby"); + // Each grip points at the name of the clip it belongs to, not at a shared node. + expect(described(clipEls[0], "start")).toBe(described(clipEls[0], "end")); + expect(described(clipEls[0], "start")).not.toBe(described(clipEls[1], "start")); + // And the target exists and carries the clip's name, or the reference is dead. + for (const el of clipEls) { + const target = document.getElementById(described(el, "start") as string); + expect(target?.textContent).toBe("rec"); + } + }); + + // A grip keeps DOM focus through a drag on it, so an arrow key can land mid-drag. The + // drag's pending range came from a snapshot the nudge's write invalidates, so it must + // stop being pending rather than commit over the nudge when the pointer is released. + it("lets a keyboard nudge take over from a drag instead of being overwritten by it", () => { + const { clipEls, tl } = renderTimeline(); + const grip = gripFor(clipEls[0], "end"); + fireEvent.pointerDown(grip, { clientX: 0, pointerId: 1 }); + window.dispatchEvent(pointerEvent("pointermove", -100, 1)); + + fireEvent.keyDown(grip, { key: "ArrowLeft" }); + expect(tl.applyClipEdit).toHaveBeenCalledTimes(1); + expect(tl.applyClipEdit).toHaveBeenLastCalledWith("c@0", 0, 1799.9); + + // The drag is off: its release does not put the pre-nudge range back. + window.dispatchEvent(pointerEvent("pointerup", -100, 1)); + expect(tl.applyClipEdit).toHaveBeenCalledTimes(1); + }); + + // The commit goes through the prop, which the shell has wrapped in the one queue every + // document write shares. Calling `tl.applyClipEdit` here instead would read the document + // at call time and save it back, so two writes in flight would both build on the same + // pre-trim document -- a held arrow key repeats about thirty times a second, which is + // exactly how you get two. + it("commits through the shell's write callback, not straight at the timeline api", () => { + const onApplyClipEdit = vi.fn(); + const { clipEls, tl, shellDoc } = renderTimeline(undefined, undefined, undefined, undefined, { + onApplyClipEdit, + }); + // What the callback is handed is resolved by the shell, inside its queue. + const resolvedLast = () => + onApplyClipEdit.mock.lastCall?.[1](shellDoc as unknown as AxcutDocument); + + dragHandle(gripFor(clipEls[0], "end"), -100); + expect(onApplyClipEdit).toHaveBeenCalledWith("c@0", expect.any(Function)); + expect(resolvedLast()).toEqual({ start: 0, end: 1600 }); + expect(tl.applyClipEdit).not.toHaveBeenCalled(); + + fireEvent.keyDown(gripFor(clipEls[0], "end"), { key: "ArrowLeft" }); + expect(onApplyClipEdit).toHaveBeenLastCalledWith("c@0", expect.any(Function)); + expect(resolvedLast()).toEqual({ start: 0, end: 1799.9 }); + expect(tl.applyClipEdit).not.toHaveBeenCalled(); + }); + + // A gesture that ignores foreign pointers is also a gesture that no longer ends when + // another grip is pressed. Two live drags would fight over the single preview and both + // commit on release, with only the newer one reachable through the ref the unmount + // effect cancels — so the older press is abandoned the moment the next one starts. + it("abandons a trim still in flight when another grip is pressed", () => { + const { clipEls, tl } = renderTimeline(); + fireEvent.pointerDown(gripFor(clipEls[0], "end"), { clientX: 0, pointerId: 1 }); + window.dispatchEvent(pointerEvent("pointermove", -100, 1)); + + fireEvent.pointerDown(gripFor(clipEls[0], "start"), { clientX: 0, pointerId: 2 }); + // The first press is no longer anybody's: its release writes nothing. + window.dispatchEvent(pointerEvent("pointerup", -100, 1)); + expect(tl.applyClipEdit).not.toHaveBeenCalled(); + + // The second is the live one, and it commits its own edge alone. + window.dispatchEvent(pointerEvent("pointermove", 100, 2)); + window.dispatchEvent(pointerEvent("pointerup", 100, 2)); + expect(tl.applyClipEdit).toHaveBeenCalledTimes(1); + expect(tl.applyClipEdit).toHaveBeenCalledWith("c@0", 200, 1800); + }); + + it("drops every trim in flight when the timeline unmounts, not just the newest", () => { + const { clipEls, tl, unmount } = renderTimeline(); + fireEvent.pointerDown(gripFor(clipEls[0], "end"), { clientX: 0, pointerId: 1 }); + window.dispatchEvent(pointerEvent("pointermove", -100, 1)); + fireEvent.pointerDown(gripFor(clipEls[0], "start"), { clientX: 0, pointerId: 2 }); + window.dispatchEvent(pointerEvent("pointermove", 100, 2)); + + unmount(); + window.dispatchEvent(pointerEvent("pointerup", -100, 1)); + window.dispatchEvent(pointerEvent("pointerup", 100, 2)); + expect(tl.applyClipEdit).not.toHaveBeenCalled(); + }); + + // The shell renders the timeline conditionally, so it can go away under a drag that + // is still holding its `window` listeners. Those closures survive the unmount, and + // the next release would otherwise write a trim through a hook the user has already + // navigated away from. + it("drops a trim still in flight when the timeline unmounts", () => { + const { clipEls, tl, unmount } = renderTimeline(); + fireEvent.pointerDown(gripFor(clipEls[0], "end"), { clientX: 0 }); + window.dispatchEvent(pointerEvent("pointermove", -100)); + unmount(); + window.dispatchEvent(pointerEvent("pointerup", -100)); + expect(tl.applyClipEdit).not.toHaveBeenCalled(); + }); + + // The grip sits inside the card, whose own pointerdown starts a reorder and + // whose click selects. Only one gesture can own the press. + it("does not let a trim double as a selection", () => { + const { clipEls, tl } = renderTimeline([clip(0, 900), clip(900, 1800)]); + const grip = gripFor(clipEls[0], "end"); + dragHandle(grip, -50); + // dragHandle stops at pointerup, but a real pointer sequence ends in a click + // that bubbles to the card, whose handler selects. Dispatching it is the only + // way this asserts anything: without it the test passes even with the grip's + // stopPropagation deleted. + fireEvent.click(grip); + expect(tl.selectClip).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/ai-edition/v4/V4Timeline.tsx b/src/components/ai-edition/v4/V4Timeline.tsx index 28aad552c..18a391faa 100644 --- a/src/components/ai-edition/v4/V4Timeline.tsx +++ b/src/components/ai-edition/v4/V4Timeline.tsx @@ -45,7 +45,7 @@ import { import { createId } from "@/lib/ai-edition/document/ids"; import { isGeneratedAssetId } from "@/lib/ai-edition/document/insertion"; import { setUiProbeScrubbing } from "@/lib/ai-edition/perf/uiFrameProbe"; -import type { AxcutAudioTrack, AxcutClip } from "@/lib/ai-edition/schema"; +import type { AxcutAudioTrack, AxcutClip, AxcutDocument } from "@/lib/ai-edition/schema"; import { audioGainScalar } from "@/lib/ai-edition/store/editorSettings"; import { useProjectStore } from "@/lib/ai-edition/store/projectStore"; import { useTimelineTranscriptGate } from "@/lib/ai-edition/store/transcriptionStore"; @@ -183,6 +183,87 @@ function cardFitsDuration(cardPx: number, text: string): boolean { } const CLIP_GUTTER_PX = 6; +/** How far a press on a clip has to travel before it counts as a drag, in screen px. + * Shared by the reorder and the edge trim: below it a press is click jitter, and at a + * low zoom one pixel of that is seconds of timeline, so a trim without it turned an + * unsteady click into a real edit and an undo step. */ +const CLIP_DRAG_START_PX = 4; +/** The shortest a clip may be left by a trim — the same floor the Edit modal's + * handles stop at, so the two ways into this edit agree on what "too short" is. */ +const MIN_CLIP_SEC = 0.05; + +/** A clip's out-point in its own media. `sourceEndSec` is optional in the schema — a + * clip whose asset has not been probed carries none — and the honest stand-in is the + * length the clip already occupies on the timeline, which is what the waveform painter + * has always used. Read through here rather than defaulted per call site: `?? 0` puts + * the out-point BEFORE the in-point, and `setClipSourceRange` orders its endpoints, so + * a trim against that fallback commits a collapsed clip rather than failing. */ +function clipOutPointSec(clip: AxcutClip): number { + return ( + clip.sourceEndSec ?? + clip.sourceStartSec + Math.max(0, clip.timelineEndSec - clip.timelineStartSec) + ); +} + +/** What the clip row previews of a live edge trim (see `edgeTrim` in the component). */ +type EdgeTrimPreview = { id: string; edge: "start" | "end"; deltaSec: number }; + +/** A clip's source range, as an edge trim hands it to `applyClipEdit`. */ +type ClipSourceRange = { start: number; end: number }; + +/** Where one edge of `clip` lands when moved by `shiftSec` of source time, held inside + * the media and above the minimum length. The upper bound is the one the Edit modal + * computes, and for the same reason: it has to hold the current selection whatever the + * metadata says, so it falls back to the out-point. An asset whose duration has not + * been probed can therefore be trimmed in but not pulled back out, which is the safe + * way round, since the alternative invents footage past the end of the file. */ +function clampedEdgeRange( + clip: AxcutClip, + assetDurationSec: number | undefined, + edge: "start" | "end", + shiftSec: number, +): ClipSourceRange { + const fromStart = clip.sourceStartSec; + const fromEnd = clipOutPointSec(clip); + const sourceDurationSec = Math.max(assetDurationSec ?? 0, fromEnd, 0.001); + return edge === "start" + ? { + start: Math.min(Math.max(fromStart + shiftSec, 0), fromEnd - MIN_CLIP_SEC), + end: fromEnd, + } + : { + start: fromStart, + end: Math.max(Math.min(fromEnd + shiftSec, sourceDurationSec), fromStart + MIN_CLIP_SEC), + }; +} + +/** An edge move, resolved against whatever document the write queue holds when it gets + * to it, not against the render the gesture happened in. The queue serialises writes, + * but a range computed at call time is fixed at call time: a held arrow key enqueues + * thirty of them from the same stale clip, all naming the same range, which lost every + * step but one and still pushed an undo step per save. Resolving inside the task makes + * each step build on the one before it. + * + * Null when there is nothing to write: the clip is gone, or the move lands on the range + * it already has (against a stop). That is checked here too, and for the same reason: + * the render-time clip cannot say whether the queued steps ahead of this one have + * already reached the stop. */ +function resolveEdgeShift( + clipId: string, + edge: "start" | "end", + shiftSec: number, +): (doc: AxcutDocument) => ClipSourceRange | null { + return (doc) => { + const clip = doc.timeline.clips.find((c) => c.id === clipId); + if (!clip) return null; + const assetDurationSec = doc.assets.find((a) => a.id === clip.assetId)?.durationSec; + const next = clampedEdgeRange(clip, assetDurationSec, edge, shiftSec); + const moved = + Math.abs(next.start - clip.sourceStartSec) > 0.001 || + Math.abs(next.end - clipOutPointSec(clip)) > 0.001; + return moved ? next : null; + }; +} /** * Shortest region a resize may leave behind — the storage grid itself (regions * are `Math.round`ed to whole ms, and coalesceRegionsForRuler's epsilon is 1 ms), @@ -571,6 +652,7 @@ export function V4Timeline({ onPrevClip, onNextClip, onEditClip, + onApplyClipEdit, onAddVoiceover, }: { tl: TimelineApi; @@ -588,6 +670,24 @@ export function V4Timeline({ /** Opens the voiceover recorder. Shell-level like the clip editor: the * dialog owns the microphone and the shell owns the transport. */ onAddVoiceover: () => void; + /** Commits an edge trim. The shell owns the one queue every document write shares + * (`useSequentialTimelineOps`), and this has to go through it: `tl.applyClipEdit` + * reads the document and saves it back, so two calls in flight would both build on + * the same pre-trim document and the second would clobber the first. + * + * The queue orders the writes, but it cannot fix a value computed before the task + * ran, so the range is NOT passed in. `resolveRange` is called inside the queued task + * with the document as the previous write left it, and answers the range to save, or + * null to save nothing. A keyboard nudge fires per keydown and a held arrow repeats + * about thirty times a second, all from the same render: resolved at call time they + * all named the same range. + * + * Settles once the write has, so a drag can keep its preview on screen until the + * store holds the trimmed clip. */ + onApplyClipEdit: ( + clipId: string, + resolveRange: (doc: AxcutDocument) => { start: number; end: number } | null, + ) => Promise; }) { const t = useScopedT("timeline"); // The live bindings, not the defaults: these keys are remappable, and a menu @@ -596,6 +696,9 @@ export function V4Timeline({ // The camera lane borrows the Layout pane's "No Webcam" wording when there is no // camera to grow, so the two surfaces say the same thing about the same project. const ts = useScopedT("settings"); + // The edge handles reuse the Edit modal's own labels: they adjust the same two + // numbers, and saying it differently here would be two names for one edit. + const te = useScopedT("editor"); // Wheel zoom/pan listens on the whole pane (toolbar down through the nav bar), // not just the lanes — a user scrolling over the ruler or the hint labels // expects the same zoom/pan the lanes give, not silence. @@ -631,6 +734,24 @@ export function V4Timeline({ pointerDeltaX: number; shiftPx: number; } | null>(null); + /** A live edge trim. `deltaSec` is the change to the clip's DURATION, which is + * what the rest of the row has to absorb: clips are laid back-to-back, so a + * clip that loses half a second pulls everything after it half a second left. + * Held apart from the committed document so the drag can be abandoned. */ + const [edgeTrim, setEdgeTrim] = useState(null); + /** The clip whose trim grip holds keyboard focus. A card too narrow for grips does not + * render them, and the width that decides it changes under the grip itself: a nudge + * shortens the clip, a Ctrl+wheel zooms the row out. Unmounting the focused button + * drops focus to the body, so a keyboard user nudging a clip down past the threshold + * lost their place mid-edit. The grips stay while focus is in them. */ + const [gripFocusClipId, setGripFocusClipId] = useState(null); + /** Calls off an edge trim still in flight. A drag holds its listeners on `window`, so + * it outlives this component — which the shell unmounts on its own schedule (it + * renders the timeline conditionally). Without this the closures survive the + * unmount and the next stray release commits a trim through a hook the user has + * navigated away from. Null whenever no trim is being dragged. */ + const abortEdgeTrimRef = useRef<(() => void) | null>(null); + useEffect(() => () => abortEdgeTrimRef.current?.(), []); const { settings, set: setSettings } = useEditorSettings(); const [autoEnhanceOpen, setAutoEnhanceOpen] = useState(false); @@ -691,6 +812,14 @@ export function V4Timeline({ useEffect(() => { setTimelineScale(pxPerSec); }, [pxPerSec]); + // The same scale for a gesture already in flight. An edge trim reads it on every move + // rather than closing over the value at pointerdown: Ctrl+wheel zooms the row mid-drag, + // and against a frozen scale the edge came off the cursor while the clips it ripples + // were already being drawn at the new one. + const pxPerSecRef = useRef(pxPerSec); + useEffect(() => { + pxPerSecRef.current = pxPerSec; + }, [pxPerSec]); // ── region lanes ──────────────────────────────────────────────── // zoom/speed/annotation: one pill per row, never coalesced — each carries @@ -1404,6 +1533,156 @@ export function V4Timeline({ // same document/timeline.ts#moveClip the agent's "moveClip" tool uses. // (That tool takes a neighbour's id rather than this index — the index is // relative to the array with the moved clip already removed, see below.) + /** Trim a clip by dragging one of its own edges, instead of opening the Edit + * modal to move the same two numbers. + * + * The document work is already solved and shared: `applyClipEdit` → + * `setClipSourceRange` clamps the range, relays every clip back-to-back, and + * reclamps the pills anchored inside the window the trim just removed. This + * only has to turn pointer travel into a source range and hand it over — which + * is also why an edge trim undoes in one step like any other edit. + * + * Dragging the START edge does NOT move the clip's left edge on screen. Clips + * are laid back-to-back from zero, so trimming a clip's head leaves it starting + * exactly where it started and takes the length off its tail — a ripple trim. + * The live preview below models that rather than the more literal reading, + * because the literal one would show a gap the commit will not produce. */ + const startEdgeTrim = useCallback( + (e: ReactPointerEvent, clip: AxcutClip, edge: "start" | "end") => { + if (e.button !== 0) return; + // Before the panel is measured there is no px→sec rate to drag against. + if (!Number.isFinite(pxPerSecRef.current) || pxPerSecRef.current <= 0) return; + // This handle sits inside the clip card, whose own pointerdown starts a + // reorder. Only one of the two gestures can own this press. + e.preventDefault(); + e.stopPropagation(); + // One trim at a time. Now that a gesture ignores pointers other than its own, + // a second grip pressed before the first is released would otherwise run a + // second, independent drag: two of them fighting over the single `edgeTrim` + // preview, both committing on release, and only the newer one reachable + // through the ref the unmount effect cancels. The older press is abandoned, + // not committed — the user moved on to another edge. + abortEdgeTrimRef.current?.(); + + const fromStart = clip.sourceStartSec; + const fromEnd = clipOutPointSec(clip); + const assetDurationSec = tl.assets.find((a) => a.id === clip.assetId)?.durationSec; + + const startX = e.clientX; + // How far the dragged edge has moved in source time, clamped against the clip as + // it was pressed. What is committed is this MOVE, not the range it produced here: + // the preview draws the committed length plus the change, so if the document + // moves under the drag (an undo with the pointer still down, or a queued write + // landing), the move applied to that newer clip is exactly what is on screen at + // release. The absolute range would put back whatever the drag started from. + let shiftSec = 0; + // Nothing moves until the press has travelled far enough to be a drag, the same + // dead zone a reorder has; past it the move is measured from the press, not from + // the edge of the dead zone, so the grip does not lag the pointer by 4px. + let dragging = false; + // The preview this gesture last put on screen. Held so the release can take down + // its own preview and nothing newer: the save it waits on is async, and another + // press may have started a preview of its own by the time it resolves. + let preview: EdgeTrimPreview = { id: clip.id, edge, deltaSec: 0 }; + setEdgeTrim(preview); + + // The listeners sit on `window`, which hears every pointer on the device, not + // just the one that started this. On a touchscreen a second finger's release + // would otherwise commit the first finger's trim halfway through it — and, when + // the terminal listeners were `{ once: true }`, unregister them on its way out, + // so the finger still dragging ended up attached to nothing. + const pointerId = e.pointerId; + const ours = (ev: PointerEvent) => ev.pointerId === pointerId; + + const move = (moveEvent: PointerEvent) => { + if (!ours(moveEvent)) return; + if (!dragging && Math.abs(moveEvent.clientX - startX) < CLIP_DRAG_START_PX) return; + dragging = true; + const scale = pxPerSecRef.current; + if (!Number.isFinite(scale) || scale <= 0) return; + const deltaSec = (moveEvent.clientX - startX) / scale; + const next = clampedEdgeRange(clip, assetDurationSec, edge, deltaSec); + shiftSec = edge === "start" ? next.start - fromStart : next.end - fromEnd; + preview = { id: clip.id, edge, deltaSec: next.end - next.start - (fromEnd - fromStart) }; + setEdgeTrim(preview); + }; + + const detach = () => { + window.removeEventListener("pointermove", move); + window.removeEventListener("pointerup", end); + window.removeEventListener("pointercancel", cancel); + abortEdgeTrimRef.current = null; + }; + + const end = async (endEvent: PointerEvent) => { + if (!ours(endEvent)) return; + detach(); + try { + // A press that never left the dead zone is not an edit, and writing one would + // put an empty step on the undo stack. (The resolver also refuses a move that + // lands on the range the clip already has; this just skips the queue.) + // + // Awaited with the preview still up, as the reorder does: dropped first, the + // card snapped back to its old length for as long as the save took, then + // jumped to the new one when the store caught up. + if (Math.abs(shiftSec) > 0.001) { + await onApplyClipEdit(clip.id, resolveEdgeShift(clip.id, edge, shiftSec)); + } + } finally { + setEdgeTrim((current) => (current === preview ? null : current)); + } + }; + + // The browser takes the pointer away on a palm rejection, a system gesture, or + // a lost capture, and then sends no `pointerup` at all. Without this the drag + // stays live: the preview is frozen on screen, `pointermove` keeps tracking the + // cursor, and the next unrelated release commits a trim nobody asked for. + // Cancelled means abandoned, so it drops the pending range rather than writing it. + // Called both by the browser (with the event) and by the unmount effect (without + // one), which is abandoning the gesture outright and does not get to be picky + // about whose pointer it was. + const cancel = (cancelEvent?: PointerEvent) => { + if (cancelEvent && !ours(cancelEvent)) return; + detach(); + setEdgeTrim(null); + }; + + // The gesture outlives this component if the shell stops rendering the timeline + // mid-drag, so the unmount effect needs a way to call the whole thing off. + abortEdgeTrimRef.current = cancel; + + // Not `{ once: true }`: a listener that filters has to survive the events it + // filters out, and `detach` removes all three the moment this gesture is over. + window.addEventListener("pointermove", move); + window.addEventListener("pointerup", end); + window.addEventListener("pointercancel", cancel); + }, + [tl, onApplyClipEdit], + ); + + /** The keyboard half of the same edit. These grips are focusable buttons, and a + * button that only answers a pointer is worse than no button — it takes a tab + * stop and then does nothing with it. Shift for a coarse second, otherwise a + * tenth, which is the precision the duration readouts are printed at. */ + const nudgeEdge = useCallback( + (clipId: string, edge: "start" | "end", stepSec: number) => { + // A grip keeps DOM focus through a drag on it (the pointerdown preventDefault + // leaves focus where it was), so an arrow key can land mid-drag. Two edits of + // the same edge from one hand at once have no sensible merge, so the key wins + // and the drag stops being pending rather than committing on release. + abortEdgeTrimRef.current?.(); + // A step, not a range: the render this key landed in may be several queued + // steps behind, so the range is worked out in the queue. Against the stop the + // resolver answers null and nothing is saved, so holding the key down at the + // end of the source does not pile identical steps onto the undo stack. + void onApplyClipEdit(clipId, resolveEdgeShift(clipId, edge, stepSec)); + }, + [onApplyClipEdit], + ); + + /** Where the trimmed clip sits, so the clips after it know to slide with it. */ + const edgeTrimIndex = edgeTrim ? clips.findIndex((c) => c.id === edgeTrim.id) : -1; + const startClipDrag = useCallback( (e: ReactPointerEvent, clip: AxcutClip) => { if (e.button !== 0) return; @@ -1462,7 +1741,7 @@ export function V4Timeline({ }; const move = (ev: PointerEvent) => { - if (!dragging && Math.abs(ev.clientX - startX) < 4) return; + if (!dragging && Math.abs(ev.clientX - startX) < CLIP_DRAG_START_PX) return; dragging = true; didClipDragRef.current = true; const target = computeTarget(ev.clientX); @@ -2172,13 +2451,18 @@ export function V4Timeline({ }} > {clips.map((c, i) => { - const dur = c.timelineEndSec - c.timelineStartSec; // On the expanded ruler the box also carries whatever pauses fall // inside it — the film really does stay on this clip's frame for // them, so they belong to its box rather than between boxes. const boxStart = c.timelineStartSec; const boxEnd = c.timelineEndSec; - const boxLen = boxEnd - boxStart; + // A live edge trim previews as a ripple: the clip being trimmed + // absorbs the whole change in its own length, and everything after + // it slides by that much. Its own left edge never moves, because + // the commit relays the row back-to-back from zero and will put it + // back exactly where it is now. + const trimming = edgeTrim?.id === c.id; + const boxLen = boxEnd - boxStart + (trimming ? edgeTrim.deltaSec : 0); const asset = tl.assets.find((a) => a.id === c.assetId); const clipVideoUrl = videoSources.find((v) => v.id === c.assetId)?.src; const selected = tl.clipSelection === c.id; @@ -2189,7 +2473,10 @@ export function V4Timeline({ // follows the pointer directly (see .tlClipDragging's // transition:none override). let clipTransform: string | undefined; - if (dragging) { + const rippling = Boolean(edgeTrim) && edgeTrimIndex >= 0 && i > edgeTrimIndex; + if (rippling && edgeTrim) { + clipTransform = `translateX(${edgeTrim.deltaSec * pxPerSec}px)`; + } else if (dragging) { clipTransform = `translateX(${clipDrag.pointerDeltaX}px)`; } else if (clipDrag) { const { from, target, shiftPx } = clipDrag; @@ -2203,9 +2490,16 @@ export function V4Timeline({ // there is no arrangement that fits a button inside that — so while // it is selected the controls step outside the box instead. const narrow = boxLen * pxPerSec < NARROW_CLIP_PX; + const gripFocused = gripFocusClipId === c.id; // The gutter is taken out of the card's own width below, so the // room the label actually has is that much less than the span. - const durText = formatSec(dur); + // From `boxLen`, not the committed length: during a drag the card is + // already showing the trimmed size, and a readout still printing the + // old one contradicts the box it sits in. It is also the precise half + // of the preview — the keyboard step is a tenth BECAUSE this is + // printed to a tenth — so it is the number the user is aiming with. + // Identical to the committed length whenever no trim is in flight. + const durText = formatSec(boxLen); return (
startClipDrag(e, c)} + // Focus-within, but only once it started on a grip: moving on to + // this card's delete button keeps the grips (it is the same card), + // and leaving the card lets them go. + onFocus={(e) => { + if (e.target instanceof HTMLElement && e.target.dataset.edge) { + setGripFocusClipId(c.id); + } + }} + onBlur={(e) => { + if (e.currentTarget.contains(e.relatedTarget as Node | null)) return; + setGripFocusClipId((current) => (current === c.id ? null : current)); + }} onClick={(e) => { e.stopPropagation(); // A completed reorder-drag also fires a click; don't let it @@ -2247,9 +2553,81 @@ export function V4Timeline({ videoUrl={clipVideoUrl} assetDurationSec={asset?.durationSec} sourceStartSec={c.sourceStartSec} - sourceEndSec={c.sourceEndSec ?? c.sourceStartSec + dur} + sourceEndSec={clipOutPointSec(c)} gain={audioGainScalar(settings.audioGainDb)} /> + {/* Only on a card wide enough to hold them. Below that the two grips + would cover the whole clip and leave no body to grab for a reorder — + the pencil (and the Edit modal behind it) stays the way in at that + size, the same bargain the other in-clip controls strike. Except + while one is in use: a card that narrows under a drag or a nudge + keeps the grip the user is holding. */} + {narrow && !trimming && !gripFocused ? null : ( + <> +