From 67dbcf7bdbe68eb4ad7a710676272552813955a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Fri, 2 Oct 2026 08:04:29 -0700 Subject: [PATCH] feat(studio): preview timeline track insertion (#4907) * feat(studio): preview new tracks at drop boundaries * fix(studio): preserve group ownership across overlapping moves * refactor(studio): share timeline drop decisions and fixtures * fix(studio): open an empty audio zone below visual tracks * fix(studio): cancel thumbnails when project pages leave * refactor(studio): share thumbnail regression setup * test(studio): gate thumbnail socket release in browser CI * chore(studio): keep optimistic revision comments within ratchet * fix(studio): pin regression proxy requests to its backend * test(studio): bound the proxy origin probe * test(studio): probe proxy origin through the ready document --- .github/workflows/ci.yml | 6 +- .../2026-10-02-ghost-lane-membership.html | 67 +++++ ...026-10-02-thumbnail-document-lifetime.html | 59 +++++ packages/studio/package.json | 1 + .../src/hooks/timelineEditingHelpers.ts | 7 + .../src/hooks/timelineMoveAdapter.test.ts | 12 + .../studio/src/hooks/timelineMoveAdapter.ts | 13 +- .../hooks/useTimelineGroupEditing.test.tsx | 77 ++++++ .../src/hooks/useTimelineGroupEditing.ts | 6 +- packages/studio/src/main.tsx | 4 + .../components/Timeline.ghostLane.test.tsx | 69 +---- .../components/Timeline.trackPadding.test.tsx | 67 +---- .../src/player/components/TimelineCanvas.tsx | 8 +- .../src/player/components/TimelineClip.tsx | 6 + .../TimelineGestureOverlay.test.tsx | 41 +++ .../components/TimelineGestureOverlay.tsx | 67 ++++- .../src/player/components/TimelineLanes.tsx | 34 +-- .../player/components/timelineCallbacks.ts | 10 +- .../components/timelineClipDragCommit.test.ts | 51 ++++ .../components/timelineClipDragCommit.ts | 84 ++++-- .../timelineClipDragGestureLifecycle.ts | 20 +- .../timelineClipDragPreview.test.ts | 78 +++--- .../components/timelineClipDragPreview.ts | 98 +++++-- .../components/timelineClipDragTypes.ts | 6 +- .../player/components/timelineCollision.ts | 62 ++++- .../player/components/timelineDragLanding.ts | 26 +- .../components/timelineMountFixtures.tsx | 65 +++++ .../components/timelineOptimisticRevision.ts | 19 +- .../timelineTrackInsertLayout.test.ts | 249 ++++++++++++++++++ .../components/timelineTrackInsertLayout.ts | 71 +++++ .../useTimelineClipDrag.keyboard.test.tsx | 163 ++++++++++++ .../player/components/useTimelineClipDrag.ts | 128 ++++++++- .../useTimelineDisplayLayout.test.tsx | 59 +++++ .../components/useTimelineKeyboardActor.ts | 93 ++++++- .../components/useTimelineProviderState.tsx | 4 +- .../useTimelineRowVirtualization.test.tsx | 56 ++++ .../useTimelineRowVirtualization.ts | 4 +- .../components/useTimelineTrackLayout.ts | 57 +++- .../player/hooks/timelinePlayerSync.test.ts | 7 + .../src/player/hooks/timelinePlayerSync.ts | 1 + .../player/lib/thumbnailPageLifecycle.test.ts | 123 +++++++++ .../src/player/lib/thumbnailPageLifecycle.ts | 27 ++ .../src/player/lib/thumbnailScheduler.ts | 22 +- .../tests/e2e/fixtures/ghost-lane/groups.html | 44 ++++ .../e2e/fixtures/ghost-lane/hyperframes.json | 1 + .../tests/e2e/fixtures/ghost-lane/index.html | 151 +++++++++++ packages/studio/tests/e2e/thumbnail-save.mjs | 163 ++++++++++++ packages/studio/vite.config.ts | 5 + packages/studio/vite.request-signal.test.ts | 66 +++++ packages/studio/vite.request-signal.ts | 11 + 50 files changed, 2278 insertions(+), 290 deletions(-) create mode 100644 docs/contracts/2026-10-02-ghost-lane-membership.html create mode 100644 docs/contracts/2026-10-02-thumbnail-document-lifetime.html create mode 100644 packages/studio/src/player/components/timelineMountFixtures.tsx create mode 100644 packages/studio/src/player/components/timelineTrackInsertLayout.test.ts create mode 100644 packages/studio/src/player/components/timelineTrackInsertLayout.ts create mode 100644 packages/studio/src/player/components/useTimelineClipDrag.keyboard.test.tsx create mode 100644 packages/studio/src/player/components/useTimelineDisplayLayout.test.tsx create mode 100644 packages/studio/src/player/components/useTimelineRowVirtualization.test.tsx create mode 100644 packages/studio/src/player/lib/thumbnailPageLifecycle.test.ts create mode 100644 packages/studio/src/player/lib/thumbnailPageLifecycle.ts create mode 100644 packages/studio/tests/e2e/fixtures/ghost-lane/groups.html create mode 100644 packages/studio/tests/e2e/fixtures/ghost-lane/hyperframes.json create mode 100644 packages/studio/tests/e2e/fixtures/ghost-lane/index.html create mode 100644 packages/studio/tests/e2e/thumbnail-save.mjs create mode 100644 packages/studio/vite.request-signal.test.ts create mode 100644 packages/studio/vite.request-signal.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b8eb54d469..ef1f49dd1f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -958,7 +958,7 @@ jobs: node packages/studio/tests/e2e/perf-ratchet.mjs check \ packages/studio/tests/e2e/perf-ceilings.json studio-open /tmp/studio-open-counts.json \ /tmp/base-perf-ceilings.json - - name: Measure the fade handle tabs and click every Renders select + - name: Check fade handles, render selects and thumbnail connection release if: success() || failure() run: | set -euo pipefail @@ -973,12 +973,14 @@ jobs: echo "FAIL: studio dev server did not start on port 5316" exit 1 fi - # Both checks run even when the first fails. + # All checks run even when an earlier one fails. rc=0 STUDIO_URL="http://localhost:5316/#project/fade-handles" \ node packages/studio/tests/e2e/fade-handles.mjs || rc=1 STUDIO_URL="http://localhost:5316/#project/fade-handles?tab=renders" \ node packages/studio/tests/e2e/render-selects.mjs || rc=1 + STUDIO_URL="http://localhost:5316/" \ + node packages/studio/tests/e2e/thumbnail-save.mjs || rc=1 exit "$rc" - name: Measure the caret of a text edited in place if: success() || failure() diff --git a/docs/contracts/2026-10-02-ghost-lane-membership.html b/docs/contracts/2026-10-02-ghost-lane-membership.html new file mode 100644 index 0000000000..dd10721f21 --- /dev/null +++ b/docs/contracts/2026-10-02-ghost-lane-membership.html @@ -0,0 +1,67 @@ + +New-track membership contract

New-track drops leave an audio group

A clip lands exactly where the ghost showed. One undo restores its track and membership.

audited 5trusting 0not exercised 1open decisions 0hyperframes @ de13693179f53c3f625b7b501f1f5fc7b083b6c9, working changes, 2026-10-02
+
In one breath. At the audited base, the move transaction saved track and timing but dropped membership before persistence. Detachment must travel through that transaction, rather than become a second save.
+

Where the value travels

Drop edits → Move adapter → Source patch → File + history → Undo restore
+

The contract, per boundary

+

Move payload

At the audited base, membership was absent from the move payload. The adapter explicitly selects only these fields.

edits.map(({ element, updates }) => ({
+  element,
+  start: updates.start,
+  track: operation === "timing" ? undefined : updates.track,
+}))
producer src/player/components/timelineClipDragCommit.ts:153; consumer src/hooks/timelineMoveAdapter.ts:37. Paths relative to packages/studio.
membership: absentreachable: nowritten: no
+

Atomic source edit

The group move turns all clip changes into source patches inside one queued file transaction. Track changes bypass the timing-only SDK path.

buildTimelineMoveTimingPatch(
+  original,
+  target,
+  toAuthoredStart(change.element, change.start),
+  change.element.duration,
+  change.track,
+)
producer src/hooks/useTimelineGroupEditing.ts:311; consumer src/hooks/timelineEditingHelpers.ts:235. SDK eligibility src/hooks/useTimelineGroupEditing.ts:299.
track: existsreachablewritten
+

Undo owns whole-file snapshots

One transaction records the complete before and after source. Including membership in the same patch gives undo its original value.

snapshots[path] = { before, after };
+await recordEdit({ label, coalesceKey, coalesceMs, files: snapshots });
producer src/utils/studioFileHistory.ts:128,144; undo consumer src/hooks/usePersistentEditHistory.ts:81,99; preview sync consumer src/hooks/useEditHistoryActions.ts:112.
snapshots: existreachablewritten and restored
+

How it says no

SignalMeaning and response
Locked shifted clipInsertion returns without persistence. Existing permission rules remain the owner.
Save rejectionThe file transaction restores already-written snapshots and rethrows. The optimistic clip rollback must restore membership alongside timing and track.
Undo content mismatchHistory refuses to overwrite a file changed since the edit and reports the conflict.
+

Decision already supplied

New tracks outside a group detach the dragged clip. Drops onto member tracks retain existing behavior. Cmd+Z restores membership. There is no open product decision.
+

Coverage, honestly

LabelCoverage
AuditedDrop edits, adapter, group move, source patch, snapshot and undo consumers opened at the named base.
Not exercisedThis page records the pre-implementation audit. Runtime proof is reported separately against the implementation head.
+

What would prove this page wrong

Drop a grouped clip at a group edge, reload the file, then Cmd+Z. The clip must lose membership after the drop, occupy the promised lane, and regain its original track and membership in one undo. A member-track drop must retain membership.

diff --git a/docs/contracts/2026-10-02-thumbnail-document-lifetime.html b/docs/contracts/2026-10-02-thumbnail-document-lifetime.html new file mode 100644 index 0000000000..1407a24e4c --- /dev/null +++ b/docs/contracts/2026-10-02-thumbnail-document-lifetime.html @@ -0,0 +1,59 @@ + +Thumbnail document lifetime

Thumbnail connections leave with their document

Saves must not queue behind media from a document the person already left.

2026-10-02 · source 0337620 · audited 4 · trusting 0 · not exercised 1 · decisions 0
+

In one breath

The scheduler bounds work in one document, but old documents retain thumbnail connections after navigation; the lifecycle actor must abort that work on pagehide and resume retained leases on pageshow.
+

Where the value travels

pagehide / pageshow → One lifecycle actor → Thumbnail scheduler → AbortSignal → fetch / media decoder
+

The verified boundaries

Scheduler owns work

entry.controller = new AbortController();
+entry.request.load(controller.signal)
Producer packages/studio/src/player/lib/thumbnailScheduler.ts:291-301; consumer packages/studio/src/player/components/CompositionThumbnail.tsx:104-112.

The controller exists, is passed to the actual fetch, and is already aborted when the final lease leaves. Document navigation currently has no scheduler actor.

+

Abort and retain

entry.preempted = true;
+entry.controller?.abort();
Producer thumbnailScheduler.ts:228-231; consumer catch/requeue/pump at :301-320 and :344-351. Paths relative to packages/studio/src/player/lib.

Existing preview reload preempts composition jobs and retains leases. Pagehide and document visibility suspension must be a separate cause: pageshow cannot release a preview-reload hold. While suspended, no queued thumbnail kind may start.

+

The receiving HTTP route

await thumbnailGenerationCoordinator.acquire(
+  cachePath, c.req.raw.signal, async (signal) => adapter.generateThumbnail({ signal, ... })
+)
packages/studio-server/src/routes/thumbnail.ts:227-244; client fetch signal at CompositionThumbnail.tsx:108-110.

Client abort releases its browser connection. Server cancellation propagation is a separate host concern; the dev HTTP bridge must forward response disconnects into that signal so queued renders lose their leases. The endpoint is a one-shot image response, not a stream or long poll. Its serial queue had no server deadline; the existing client deadline is 30 seconds, but old-document timers may stop after navigation. Lifecycle abort plus propagated disconnect removes abandoned work instead of waiting for that timer.

+

How it says no

SignalMeaning
AbortErrorRequested cancellation; retained leases return to the queue. No thumbnail error state.
Document hiddenAbort active work and hold all queued thumbnail kinds.
Preview reload remains activePageshow permits other thumbnail kinds, but composition work retains its existing hold.
+

Decision already supplied

Saves never queue behind media. Fix the held connections, preserve a tight persistence bound. No new product decision.
+

Coverage, honestly

Audited: scheduler, real composition fetch, preview-reload caller, server consumer. Runtime: Chrome NetLog shows all six sockets occupied by thumbnails, four from previous documents and two current, at a PUT stalled by the per-host socket limit. A real HTTP regression holds render responses open across hidden project documents and verifies a durable drop within 15 seconds. Removing the lifecycle binding fails that exact persistence assertion. Unit checks cover all thumbnail kinds, resumption, independent preview holds and actual HTTP disconnects.

+

What would prove this page wrong

Hold real thumbnail render responses open, leave two project documents, then drop a clip. Old-document render requests must cancel before the next document saves; the drop must persist within 15 seconds while current thumbnails remain unfinished. Cache-only poster reads return an image or 404 without entering the render queue. Retained leases must resume and preview-reload holds must survive pageshow.

diff --git a/packages/studio/package.json b/packages/studio/package.json index 99d5f3f270..aeecbb76b3 100644 --- a/packages/studio/package.json +++ b/packages/studio/package.json @@ -78,6 +78,7 @@ "test:edit-accuracy": "bun tests/e2e/edit-accuracy/run.mjs", "test:filmstrip-controls": "node tests/e2e/filmstrip-controls.mjs", "test:webmcp-edit-loop": "node tests/e2e/webmcp-edit-loop.mjs", + "test:thumbnail-save": "node tests/e2e/thumbnail-save.mjs", "test:timeline-virtualization": "TIMELINE_ROW_VIRTUALIZATION=on TIMELINE_ELEMENT_COUNT=50000 node tests/e2e/timeline-virtualization.mjs", "test:watch": "vitest", "report:sdk-cutover": "bun src/utils/sdkCutoverPolicy.report.ts", diff --git a/packages/studio/src/hooks/timelineEditingHelpers.ts b/packages/studio/src/hooks/timelineEditingHelpers.ts index b563f0f663..f88ff38f0c 100644 --- a/packages/studio/src/hooks/timelineEditingHelpers.ts +++ b/packages/studio/src/hooks/timelineEditingHelpers.ts @@ -238,6 +238,7 @@ export function buildTimelineMoveTimingPatch( start: number, duration: number, track?: number, + audioGroup?: null, ): string { if (!Number.isFinite(start) || !Number.isFinite(duration)) { console.warn( @@ -257,6 +258,12 @@ export function buildTimelineMoveTimingPatch( value: formatTimelineAttributeNumber(track), }); } + if (audioGroup === null) + patched = applyPatchByTarget(patched, target, { + type: "attribute", + property: "data-audio-group", + value: null, + }); // Content-driven duration: sync data-duration to the furthest clip end read // from the PATCHED SOURCE (raw data-duration), so it grows if a clip moved // past the end and shrinks if the furthest clip moved left. Measured from the diff --git a/packages/studio/src/hooks/timelineMoveAdapter.test.ts b/packages/studio/src/hooks/timelineMoveAdapter.test.ts index a64dab810e..74ea16ee94 100644 --- a/packages/studio/src/hooks/timelineMoveAdapter.test.ts +++ b/packages/studio/src/hooks/timelineMoveAdapter.test.ts @@ -68,6 +68,18 @@ describe("persistTimelineMoveEditsAtomically", () => { }); }); + it("carries explicit detachment through the atomic move", async () => { + const edit = { + element: { ...element("voice", 1), audioGroup: "group" }, + updates: { start: 1, track: 0, audioGroup: null }, + }; + const handle = await runMove([edit], "move", "track-insert"); + expect(handle).toHaveBeenCalledWith( + [{ element: edit.element, start: 1, track: 0, audioGroup: null }], + { coalesceKey: "move" }, + ); + }); + it("rejects without retrying individual members when the atomic batch fails", async () => { const failure = new Error("batch failed"); const handleTimelineGroupMove = vi.fn().mockRejectedValue(failure); diff --git a/packages/studio/src/hooks/timelineMoveAdapter.ts b/packages/studio/src/hooks/timelineMoveAdapter.ts index 09665c039e..0738b73322 100644 --- a/packages/studio/src/hooks/timelineMoveAdapter.ts +++ b/packages/studio/src/hooks/timelineMoveAdapter.ts @@ -5,9 +5,13 @@ import type { TimelineGroupMoveChange, } from "./useTimelineGroupEditing"; -export interface TimelineMoveEdit { +export type TimelineAtomicMoveUpdates = Pick & { + audioGroup?: null; +}; + +export interface TimelineAtomicMoveEdit { element: TimelineElement; - updates: Pick; + updates: TimelineAtomicMoveUpdates; } interface AtomicMoveDeps { @@ -20,14 +24,14 @@ interface AtomicMoveDeps { export type TimelineMoveOperation = "timing" | "lane-reorder" | "track-insert"; export type TimelineMoveEditsHandler = ( - edits: TimelineMoveEdit[], + edits: TimelineAtomicMoveEdit[], coalesceKey?: string, operation?: TimelineMoveOperation, coalesceMs?: number, ) => Promise; export function persistTimelineMoveEditsAtomically( - edits: TimelineMoveEdit[], + edits: TimelineAtomicMoveEdit[], coalesceKey: string | undefined, operation: TimelineMoveOperation, deps: AtomicMoveDeps, @@ -37,6 +41,7 @@ export function persistTimelineMoveEditsAtomically( edits.map(({ element, updates }) => ({ element, start: updates.start, + audioGroup: updates.audioGroup, // Stable track lanes: a lane is the authored data-track-index, so every // vertical gesture (lane-reorder AND track-insert) must persist the track; // z is paint order only and is synced separately. Plain horizontal moves diff --git a/packages/studio/src/hooks/useTimelineGroupEditing.test.tsx b/packages/studio/src/hooks/useTimelineGroupEditing.test.tsx index 998ae8513f..a8bbf75d63 100644 --- a/packages/studio/src/hooks/useTimelineGroupEditing.test.tsx +++ b/packages/studio/src/hooks/useTimelineGroupEditing.test.tsx @@ -4,6 +4,7 @@ import { act } from "react"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { Root } from "react-dom/client"; import type { TimelineElement } from "../player"; +import { persistTimelineMoveEditsAtomically } from "./timelineMoveAdapter"; import { useTimelineGroupEditing } from "./useTimelineGroupEditing"; import { installReactActEnvironment, mountReactHarness } from "./domSelectionTestHarness"; @@ -72,3 +73,79 @@ describe("useTimelineGroupEditing: handleTimelineGroupMove suppressFailureToast" expect(showToast).toHaveBeenCalledTimes(1); }); }); + +it.each([false, true])( + "keeps detachment and the lane atomic, including a refused history write ($0)", + async (refused) => { + const before = + '
'; + let disk = before; + const failure = new Error("history refused"); + const recordEdit = refused ? vi.fn().mockRejectedValue(failure) : vi.fn(); + const iframe = document.createElement("iframe"); + document.body.append(iframe); + iframe.contentDocument!.body.innerHTML = before; + const writeProjectFile = vi.fn(async (_path: string, content: string) => { + disk = content; + }); + vi.stubGlobal( + "fetch", + vi.fn( + async () => + new Response(JSON.stringify({ content: disk }), { + headers: { "content-type": "application/json" }, + }), + ), + ); + let hook: ReturnType; + function Harness() { + hook = useTimelineGroupEditing({ + activeCompPath: "index.html", + editQueueRef: { current: Promise.resolve() }, + pendingTimelineEditPathRef: { current: new Set() }, + previewIframeRef: { current: iframe }, + projectIdRef: { current: "fixture" }, + recordEdit, + writeProjectFile, + reloadPreview: vi.fn(), + showToast: vi.fn(), + }); + return null; + } + const root = mountReactHarness(); + try { + await act(async () => { + const moved = persistTimelineMoveEditsAtomically( + [ + { + element: { ...el("voice", 0, 2, 1), tag: "audio", audioGroup: "group" }, + updates: { start: 0, track: 0, audioGroup: null }, + }, + ], + "insert", + "track-insert", + { handleTimelineGroupMove: hook!.handleTimelineGroupMove }, + ); + if (refused) await expect(moved).rejects.toBe(failure); + else await moved; + }); + expect( + iframe.contentDocument!.getElementById("voice")?.getAttribute("data-audio-group"), + ).toBe("group"); + if (refused) { + expect(disk).toBe(before); + expect(writeProjectFile).toHaveBeenCalledTimes(2); + return; + } + expect(writeProjectFile).toHaveBeenCalledOnce(); + expect(recordEdit).toHaveBeenCalledOnce(); + expect(disk).not.toContain("data-audio-group"); + expect(disk).toContain('data-track-index="0"'); + expect(recordEdit.mock.calls[0][0].files["index.html"]).toEqual({ before, after: disk }); + } finally { + act(() => root.unmount()); + iframe.remove(); + vi.unstubAllGlobals(); + } + }, +); diff --git a/packages/studio/src/hooks/useTimelineGroupEditing.ts b/packages/studio/src/hooks/useTimelineGroupEditing.ts index 03cb03bc6b..9eb124252d 100644 --- a/packages/studio/src/hooks/useTimelineGroupEditing.ts +++ b/packages/studio/src/hooks/useTimelineGroupEditing.ts @@ -37,6 +37,7 @@ export interface TimelineGroupMoveChange { element: TimelineElement; start: number; track?: number; + audioGroup?: null; } export interface TimelineGroupResizeChange { @@ -296,7 +297,9 @@ export function useTimelineGroupEditing({ sdkChanges: toSdkTimingChanges(changes, (change) => ({ start: toAuthoredStart(change.element, change.start), })), - eligible: changes.every((change) => change.track == null), + eligible: changes.every( + (change) => change.track == null && change.audioGroup === undefined, + ), needsExtension, label, coalesceKey, @@ -315,6 +318,7 @@ export function useTimelineGroupEditing({ toAuthoredStart(change.element, change.start), change.element.duration, change.track, + change.audioGroup, ), })), coalesceKey, diff --git a/packages/studio/src/main.tsx b/packages/studio/src/main.tsx index 7ee9e3953f..63c0ab5710 100644 --- a/packages/studio/src/main.tsx +++ b/packages/studio/src/main.tsx @@ -7,8 +7,12 @@ import { StudioErrorBoundary } from "./components/StudioErrorBoundary"; import { readIconTokens } from "./styles/iconTokens"; import { trackStudioEvent } from "./utils/studioTelemetry"; import { prefetchPreviewForHash } from "./utils/previewPrefetch"; +import { bindThumbnailPageLifecycle } from "./player/lib/thumbnailPageLifecycle"; import "./styles/studio.css"; +const unbindThumbnailPageLifecycle = bindThumbnailPageLifecycle(window, document); +import.meta.hot?.dispose(unbindThumbnailPageLifecycle); + prefetchPreviewForHash(window.location.hash); window.addEventListener("hashchange", () => prefetchPreviewForHash(window.location.hash)); trackStudioEvent("session_start"); diff --git a/packages/studio/src/player/components/Timeline.ghostLane.test.tsx b/packages/studio/src/player/components/Timeline.ghostLane.test.tsx index d777c0b888..dfc0126409 100644 --- a/packages/studio/src/player/components/Timeline.ghostLane.test.tsx +++ b/packages/studio/src/player/components/Timeline.ghostLane.test.tsx @@ -1,14 +1,14 @@ // @vitest-environment happy-dom -import React, { act } from "react"; -import { createRoot } from "react-dom/client"; +import { act } from "react"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { Timeline } from "./Timeline"; +import { + mountThreeTrackTimeline as mountThreeTracks, + dragTimelineFixtureAsset, + pointerTimelineFixture as pointer, +} from "./timelineMountFixtures"; import { installTimelineMountEnv } from "./timelineMountTestEnv"; -import { usePlayerStore } from "../store/playerStore"; import { CLIP_Y, TRACK_H, getTimelineRowTop } from "./timelineLayout"; -import { TIMELINE_ASSET_MIME } from "../../utils/timelineAssetDrop"; -import type { TimelineProps } from "./TimelineTypes"; (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; @@ -21,32 +21,6 @@ afterEach(() => { const GHOST_MID = getTimelineRowTop(3) + TRACK_H / 2; const ROW1_MID = getTimelineRowTop(1) + TRACK_H / 2; -async function mountThreeTracks(props: TimelineProps = {}) { - usePlayerStore.setState({ - duration: 10, - currentTime: 0, - timelineReady: true, - gsapAnimations: new Map(), - selectedElementId: null, - selectedElementIds: new Set(), - elements: [0, 1, 2].map((track) => ({ - id: `c${track}`, - key: `c${track}`, - domId: `c${track}`, - tag: "div", - start: 0, - duration: 2, - track, - })), - }); - const host = document.createElement("div"); - document.body.append(host); - const root = createRoot(host); - await act(async () => root.render()); - const ghost = () => host.querySelector('[data-testid="timeline-ghost-lane"]'); - return { host, root, ghost }; -} - describe("Timeline ghost lane", () => { it("draws one empty track below the last with a drop hint", async () => { const { root, ghost } = await mountThreeTracks({ onAssetDrop: vi.fn() }); @@ -70,22 +44,8 @@ describe("Timeline ghost lane", () => { const onAssetDrop = vi.fn(); const { host, root, ghost } = await mountThreeTracks({ onAssetDrop }); const viewport = host.querySelector("[data-timeline-scroll-viewport]")!; - const drag = (type: string, clientY: number) => { - const event = new Event(type, { bubbles: true, cancelable: true }); - Object.defineProperties(event, { - clientX: { value: 400 }, - clientY: { value: clientY }, - dataTransfer: { - value: { - types: [TIMELINE_ASSET_MIME], - files: [], - dropEffect: "none", - getData: (mime: string) => (mime === TIMELINE_ASSET_MIME ? '{"path":"a.png"}' : ""), - }, - }, - }); - act(() => void viewport.dispatchEvent(event)); - }; + const drag = (type: string, clientY: number) => + dragTimelineFixtureAsset(viewport, type, clientY); drag("dragover", ROW1_MID); expect(ghost()?.dataset.active).toBeUndefined(); drag("dragover", GHOST_MID); @@ -100,19 +60,6 @@ describe("Timeline ghost lane", () => { onMoveElement: vi.fn(), onResizeElement: vi.fn(), }); - const pointer = (target: EventTarget, type: string, clientY: number) => - act( - () => - void target.dispatchEvent( - new PointerEvent(type, { - bubbles: true, - button: 0, - pointerId: 1, - clientX: 400, - clientY, - }), - ), - ); const clipMid = getTimelineRowTop(track) + TRACK_H / 2; pointer(host.querySelector(`[data-clip][data-el-id="c${track}"]`)!, "pointerdown", clipMid); pointer(window, "pointermove", ROW1_MID); diff --git a/packages/studio/src/player/components/Timeline.trackPadding.test.tsx b/packages/studio/src/player/components/Timeline.trackPadding.test.tsx index 83c422ea2b..b73d0d6254 100644 --- a/packages/studio/src/player/components/Timeline.trackPadding.test.tsx +++ b/packages/studio/src/player/components/Timeline.trackPadding.test.tsx @@ -7,7 +7,11 @@ import { Timeline } from "./Timeline"; import { installTimelineMountEnv } from "./timelineMountTestEnv"; import { usePlayerStore } from "../store/playerStore"; import { CLIP_Y, RULER_H, TRACK_H, TRACKS_BOTTOM_PAD, TRACKS_TOP_PAD } from "./timelineLayout"; -import { TIMELINE_ASSET_MIME } from "../../utils/timelineAssetDrop"; +import { + mountThreeTrackTimeline, + dragTimelineFixtureAsset, + pointerTimelineFixture as pointer, +} from "./timelineMountFixtures"; import type { TimelineProps } from "./TimelineTypes"; (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; @@ -59,29 +63,7 @@ describe("Timeline trackPadding", () => { const hostPads = { top: 0, bottom: TRACK_H }; async function mountThreeTracks(props: TimelineProps = {}) { - usePlayerStore.setState({ - duration: 10, - currentTime: 0, - timelineReady: true, - gsapAnimations: new Map(), - selectedElementId: null, - selectedElementIds: new Set(), - elements: [0, 1, 2].map((track) => ({ - id: `c${track}`, - key: `c${track}`, - domId: `c${track}`, - tag: "div", - start: 0, - duration: 2, - track, - })), - }); - const host = document.createElement("div"); - document.body.append(host); - const root = createRoot(host); - await act(async () => root.render()); - const viewport = host.querySelector("[data-timeline-scroll-viewport]")!; - return { host, root, viewport }; + return mountThreeTrackTimeline({ trackPadding: hostPads, ...props }); } const top = (el: Element | null | undefined) => (el as HTMLElement | null)?.style.top; @@ -94,22 +76,8 @@ describe("Timeline trackPadding", () => { `${RULER_H + TRACK_H + CLIP_Y}px`, ); - const drag = (type: string, clientY: number) => { - const event = new Event(type, { bubbles: true, cancelable: true }); - Object.defineProperties(event, { - clientX: { value: 400 }, - clientY: { value: clientY }, - dataTransfer: { - value: { - types: [TIMELINE_ASSET_MIME], - files: [], - dropEffect: "none", - getData: (mime: string) => (mime === TIMELINE_ASSET_MIME ? '{"path":"a.png"}' : ""), - }, - }, - }); - act(() => void viewport.dispatchEvent(event)); - }; + const drag = (type: string, clientY: number) => + dragTimelineFixtureAsset(viewport, type, clientY); drag("dragover", ROW1_MID); expect(top(host.querySelector('[data-testid="timeline-drop-preview"]'))).toBe( `${RULER_H + TRACK_H + CLIP_Y}px`, @@ -128,19 +96,6 @@ describe("Timeline trackPadding", () => { onMoveElement: vi.fn(), onResizeElement: vi.fn(), }); - const pointer = (target: EventTarget, type: string, clientY: number) => - act( - () => - void target.dispatchEvent( - new PointerEvent(type, { - bubbles: true, - button: 0, - pointerId: 1, - clientX: 400, - clientY, - }), - ), - ); const clip = host.querySelector('[data-clip][data-el-id="c0"]')!; pointer(clip, "pointerdown", RULER_H + TRACK_H / 2); pointer(window, "pointermove", ROW1_MID); @@ -149,9 +104,9 @@ describe("Timeline trackPadding", () => { ).find((el) => el.style.zIndex === "30" && !el.dataset.testid); expect(top(ghost)).toBe(`${RULER_H + TRACK_H + CLIP_Y}px`); pointer(window, "pointermove", RULER_H + 3 * TRACK_H + TRACK_H / 2); - expect(top(host.querySelector('[data-testid="timeline-insert-line"]'))).toBe( - `${RULER_H + 3 * TRACK_H - 0.5}px`, - ); + const lane = host.querySelector("[data-timeline-new-track-lane]"); + expect(top(lane)).toBe(`${RULER_H + 3 * TRACK_H}px`); + expect(lane?.style.height).toBe(`${TRACK_H}px`); act(() => root.unmount()); }); }); diff --git a/packages/studio/src/player/components/TimelineCanvas.tsx b/packages/studio/src/player/components/TimelineCanvas.tsx index a40078df0a..dd9739fc8f 100644 --- a/packages/studio/src/player/components/TimelineCanvas.tsx +++ b/packages/studio/src/player/components/TimelineCanvas.tsx @@ -16,7 +16,9 @@ export const TimelineCanvas = memo(function TimelineCanvas() { const props = state.canvas; const { draggedClip, scrollRef, displayTrackOrder } = props; const draggedRowIndex = - draggedClip?.started === true ? displayTrackOrder.indexOf(draggedClip.previewTrack) : -1; + draggedClip?.started === true + ? (draggedClip.insertRow ?? displayTrackOrder.indexOf(draggedClip.previewTrack)) + : -1; const dropTrackIndex = props.dropPreview ? displayTrackOrder.indexOf(props.dropPreview.track) : -1; @@ -168,7 +170,7 @@ export const TimelineCanvas = memo(function TimelineCanvas() { {/* Insertion line — a new track will be inserted at this boundary on drop. Shown while the pointer is near a lane boundary (insert mode). */} - {insertLineRow != null && ( + {insertLineRow != null && !draggedClip?.started && (
document.body.replaceChildren()); describe("TimelineGestureOverlay", () => { + it("anchors an insert actor inside the opened seam at its drop time", () => { + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => + root.render( + ({ left: 0, top: 0 }), + } as HTMLDivElement, + }} + pixelsPerSecond={100} + contentOrigin={232} + rowGeometry={createTimelineRowGeometry([0, -1, 1], [48, 48, 48], { top: 0 })} + rowHeight={42} + selectedElementId="hero" + currentTime={4} + theme={defaultTimelineTheme} + getTrackStyle={getTrackStyle} + />, + ), + ); + const actor = host.querySelector("[data-timeline-gesture-actor]"); + expect(actor?.style.left).toBe("632px"); + expect(actor?.style.top).toBe("75px"); + expect(host.querySelector("[data-timeline-new-track-label]")?.textContent).toContain( + "+ New track", + ); + act(() => root.unmount()); + }); it("keeps the drag actor mounted without a source-row node", () => { const host = document.createElement("div"); document.body.append(host); @@ -47,6 +82,8 @@ describe("TimelineGestureOverlay", () => { drag={drag} scrollRef={{ current: scroll }} pixelsPerSecond={100} + contentOrigin={232} + rowGeometry={createTimelineRowGeometry([0, 1, 2], [48, 48, 48])} rowHeight={42} selectedElementId="hero" currentTime={4} @@ -82,6 +119,8 @@ describe("TimelineGestureOverlay", () => { } as HTMLDivElement, }} pixelsPerSecond={100} + contentOrigin={232} + rowGeometry={createTimelineRowGeometry([0, 1, 2], [48, 48, 48])} rowHeight={42} selectedElementId="hero" currentTime={4} @@ -109,6 +148,8 @@ describe("TimelineGestureOverlay", () => { drag={null} scrollRef={{ current: null }} pixelsPerSecond={100} + contentOrigin={232} + rowGeometry={createTimelineRowGeometry([0, 1, 2], [48, 48, 48])} rowHeight={42} selectedElementId={null} currentTime={0} diff --git a/packages/studio/src/player/components/TimelineGestureOverlay.tsx b/packages/studio/src/player/components/TimelineGestureOverlay.tsx index d73f0f62af..bb1b89a2f3 100644 --- a/packages/studio/src/player/components/TimelineGestureOverlay.tsx +++ b/packages/studio/src/player/components/TimelineGestureOverlay.tsx @@ -1,6 +1,7 @@ import { memo } from "react"; import type { TimelineTheme } from "./timelineTheme"; import { getRenderedTimelineElement } from "./timelineTheme"; +import { CLIP_Y, type TimelineRowGeometry } from "./timelineLayout"; import { TimelineClip } from "./TimelineClip"; import { getTimelineEditCapabilities } from "./timelineEditing"; import { renderClipChildren } from "./timelineClipChildren"; @@ -15,6 +16,8 @@ interface TimelineGestureOverlayProps { scrollRef: React.RefObject; pixelsPerSecond: number; rowHeight: number; + contentOrigin: number; + rowGeometry: TimelineRowGeometry; selectedElementId: string | null; currentTime: number; theme: TimelineTheme; @@ -29,6 +32,8 @@ export const TimelineGestureOverlay = memo(function TimelineGestureOverlay({ scrollRef, pixelsPerSecond, rowHeight, + contentOrigin, + rowGeometry, selectedElementId, currentTime, theme, @@ -36,30 +41,68 @@ export const TimelineGestureOverlay = memo(function TimelineGestureOverlay({ renderClipContent, renderClipOverlay, }: TimelineGestureOverlayProps) { - const element = - drag?.started === true - ? getRenderedTimelineElement({ - element: drag.element, - draggedElementId: drag.element.key ?? drag.element.id, - previewStart: drag.previewStart, - previewTrack: drag.previewTrack, - }) - : null; - const position = drag ? getTimelineDragOverlayPosition(drag, scrollRef.current) : null; + if (!drag?.started) + return
; + const element = getRenderedTimelineElement({ + element: drag.element, + draggedElementId: drag.element.key ?? drag.element.id, + previewStart: drag.previewStart, + previewTrack: drag.previewTrack, + }); + let position = getTimelineDragOverlayPosition(drag, scrollRef.current); + const insertRow = drag.insertRow; + if (insertRow !== null) + position = { + left: contentOrigin + drag.previewStart * pixelsPerSecond, + top: rowGeometry.getRowTop(insertRow) + CLIP_Y, + }; + const clipWidth = Math.max(element.duration * pixelsPerSecond, 4); + const viewport = scrollRef.current; + let labelLeft = clipWidth + 10; + if ( + position && + viewport && + position.left + labelLeft + 100 > viewport.scrollLeft + viewport.clientWidth + ) { + labelLeft = -100; + if (position.left + labelLeft < viewport.scrollLeft + contentOrigin) + labelLeft = Math.max(0, Math.min(clipWidth - 100, drag.pointerOffsetX)); + } return (
- {element && position && ( + {position && (
+ {insertRow !== null && ( + + + New track, before track {insertRow + 1} + + )}