mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
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
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>New-track membership contract</title><style>
|
||||
:root{color-scheme:light dark;
|
||||
--bg:#fbfbfa;--card:#ffffff;--line:#e6e4df;--ink:#1c1c1a;--dim:#6b6b66;--faint:#9a9a94;
|
||||
--go:#178a5a;--go-bg:#e8f6ee;--warn:#a6690a;--warn-bg:#fbf1dc;--bad:#c0392b;--bad-bg:#fbe7e4;--info:#2a5db0;--info-bg:#e8eefb;
|
||||
--mono:ui-monospace,SFMono-Regular,Menlo,monospace;--sans:-apple-system,BlinkMacSystemFont,"Segoe UI",Inter,sans-serif}
|
||||
@media (prefers-color-scheme:dark){:root{--bg:#111213;--card:#191b1d;--line:#2a2d31;--ink:#ecebe8;--dim:#a2a29c;--faint:#6f6f6a;
|
||||
--go:#5fd39a;--go-bg:#12291f;--warn:#e6b35a;--warn-bg:#2b2311;--bad:#ff7b6b;--bad-bg:#2d1714;--info:#7fa9ff;--info-bg:#15203a}}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;background:var(--bg);color:var(--ink);font:16px/1.55 var(--sans)}
|
||||
.wrap{max-width:900px;margin:0 auto;padding:56px 24px 96px}
|
||||
header h1{font-size:30px;line-height:1.15;letter-spacing:-.4px;margin:0 0 10px}
|
||||
header .what{font-size:18px;color:var(--dim);margin:0 0 18px;max-width:70ch}
|
||||
.strip{display:flex;gap:10px;flex-wrap:wrap;align-items:center;font:13px var(--mono);color:var(--faint)}
|
||||
.strip .sep{opacity:.5}
|
||||
.pill{display:inline-flex;align-items:center;gap:6px;font:600 12px/1 var(--mono);padding:6px 10px;border-radius:999px;white-space:nowrap}
|
||||
.pill.go{color:var(--go);background:var(--go-bg)}.pill.warn{color:var(--warn);background:var(--warn-bg)}
|
||||
.pill.bad{color:var(--bad);background:var(--bad-bg)}.pill.info{color:var(--info);background:var(--info-bg)}
|
||||
.pill b{font-size:13px}
|
||||
section{margin-top:56px}
|
||||
h2{font-size:22px;letter-spacing:-.3px;margin:0 0 6px}
|
||||
.sub{color:var(--dim);margin:0 0 18px;font-size:15px}
|
||||
.card{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:20px 22px;margin:14px 0}
|
||||
.breath{border-left:4px solid var(--go);font-size:18px;line-height:1.5}
|
||||
.breath b{color:var(--go)}
|
||||
.plain{color:var(--dim);font-size:15px;margin:0 0 10px}
|
||||
.plain::before{content:"In plain words ";font:600 11px var(--mono);letter-spacing:1px;color:var(--faint);text-transform:uppercase}
|
||||
pre{background:var(--bg);border:1px solid var(--line);border-radius:10px;padding:14px 16px;overflow:auto;font:13px/1.55 var(--mono);margin:10px 0}
|
||||
code{font:13px var(--mono);background:var(--bg);border:1px solid var(--line);border-radius:5px;padding:1px 6px}
|
||||
.cite{font:12px var(--mono);color:var(--faint);margin-top:8px;display:block}
|
||||
.states{display:flex;gap:8px;margin:12px 0 4px;flex-wrap:wrap}
|
||||
table{width:100%;border-collapse:collapse;margin:10px 0;font-size:14.5px}
|
||||
th{text-align:left;font:600 11px/1.4 var(--mono);text-transform:uppercase;letter-spacing:.7px;color:var(--faint);border-bottom:1px solid var(--line);padding:8px 10px}
|
||||
td{padding:10px;border-bottom:1px solid var(--line);vertical-align:top}
|
||||
tr:last-child td{border-bottom:0}
|
||||
td.k{font:13px var(--mono);white-space:nowrap}
|
||||
.decision{border-left:4px solid var(--info)}
|
||||
.decision h3{margin:0 0 8px;font-size:17px}
|
||||
.decision .ask{color:var(--info);font-weight:600}
|
||||
blockquote{margin:12px 0;padding:8px 0 8px 16px;border-left:2px solid var(--line);color:var(--dim);font-style:italic;font-size:14.5px}
|
||||
blockquote .src{display:block;font-style:normal;font:12px var(--mono);color:var(--faint);margin-top:6px}
|
||||
ul,ol{padding-left:22px}li{margin:6px 0}
|
||||
.flow{display:flex;flex-wrap:wrap;gap:10px;align-items:center;background:var(--card);border:1px solid var(--line);border-radius:14px;padding:18px;margin:14px 0;overflow:auto}
|
||||
.flow span{border:1px solid var(--line);padding:12px;border-radius:8px}
|
||||
footer{margin-top:72px;padding-top:16px;border-top:1px solid var(--line);font:12px/1.7 var(--mono);color:var(--faint)}
|
||||
</style></head><body><main class="wrap"><header><h1>New-track drops leave an audio group</h1><p class="what">A clip lands exactly where the ghost showed. One undo restores its track and membership.</p><div class="strip"><span class="pill go">audited 5</span><span class="pill warn">trusting 0</span><span class="pill bad">not exercised 1</span><span class="pill info">open decisions 0</span><span>hyperframes @ de13693179f53c3f625b7b501f1f5fc7b083b6c9, working changes, 2026-10-02</span></div></header>
|
||||
<section><div class="card breath"><b>In one breath.</b> 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.</div></section>
|
||||
<section><h2>Where the value travels</h2><div class="flow" aria-label="One transaction carries drop fields into undo"><span>Drop edits</span> → <span>Move adapter</span> → <span>Source patch</span> → <span>File + history</span> → <span>Undo restore</span></div></section>
|
||||
<section><h2>The contract, per boundary</h2>
|
||||
<div class="card"><h3>Move payload</h3><p class="plain">At the audited base, membership was absent from the move payload. The adapter explicitly selects only these fields.</p><pre>edits.map(({ element, updates }) => ({
|
||||
element,
|
||||
start: updates.start,
|
||||
track: operation === "timing" ? undefined : updates.track,
|
||||
}))</pre><span class="cite">producer src/player/components/timelineClipDragCommit.ts:153; consumer src/hooks/timelineMoveAdapter.ts:37. Paths relative to packages/studio.</span><div class="states"><span class="pill bad">membership: absent</span><span class="pill bad">reachable: no</span><span class="pill bad">written: no</span></div></div>
|
||||
<div class="card"><h3>Atomic source edit</h3><p class="plain">The group move turns all clip changes into source patches inside one queued file transaction. Track changes bypass the timing-only SDK path.</p><pre>buildTimelineMoveTimingPatch(
|
||||
original,
|
||||
target,
|
||||
toAuthoredStart(change.element, change.start),
|
||||
change.element.duration,
|
||||
change.track,
|
||||
)</pre><span class="cite">producer src/hooks/useTimelineGroupEditing.ts:311; consumer src/hooks/timelineEditingHelpers.ts:235. SDK eligibility src/hooks/useTimelineGroupEditing.ts:299.</span><div class="states"><span class="pill go">track: exists</span><span class="pill go">reachable</span><span class="pill go">written</span></div></div>
|
||||
<div class="card"><h3>Undo owns whole-file snapshots</h3><p class="plain">One transaction records the complete before and after source. Including membership in the same patch gives undo its original value.</p><pre>snapshots[path] = { before, after };
|
||||
await recordEdit({ label, coalesceKey, coalesceMs, files: snapshots });</pre><span class="cite">producer src/utils/studioFileHistory.ts:128,144; undo consumer src/hooks/usePersistentEditHistory.ts:81,99; preview sync consumer src/hooks/useEditHistoryActions.ts:112.</span><div class="states"><span class="pill go">snapshots: exist</span><span class="pill go">reachable</span><span class="pill go">written and restored</span></div></div></section>
|
||||
<section><h2>How it says no</h2><table><tr><th>Signal</th><th>Meaning and response</th></tr><tr><td>Locked shifted clip</td><td>Insertion returns without persistence. Existing permission rules remain the owner.</td></tr><tr><td>Save rejection</td><td>The file transaction restores already-written snapshots and rethrows. The optimistic clip rollback must restore membership alongside timing and track.</td></tr><tr><td>Undo content mismatch</td><td>History refuses to overwrite a file changed since the edit and reports the conflict.</td></tr></table></section>
|
||||
<section><h2>Decision already supplied</h2><div class="card decision">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.</div></section>
|
||||
<section><h2>Coverage, honestly</h2><table><tr><th>Label</th><th>Coverage</th></tr><tr><td>Audited</td><td>Drop edits, adapter, group move, source patch, snapshot and undo consumers opened at the named base.</td></tr><tr><td>Not exercised</td><td>This page records the pre-implementation audit. Runtime proof is reported separately against the implementation head.</td></tr></table></section>
|
||||
<section><h2>What would prove this page wrong</h2><p>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.</p></section></main></body></html>
|
||||
@@ -0,0 +1,59 @@
|
||||
<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Thumbnail document lifetime</title><style>
|
||||
:root{color-scheme:light dark;
|
||||
--bg:#fbfbfa;--card:#ffffff;--line:#e6e4df;--ink:#1c1c1a;--dim:#6b6b66;--faint:#9a9a94;
|
||||
--go:#178a5a;--go-bg:#e8f6ee;--warn:#a6690a;--warn-bg:#fbf1dc;--bad:#c0392b;--bad-bg:#fbe7e4;--info:#2a5db0;--info-bg:#e8eefb;
|
||||
--mono:ui-monospace,SFMono-Regular,Menlo,monospace;--sans:-apple-system,BlinkMacSystemFont,"Segoe UI",Inter,sans-serif}
|
||||
@media (prefers-color-scheme:dark){:root{--bg:#111213;--card:#191b1d;--line:#2a2d31;--ink:#ecebe8;--dim:#a2a29c;--faint:#6f6f6a;
|
||||
--go:#5fd39a;--go-bg:#12291f;--warn:#e6b35a;--warn-bg:#2b2311;--bad:#ff7b6b;--bad-bg:#2d1714;--info:#7fa9ff;--info-bg:#15203a}}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;background:var(--bg);color:var(--ink);font:16px/1.55 var(--sans)}
|
||||
.wrap{max-width:900px;margin:0 auto;padding:56px 24px 96px}
|
||||
header h1{font-size:30px;line-height:1.15;letter-spacing:-.4px;margin:0 0 10px}
|
||||
header .what{font-size:18px;color:var(--dim);margin:0 0 18px;max-width:70ch}
|
||||
.strip{display:flex;gap:10px;flex-wrap:wrap;align-items:center;font:13px var(--mono);color:var(--faint)}
|
||||
.strip .sep{opacity:.5}
|
||||
.pill{display:inline-flex;align-items:center;gap:6px;font:600 12px/1 var(--mono);padding:6px 10px;border-radius:999px;white-space:nowrap}
|
||||
.pill.go{color:var(--go);background:var(--go-bg)}.pill.warn{color:var(--warn);background:var(--warn-bg)}
|
||||
.pill.bad{color:var(--bad);background:var(--bad-bg)}.pill.info{color:var(--info);background:var(--info-bg)}
|
||||
.pill b{font-size:13px}
|
||||
section{margin-top:56px}
|
||||
h2{font-size:22px;letter-spacing:-.3px;margin:0 0 6px}
|
||||
.sub{color:var(--dim);margin:0 0 18px;font-size:15px}
|
||||
.card{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:20px 22px;margin:14px 0}
|
||||
.breath{border-left:4px solid var(--go);font-size:18px;line-height:1.5}
|
||||
.breath b{color:var(--go)}
|
||||
.plain{color:var(--dim);font-size:15px;margin:0 0 10px}
|
||||
.plain::before{content:"In plain words ";font:600 11px var(--mono);letter-spacing:1px;color:var(--faint);text-transform:uppercase}
|
||||
pre{background:var(--bg);border:1px solid var(--line);border-radius:10px;padding:14px 16px;overflow:auto;font:13px/1.55 var(--mono);margin:10px 0}
|
||||
code{font:13px var(--mono);background:var(--bg);border:1px solid var(--line);border-radius:5px;padding:1px 6px}
|
||||
.cite{font:12px var(--mono);color:var(--faint);margin-top:8px;display:block}
|
||||
.states{display:flex;gap:8px;margin:12px 0 4px;flex-wrap:wrap}
|
||||
table{width:100%;border-collapse:collapse;margin:10px 0;font-size:14.5px}
|
||||
th{text-align:left;font:600 11px/1.4 var(--mono);text-transform:uppercase;letter-spacing:.7px;color:var(--faint);border-bottom:1px solid var(--line);padding:8px 10px}
|
||||
td{padding:10px;border-bottom:1px solid var(--line);vertical-align:top}
|
||||
tr:last-child td{border-bottom:0}
|
||||
td.k{font:13px var(--mono);white-space:nowrap}
|
||||
.decision{border-left:4px solid var(--info)}
|
||||
.decision h3{margin:0 0 8px;font-size:17px}
|
||||
.decision .ask{color:var(--info);font-weight:600}
|
||||
blockquote{margin:12px 0;padding:8px 0 8px 16px;border-left:2px solid var(--line);color:var(--dim);font-style:italic;font-size:14.5px}
|
||||
blockquote .src{display:block;font-style:normal;font:12px var(--mono);color:var(--faint);margin-top:6px}
|
||||
ul,ol{padding-left:22px}li{margin:6px 0}
|
||||
.flow{display:flex;flex-wrap:wrap;gap:10px;align-items:center;background:var(--card);border:1px solid var(--line);border-radius:14px;padding:18px;margin:14px 0;overflow:auto}
|
||||
.flow span{border:1px solid var(--line);padding:12px;border-radius:8px}
|
||||
footer{margin-top:72px;padding-top:16px;border-top:1px solid var(--line);font:12px/1.7 var(--mono);color:var(--faint)}
|
||||
</style></head><body><main class="wrap"><header><h1>Thumbnail connections leave with their document</h1><p class="what">Saves must not queue behind media from a document the person already left.</p><div class="strip">2026-10-02 · source 0337620 · audited 4 · trusting 0 · not exercised 1 · decisions 0</div></header>
|
||||
<section><h2>In one breath</h2><div class="card 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.</div></section>
|
||||
<section><h2>Where the value travels</h2><div class="flow"><span>pagehide / pageshow</span> → <span>One lifecycle actor</span> → <span>Thumbnail scheduler</span> → <span>AbortSignal</span> → <span>fetch / media decoder</span></div></section>
|
||||
<section><h2>The verified boundaries</h2><div class="card"><h3>Scheduler owns work</h3><pre>entry.controller = new AbortController();
|
||||
entry.request.load(controller.signal)</pre><span class="cite">Producer packages/studio/src/player/lib/thumbnailScheduler.ts:291-301; consumer packages/studio/src/player/components/CompositionThumbnail.tsx:104-112.</span><p>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.</p></div>
|
||||
<div class="card"><h3>Abort and retain</h3><pre>entry.preempted = true;
|
||||
entry.controller?.abort();</pre><span class="cite">Producer thumbnailScheduler.ts:228-231; consumer catch/requeue/pump at :301-320 and :344-351. Paths relative to packages/studio/src/player/lib.</span><p>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.</p></div>
|
||||
<div class="card"><h3>The receiving HTTP route</h3><pre>await thumbnailGenerationCoordinator.acquire(
|
||||
cachePath, c.req.raw.signal, async (signal) => adapter.generateThumbnail({ signal, ... })
|
||||
)</pre><span class="cite">packages/studio-server/src/routes/thumbnail.ts:227-244; client fetch signal at CompositionThumbnail.tsx:108-110.</span><p>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.</p></div></section>
|
||||
<section><h2>How it says no</h2><table><tr><th>Signal</th><th>Meaning</th></tr><tr><td>AbortError</td><td>Requested cancellation; retained leases return to the queue. No thumbnail error state.</td></tr><tr><td>Document hidden</td><td>Abort active work and hold all queued thumbnail kinds.</td></tr><tr><td>Preview reload remains active</td><td>Pageshow permits other thumbnail kinds, but composition work retains its existing hold.</td></tr></table></section>
|
||||
<section><h2>Decision already supplied</h2><div class="card">Saves never queue behind media. Fix the held connections, preserve a tight persistence bound. No new product decision.</div></section>
|
||||
<section><h2>Coverage, honestly</h2><p>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.</p></section>
|
||||
<section><h2>What would prove this page wrong</h2><p>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.</p></section></main></body></html>
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -5,9 +5,13 @@ import type {
|
||||
TimelineGroupMoveChange,
|
||||
} from "./useTimelineGroupEditing";
|
||||
|
||||
export interface TimelineMoveEdit {
|
||||
export type TimelineAtomicMoveUpdates = Pick<TimelineElement, "start" | "track"> & {
|
||||
audioGroup?: null;
|
||||
};
|
||||
|
||||
export interface TimelineAtomicMoveEdit {
|
||||
element: TimelineElement;
|
||||
updates: Pick<TimelineElement, "start" | "track">;
|
||||
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<void>;
|
||||
|
||||
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
|
||||
|
||||
@@ -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 =
|
||||
'<main data-composition-id="root" data-duration="4"><audio id="voice" data-start="0" data-duration="2" data-track-index="1" data-audio-group="group"></audio></main>';
|
||||
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<typeof useTimelineGroupEditing>;
|
||||
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(<Harness />);
|
||||
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();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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(<Timeline {...props} />));
|
||||
const ghost = () => host.querySelector<HTMLElement>('[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<HTMLElement>("[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);
|
||||
|
||||
@@ -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(<Timeline trackPadding={hostPads} {...props} />));
|
||||
const viewport = host.querySelector<HTMLElement>("[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<HTMLElement>("[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());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 && (
|
||||
<div
|
||||
data-testid="timeline-insert-line"
|
||||
className="absolute pointer-events-none"
|
||||
@@ -211,6 +213,8 @@ export const TimelineCanvas = memo(function TimelineCanvas() {
|
||||
scrollRef={scrollRef}
|
||||
pixelsPerSecond={props.pps}
|
||||
rowHeight={draggedClipHeight}
|
||||
rowGeometry={props.rowGeometry}
|
||||
contentOrigin={props.contentOrigin}
|
||||
selectedElementId={props.selectedElementId}
|
||||
currentTime={props.currentTime}
|
||||
theme={props.theme}
|
||||
|
||||
@@ -141,6 +141,12 @@ export const TimelineClip = memo(function TimelineClip({
|
||||
tabIndex={isGestureActor ? undefined : tabIndex}
|
||||
aria-label={`${displayLabel}, ${startLabel} to ${endLabel} seconds`}
|
||||
aria-pressed={isGestureActor ? undefined : isSelected}
|
||||
aria-keyshortcuts={isGestureActor || !capabilities.canMove ? undefined : "Space"}
|
||||
aria-description={
|
||||
isGestureActor || !capabilities.canMove
|
||||
? undefined
|
||||
: "Space picks up. Up and Down choose a new track. Enter drops. Escape cancels."
|
||||
}
|
||||
className={clipClassName}
|
||||
style={style}
|
||||
title={
|
||||
|
||||
@@ -6,6 +6,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { defaultTimelineTheme } from "./timelineTheme";
|
||||
import { TimelineGestureOverlay } from "./TimelineGestureOverlay";
|
||||
import type { DraggedClipState } from "./timelineClipDragTypes";
|
||||
import { createTimelineRowGeometry } from "./timelineLayout";
|
||||
import { getTrackStyle } from "./useTimelineTrackLayout";
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
@@ -32,6 +33,40 @@ const drag: DraggedClipState = {
|
||||
afterEach(() => 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(
|
||||
<TimelineGestureOverlay
|
||||
drag={{ ...drag, insertRow: 1 }}
|
||||
scrollRef={{
|
||||
current: {
|
||||
scrollLeft: 0,
|
||||
scrollTop: 0,
|
||||
getBoundingClientRect: () => ({ 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<HTMLElement>("[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}
|
||||
|
||||
@@ -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<HTMLDivElement | null>;
|
||||
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 <div data-timeline-gesture-overlay className="absolute inset-0 pointer-events-none" />;
|
||||
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 (
|
||||
<div data-timeline-gesture-overlay className="absolute inset-0 pointer-events-none">
|
||||
{element && position && (
|
||||
{position && (
|
||||
<div
|
||||
data-timeline-gesture-actor={element.key ?? element.id}
|
||||
className="absolute"
|
||||
style={{
|
||||
top: position.top,
|
||||
left: position.left,
|
||||
width: Math.max(element.duration * pixelsPerSecond, 4),
|
||||
width: clipWidth,
|
||||
height: rowHeight,
|
||||
zIndex: 40,
|
||||
}}
|
||||
>
|
||||
{insertRow !== null && (
|
||||
<span
|
||||
data-timeline-new-track-label
|
||||
role="status"
|
||||
className="absolute whitespace-nowrap"
|
||||
style={{
|
||||
left: labelLeft,
|
||||
top: (rowHeight - 22) / 2,
|
||||
height: 22,
|
||||
padding: "2px 8px",
|
||||
borderRadius: 999,
|
||||
fontSize: 11,
|
||||
color: "var(--timeline-accent)",
|
||||
background: theme.rowBackground,
|
||||
border: "1px solid var(--timeline-accent)",
|
||||
zIndex: 25,
|
||||
}}
|
||||
>
|
||||
+ New track<span className="sr-only">, before track {insertRow + 1}</span>
|
||||
</span>
|
||||
)}
|
||||
<TimelineClip
|
||||
el={{ ...element, start: 0 }}
|
||||
pps={pixelsPerSecond}
|
||||
|
||||
@@ -54,7 +54,6 @@ export function TimelineLanes({
|
||||
pinnedClipIdentities,
|
||||
trackOrder,
|
||||
tracks,
|
||||
trackStyles,
|
||||
groups,
|
||||
laneCounts,
|
||||
selectedElementId,
|
||||
@@ -197,9 +196,26 @@ export function TimelineLanes({
|
||||
actorWindows,
|
||||
)
|
||||
: els;
|
||||
const ts = trackStyles.get(trackNum) ?? getTrackStyle("");
|
||||
const isPendingTrack =
|
||||
draggedClip?.started === true && !trackOrder.includes(trackNum) && els.length === 0;
|
||||
if (isPendingTrack)
|
||||
return (
|
||||
<div
|
||||
key={rowKey}
|
||||
data-timeline-new-track-lane={row}
|
||||
aria-hidden="true"
|
||||
className={rowsVirtualized ? "absolute" : "relative"}
|
||||
style={{
|
||||
top: rowsVirtualized ? rowGeometry.getRowTop(row) : undefined,
|
||||
marginLeft: contentOrigin,
|
||||
width: trackContentWidth,
|
||||
height: TRACK_H,
|
||||
border: "1px dashed var(--timeline-accent)",
|
||||
background: "color-mix(in srgb, var(--timeline-accent) 5%, transparent)",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
const rowBackground = theme.rowBackground;
|
||||
const beatStripOnTrack = trackShowsBeatStrip(els, beatAnalysis?.beatTimes, {
|
||||
selectedElementId,
|
||||
@@ -343,20 +359,6 @@ export function TimelineLanes({
|
||||
renderTimeRange={rowsVirtualized ? renderTimeRange : undefined}
|
||||
/>
|
||||
)}
|
||||
{isPendingTrack && (
|
||||
<div
|
||||
className="absolute inset-0 flex items-center"
|
||||
style={{
|
||||
paddingLeft: 16,
|
||||
color: ts.label,
|
||||
fontSize: 11,
|
||||
letterSpacing: "0.06em",
|
||||
textTransform: "uppercase",
|
||||
}}
|
||||
>
|
||||
New track
|
||||
</div>
|
||||
)}
|
||||
{
|
||||
// fallow-ignore-next-line complexity
|
||||
renderElements.map((el) => {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
// fallow-ignore-file code-duplication
|
||||
// fallow-ignore-file dead-code
|
||||
import type { TimelineEditOutcome } from "../../hooks/timelineEditPermission";
|
||||
import type { TimelineElement } from "../store/playerStore";
|
||||
import type { TimelineMoveOperation } from "../../hooks/timelineMoveAdapter";
|
||||
import type {
|
||||
TimelineMoveOperation,
|
||||
TimelineAtomicMoveUpdates,
|
||||
} from "../../hooks/timelineMoveAdapter";
|
||||
import type { BlockedTimelineEditIntent } from "./timelineEditing";
|
||||
import type { PropertyGroupName } from "@hyperframes/core/gsap-parser";
|
||||
import type { TimelineKeyframeTarget } from "./timelineKeyframeIdentity";
|
||||
@@ -50,7 +52,7 @@ export type TimelineLinkEdit =
|
||||
export interface TimelineEditCallbacks {
|
||||
onMoveElement?: (
|
||||
element: TimelineElement,
|
||||
updates: Pick<TimelineElement, "start" | "track">,
|
||||
updates: TimelineAtomicMoveUpdates,
|
||||
) => Promise<void> | void;
|
||||
/** Atomic multi-clip move (single undo) for main-track ripple + track-insert.
|
||||
* `coalesceKey` (drag-commit gesture id) merges the move history entry with a
|
||||
@@ -58,7 +60,7 @@ export interface TimelineEditCallbacks {
|
||||
* widens that entry's fold window when a server round-trip separates the
|
||||
* gesture's records (per-gesture-unique keys keep the fold gesture-scoped). */
|
||||
onMoveElements?: (
|
||||
edits: Array<{ element: TimelineElement; updates: Pick<TimelineElement, "start" | "track"> }>,
|
||||
edits: Array<{ element: TimelineElement; updates: TimelineAtomicMoveUpdates }>,
|
||||
coalesceKey?: string,
|
||||
operation?: TimelineMoveOperation,
|
||||
coalesceMs?: number,
|
||||
|
||||
@@ -1483,6 +1483,57 @@ describe("persistMoveEdits: a nested row dropped before its host", () => {
|
||||
});
|
||||
|
||||
describe("persistMoveEdits convergence", () => {
|
||||
it.each([
|
||||
{ successor: "timing", detach: false, expectedGroup: "G" },
|
||||
{ successor: "detach", detach: true, expectedGroup: undefined },
|
||||
])(
|
||||
"preserves newer $successor while rolling back a refused detach",
|
||||
async ({ detach, expectedGroup }) => {
|
||||
let current: TimelineElement = { ...el("grouped", 0, 0, 4), audioGroup: "G" };
|
||||
let rejectDetach!: (error: Error) => void;
|
||||
let resolveSuccessor!: () => void;
|
||||
const refused = new Promise<void>((_resolve, reject) => {
|
||||
rejectDetach = reject;
|
||||
});
|
||||
const successor = new Promise<void>((resolve) => {
|
||||
resolveSuccessor = resolve;
|
||||
});
|
||||
const updateElement = (_key: string, updates: Partial<TimelineElement>) => {
|
||||
current = { ...current, ...updates };
|
||||
};
|
||||
const deps = { elements: [current], trackOrder: [0, 1], updateElement };
|
||||
const first = persistMoveEdits(
|
||||
[{ element: current, updates: { start: 0, track: 1, audioGroup: null } }],
|
||||
{ ...deps, onMoveElements: () => refused },
|
||||
undefined,
|
||||
"track-insert",
|
||||
);
|
||||
expect(current.audioGroup).toBeUndefined();
|
||||
const second = persistMoveEdits(
|
||||
[
|
||||
{
|
||||
element: current,
|
||||
updates: { start: 5, track: 1, ...(detach ? { audioGroup: null } : {}) },
|
||||
},
|
||||
],
|
||||
{ ...deps, onMoveElements: () => successor },
|
||||
);
|
||||
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
try {
|
||||
rejectDetach(new Error("save refused"));
|
||||
await expect(first).resolves.toBe(false);
|
||||
expect(current).toMatchObject({ start: 5, track: 1 });
|
||||
expect(current.audioGroup).toBe(expectedGroup);
|
||||
resolveSuccessor();
|
||||
await expect(second).resolves.toBe(true);
|
||||
expect(current).toMatchObject({ start: 5, track: 1 });
|
||||
expect(current.audioGroup).toBe(expectedGroup);
|
||||
} finally {
|
||||
consoleError.mockRestore();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("reasserts a saved lane after a stale runtime sync", async () => {
|
||||
const clip = { ...el("headline", 2, 0.5, 4.9), authoredTrack: 2 };
|
||||
let releaseSave: (() => void) | undefined;
|
||||
|
||||
@@ -6,12 +6,17 @@ import type { DraggedClipState } from "./useTimelineClipDrag";
|
||||
import type { ZMirrorLaneMove } from "./timelineZMirror";
|
||||
import { classifyZone } from "./timelineZones";
|
||||
import { layoutAfterTrackInsert } from "./timelineDragLanding";
|
||||
import type { TimelineTrackInsertLayout } from "./timelineTrackInsertLayout";
|
||||
import { computeStackingPatches, type StackingPatch } from "./timelineStackingSync";
|
||||
import {
|
||||
canMoveTimelineElement as canMoveElement,
|
||||
resolveExpandedHostAlias,
|
||||
} from "./timelineAuthoredMoveTarget";
|
||||
import type { TimelineMoveOperation } from "../../hooks/timelineMoveAdapter";
|
||||
import type {
|
||||
TimelineMoveOperation,
|
||||
TimelineAtomicMoveUpdates,
|
||||
TimelineAtomicMoveEdit,
|
||||
} from "../../hooks/timelineMoveAdapter";
|
||||
import {
|
||||
beginTimelineOptimisticGesture,
|
||||
isLatestTimelineOptimisticGesture,
|
||||
@@ -21,10 +26,8 @@ import { refreshAfterDurableLaneMove } from "./timelineLaneMoveRefresh";
|
||||
import { authoredTrackForLane } from "./timelineAuthoredTrack";
|
||||
import { resolveGroupMovers } from "./timelineMultiDragPreview";
|
||||
|
||||
type StartTrack = Pick<TimelineElement, "start" | "track">;
|
||||
export interface TimelineMoveEdit {
|
||||
element: TimelineElement;
|
||||
updates: StartTrack;
|
||||
type StartTrack = TimelineAtomicMoveUpdates;
|
||||
export interface TimelineMoveEdit extends TimelineAtomicMoveEdit {
|
||||
/**
|
||||
* File-space track override for the persist. The store's `updates.track` is a
|
||||
* DISPLAY lane; when the source file's numbering is sparse (authored tracks
|
||||
@@ -37,6 +40,7 @@ export interface TimelineMoveEdit {
|
||||
export interface DragCommitDeps {
|
||||
elements: TimelineElement[];
|
||||
trackOrder: number[];
|
||||
trackInsertLayout?: TimelineTrackInsertLayout;
|
||||
updateElement: (key: string, updates: Partial<TimelineElement>) => void;
|
||||
/** Single-clip, SDK-cutover-aware persist (pure time-moves keep this path). */
|
||||
onMoveElement?: (element: TimelineElement, updates: StartTrack) => Promise<void> | void;
|
||||
@@ -85,6 +89,17 @@ const round3 = (v: number) => Math.round(v * 1000) / 1000;
|
||||
// One deterministic coalesce key shared by both records in a lane-change gesture.
|
||||
let laneChangeGestureSeq = 0;
|
||||
|
||||
function moveStoreUpdates(edit: TimelineMoveEdit) {
|
||||
const writtenTrack =
|
||||
edit.persistTrack ??
|
||||
(edit.updates.track !== edit.element.track ? edit.updates.track : undefined);
|
||||
const { audioGroup, ...timing } = edit.updates;
|
||||
return {
|
||||
timing: writtenTrack == null ? timing : { ...timing, authoredTrack: writtenTrack },
|
||||
detach: audioGroup === null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimistically apply + persist a batch of moves with rollback on failure.
|
||||
*
|
||||
@@ -123,26 +138,42 @@ export function persistMoveEdits(
|
||||
}
|
||||
const prev = edits.map((e) => ({
|
||||
key: keyOf(e.element),
|
||||
start: e.element.start,
|
||||
track: e.element.track,
|
||||
authoredTrack: e.element.authoredTrack,
|
||||
updates: {
|
||||
start: e.element.start,
|
||||
track: e.element.track,
|
||||
authoredTrack: e.element.authoredTrack,
|
||||
...(e.updates.audioGroup === null ? { audioGroup: e.element.audioGroup } : {}),
|
||||
},
|
||||
}));
|
||||
const revision = beginTimelineOptimisticGesture(
|
||||
updateElement,
|
||||
edits.map((edit) => keyOf(edit.element)),
|
||||
);
|
||||
const membershipRevision = beginTimelineOptimisticGesture(
|
||||
updateElement,
|
||||
edits.filter((edit) => edit.updates.audioGroup === null).map((edit) => keyOf(edit.element)),
|
||||
"membership",
|
||||
);
|
||||
// The file write below targets `persistTrack` (authored space) when supplied,
|
||||
// or `updates.track` on a genuine lane write (track insert renumber). Mirror
|
||||
// that written value into the store's `authoredTrack` so a SECOND drag before
|
||||
// any reload resolves authored tracks from what the file now says, not stale
|
||||
// pre-edit data. Pure time-moves leave authoredTrack untouched.
|
||||
const applyEdit = (e: TimelineMoveEdit) => {
|
||||
const writtenTrack =
|
||||
e.persistTrack ?? (e.updates.track !== e.element.track ? e.updates.track : undefined);
|
||||
updateElement(
|
||||
keyOf(e.element),
|
||||
writtenTrack == null ? e.updates : { ...e.updates, authoredTrack: writtenTrack },
|
||||
);
|
||||
const applyEdit = (e: TimelineMoveEdit, reassert = false) => {
|
||||
const { timing, detach } = moveStoreUpdates(e);
|
||||
const key = keyOf(e.element);
|
||||
const updates: Partial<TimelineElement> = {};
|
||||
if (!reassert || isLatestTimelineOptimisticGesture(updateElement, revision, key)) {
|
||||
Object.assign(updates, timing);
|
||||
}
|
||||
if (
|
||||
detach &&
|
||||
(!reassert ||
|
||||
isLatestTimelineOptimisticGesture(updateElement, membershipRevision, key, "membership"))
|
||||
) {
|
||||
updates.audioGroup = undefined;
|
||||
}
|
||||
if (Object.keys(updates).length) updateElement(key, updates);
|
||||
};
|
||||
for (const e of edits) applyEdit(e);
|
||||
// The store above gets DISPLAY lanes; the file below gets the authored-space
|
||||
@@ -161,17 +192,21 @@ export function persistMoveEdits(
|
||||
// restore the preview manifest's pre-gesture lane. Reassert the durable
|
||||
// result after persistence, but only while this remains the latest
|
||||
// optimistic gesture so an older save can never clobber a newer drag.
|
||||
for (const e of edits) {
|
||||
const key = keyOf(e.element);
|
||||
if (isLatestTimelineOptimisticGesture(updateElement, revision, key)) applyEdit(e);
|
||||
}
|
||||
for (const e of edits) applyEdit(e, true);
|
||||
return true;
|
||||
},
|
||||
(error) => {
|
||||
for (const p of prev) {
|
||||
const { audioGroup, ...timing } = p.updates;
|
||||
const updates: Partial<TimelineElement> = {};
|
||||
if (isLatestTimelineOptimisticGesture(updateElement, revision, p.key)) {
|
||||
updateElement(p.key, { start: p.start, track: p.track, authoredTrack: p.authoredTrack });
|
||||
Object.assign(updates, timing);
|
||||
}
|
||||
if (
|
||||
isLatestTimelineOptimisticGesture(updateElement, membershipRevision, p.key, "membership")
|
||||
)
|
||||
updates.audioGroup = audioGroup;
|
||||
if (Object.keys(updates).length) updateElement(p.key, updates);
|
||||
}
|
||||
console.error("[Timeline] Failed to persist clip edits", error);
|
||||
return false;
|
||||
@@ -370,7 +405,14 @@ function buildTrackInsertEdits(
|
||||
let start = src.start;
|
||||
if (normKey === editKey) start = previewStart;
|
||||
else if (multi?.keys.has(normKey)) start = multi.movedStart(src);
|
||||
edits.push({ element: src, updates: { start, track: norm.track } });
|
||||
edits.push({
|
||||
element: src,
|
||||
updates: {
|
||||
start,
|
||||
track: norm.track,
|
||||
...(normKey === editKey && src.audioGroup ? { audioGroup: null } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
return { candidate, edits };
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { TimelineTrackInsertLayout } from "./timelineTrackInsertLayout";
|
||||
import type { Dispatch, RefObject, SetStateAction } from "react";
|
||||
import { resolveTimelineDragEscape } from "./timelineEditing";
|
||||
import { commitDraggedClipMove } from "./timelineClipDragCommit";
|
||||
@@ -37,6 +38,8 @@ interface TimelineGestureCommit {
|
||||
type UpdateElement = ReturnType<typeof usePlayerStore.getState>["updateElement"];
|
||||
|
||||
interface TimelineClipDragGestureLifecycleInput {
|
||||
commitGestureRef?: RefObject<() => void>;
|
||||
trackInsertLayoutRef?: RefObject<TimelineTrackInsertLayout | undefined>;
|
||||
lifecycleRef: RefObject<TimelineGestureLifecycle>;
|
||||
sessionEpochRef: RefObject<number>;
|
||||
cancelGestureRef: RefObject<
|
||||
@@ -81,6 +84,8 @@ interface TimelineClipDragGestureLifecycleInput {
|
||||
export function mountTimelineClipDragGestureLifecycle({
|
||||
// The explicit destructuring mirrors the single call site's dependency object by design.
|
||||
// fallow-ignore-next-line code-duplication
|
||||
commitGestureRef,
|
||||
trackInsertLayoutRef,
|
||||
lifecycleRef,
|
||||
sessionEpochRef,
|
||||
cancelGestureRef,
|
||||
@@ -120,6 +125,7 @@ export function mountTimelineClipDragGestureLifecycle({
|
||||
};
|
||||
|
||||
const pointerMatchesGesture = (event: PointerEvent): boolean => {
|
||||
if (draggedClipRef.current?.pointerId === null) return false;
|
||||
const pointerId = lifecycleRef.current.pointerId;
|
||||
return pointerId === null || event.pointerId === pointerId;
|
||||
};
|
||||
@@ -293,6 +299,7 @@ export function mountTimelineClipDragGestureLifecycle({
|
||||
commitDraggedClipMove(drag, {
|
||||
elements: elementsRef.current,
|
||||
trackOrder: trackOrderRef.current,
|
||||
trackInsertLayout: trackInsertLayoutRef?.current,
|
||||
updateElement,
|
||||
onMoveElement: onMoveElementRef.current,
|
||||
onMoveElements: onMoveElementsRef.current,
|
||||
@@ -315,10 +322,12 @@ export function mountTimelineClipDragGestureLifecycle({
|
||||
);
|
||||
};
|
||||
|
||||
const claimActiveGesture = (event: PointerEvent): TimelineGestureCommit | "ignored" | null => {
|
||||
const claimActiveGesture = (
|
||||
event: PointerEvent | null,
|
||||
): TimelineGestureCommit | "ignored" | null => {
|
||||
const lifecycle = lifecycleRef.current;
|
||||
if (lifecycle.phase !== "active") return null;
|
||||
if (!pointerMatchesGesture(event)) return "ignored";
|
||||
if (event && !pointerMatchesGesture(event)) return "ignored";
|
||||
if (lifecycle.sessionEpoch !== sessionEpochRef.current) {
|
||||
cancelGesture();
|
||||
return "ignored";
|
||||
@@ -358,6 +367,12 @@ export function mountTimelineClipDragGestureLifecycle({
|
||||
}
|
||||
};
|
||||
|
||||
if (commitGestureRef)
|
||||
commitGestureRef.current = () => {
|
||||
const claimed = claimActiveGesture(null);
|
||||
if (claimed && claimed !== "ignored") commitClaimedGesture(claimed);
|
||||
};
|
||||
|
||||
const handleWindowPointerUp = (event: PointerEvent) => {
|
||||
if (releasedOutsideWindow(event)) return handleWindowPointerCancel(event);
|
||||
const claimed = claimActiveGesture(event);
|
||||
@@ -411,6 +426,7 @@ export function mountTimelineClipDragGestureLifecycle({
|
||||
return () => {
|
||||
cancelGesture({ updateReact: false });
|
||||
cancelGestureRef.current = () => false;
|
||||
if (commitGestureRef) commitGestureRef.current = () => {};
|
||||
window.removeEventListener("pointermove", handleWindowPointerMove);
|
||||
window.removeEventListener("pointerup", handleWindowPointerUp);
|
||||
window.removeEventListener("pointercancel", handleWindowPointerCancel);
|
||||
|
||||
@@ -7,8 +7,18 @@ import {
|
||||
type DragPreviewContext,
|
||||
} from "./timelineClipDragPreview";
|
||||
import type { DraggedClipState } from "./timelineClipDragTypes";
|
||||
import { commitDraggedClipMove, persistMoveEdits } from "./timelineClipDragCommit";
|
||||
import { LANE_H, RULER_H, TRACKS_TOP_PAD, TRACK_H } from "./timelineLayout";
|
||||
import {
|
||||
commitDraggedClipMove,
|
||||
persistMoveEdits,
|
||||
type TimelineMoveEdit,
|
||||
} from "./timelineClipDragCommit";
|
||||
import {
|
||||
LANE_H,
|
||||
RULER_H,
|
||||
TRACKS_TOP_PAD,
|
||||
TRACK_H,
|
||||
createTimelineRowGeometry,
|
||||
} from "./timelineLayout";
|
||||
import { isMultiDragPassenger } from "./timelineMultiDragPreview";
|
||||
import { resolveMultiDragPreview } from "./timelineProviderStateBuilders";
|
||||
|
||||
@@ -110,6 +120,20 @@ function horizontalDrag(
|
||||
}
|
||||
|
||||
describe("computeDragPreview — plain horizontal drag never arms a phantom insert (BUG 1)", () => {
|
||||
it("opens the physical seam below a zero-padding ruler and keeps it armed inside the new lane", () => {
|
||||
const geometry = createTimelineRowGeometry([0, 1, 2], [104, 48, 48], { top: 0 });
|
||||
const context = { ...ctx(geometry.rowHeights), rowGeometry: geometry };
|
||||
const { drag, clientX } = horizontalDrag(moodboard, 0.5, 2);
|
||||
const top = computeDragPreview(drag, clientX, 24, context);
|
||||
expect(top.insertRow).toBe(0);
|
||||
const between = computeDragPreview(drag, clientX, 128, context);
|
||||
expect(between.insertRow).toBe(1);
|
||||
const inside = computeDragPreview(between, clientX, 150, context);
|
||||
expect(inside.insertRow).toBe(1);
|
||||
const below = computeDragPreview(inside, clientX, 202, context);
|
||||
expect(below.insertRow).toBeNull();
|
||||
});
|
||||
|
||||
it("dragging v-moodboard +2s while grabbing its clip body keeps it a pure time move", () => {
|
||||
const { drag, clientX, clientY } = horizontalDrag(moodboard, 0.5, 2);
|
||||
const next = computeDragPreview(drag, clientX, clientY, ctx());
|
||||
@@ -470,11 +494,10 @@ describe("computeDragPreview — the ghost start is the committed start", () =>
|
||||
expect(committedStart(ghost, tag, rows)).toBe(10);
|
||||
});
|
||||
|
||||
it("the edge of a row is still that row, not a new track", () => {
|
||||
it("a clip aimed at a seam opens a new track at that boundary", () => {
|
||||
for (const edge of [0.02, 0.98, 1.02]) {
|
||||
const ghost = preview(tag, rows, 2, edge);
|
||||
expect(ghost.insertRow).toBeNull();
|
||||
expect(ghost.previewTrack).toBe(Math.floor(edge));
|
||||
expect(ghost.insertRow).toBe(Math.round(edge));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -550,6 +573,23 @@ describe("computeDragPreview — the ghost start is the committed start", () =>
|
||||
});
|
||||
|
||||
describe("computeDragPreview — a group move keeps its shape", () => {
|
||||
function commitPreview(
|
||||
ghost: DraggedClipState,
|
||||
elements: TimelineElement[],
|
||||
selectedKeys: ReadonlySet<string>,
|
||||
) {
|
||||
const onMoveElements = vi.fn<(edits: TimelineMoveEdit[]) => void>();
|
||||
commitDraggedClipMove(ghost, {
|
||||
elements,
|
||||
trackOrder: [0, 1, 2],
|
||||
updateElement: vi.fn(),
|
||||
onMoveElement: vi.fn(),
|
||||
onMoveElements,
|
||||
selectedKeys,
|
||||
});
|
||||
return onMoveElements.mock.calls[0][0];
|
||||
}
|
||||
|
||||
it("never bumps the grabbed clip further left than the group's 0 limit", () => {
|
||||
const a = clip("a", 1, 0.5, 1, 1);
|
||||
const b = clip("b", 2, 7, 2, 1);
|
||||
@@ -563,19 +603,7 @@ describe("computeDragPreview — a group move keeps its shape", () => {
|
||||
selectedKeys,
|
||||
});
|
||||
expect(ghost).toMatchObject({ previewTrack: 0, insertRow: null, previewStart: 10 });
|
||||
const onMoveElements = vi.fn();
|
||||
commitDraggedClipMove(ghost, {
|
||||
elements,
|
||||
trackOrder: [0, 1, 2],
|
||||
updateElement: vi.fn(),
|
||||
onMoveElement: vi.fn(),
|
||||
onMoveElements,
|
||||
selectedKeys,
|
||||
});
|
||||
const edits = onMoveElements.mock.calls[0][0] as Array<{
|
||||
element: TimelineElement;
|
||||
updates: { start: number };
|
||||
}>;
|
||||
const edits = commitPreview(ghost, elements, selectedKeys);
|
||||
const moved = Object.fromEntries(
|
||||
edits.map((e) => [e.element.id, e.updates.start - e.element.start]),
|
||||
);
|
||||
@@ -595,19 +623,7 @@ describe("computeDragPreview — a group move keeps its shape", () => {
|
||||
...ctx(undefined, elements),
|
||||
selectedKeys,
|
||||
});
|
||||
const onMoveElements = vi.fn();
|
||||
commitDraggedClipMove(ghost, {
|
||||
elements,
|
||||
trackOrder: [0, 1, 2],
|
||||
updateElement: vi.fn(),
|
||||
onMoveElement: vi.fn(),
|
||||
onMoveElements,
|
||||
selectedKeys,
|
||||
});
|
||||
const edits = onMoveElements.mock.calls[0][0] as Array<{
|
||||
element: TimelineElement;
|
||||
updates: { start: number; track: number };
|
||||
}>;
|
||||
const edits = commitPreview(ghost, elements, selectedKeys);
|
||||
return Object.fromEntries(edits.map((e) => [e.element.id, e.updates]));
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,12 @@ import {
|
||||
} from "./timelineGroupEditing";
|
||||
import type { TimelineElement } from "../store/playerStore";
|
||||
import { clampToHostStart } from "../store/timelineElement";
|
||||
import { getTimelineRowFromY } from "./timelineLayout";
|
||||
import {
|
||||
CLIP_Y,
|
||||
TRACK_H,
|
||||
getTimelineRowGeometry,
|
||||
type TimelineRowGeometry,
|
||||
} from "./timelineLayout";
|
||||
import { isMusicTrack, isAudioTimelineElement } from "../../utils/timelineInspector";
|
||||
import {
|
||||
TIMELINE_SNAP_PX,
|
||||
@@ -38,6 +43,9 @@ export interface DragPreviewContext {
|
||||
duration: number;
|
||||
trackOrder: number[];
|
||||
rowHeights?: readonly number[];
|
||||
rowGeometry?: TimelineRowGeometry;
|
||||
allowedInsertRows?: ReadonlySet<number>;
|
||||
groupTracks?: ReadonlyMap<number, readonly number[]>;
|
||||
elements: TimelineElement[];
|
||||
selectedKeys: ReadonlySet<string>;
|
||||
buildSnapTargets: BuildSnapTargets;
|
||||
@@ -50,6 +58,33 @@ export interface DragPreviewContext {
|
||||
audioTracks?: ReadonlySet<number>;
|
||||
}
|
||||
|
||||
export function createKeyboardClipDrag(
|
||||
element: TimelineElement,
|
||||
insertRow: number,
|
||||
scrollLeft: number,
|
||||
scrollTop: number,
|
||||
): DraggedClipState {
|
||||
return {
|
||||
pointerId: null,
|
||||
element,
|
||||
insertRow,
|
||||
started: true,
|
||||
previewStart: element.start,
|
||||
previewTrack: element.track,
|
||||
desiredTrack: element.track,
|
||||
originClientX: 0,
|
||||
originClientY: 0,
|
||||
originScrollLeft: scrollLeft,
|
||||
originScrollTop: scrollTop,
|
||||
pointerClientX: 0,
|
||||
pointerClientY: 0,
|
||||
pointerOffsetX: 0,
|
||||
pointerOffsetY: 0,
|
||||
snapTime: null,
|
||||
snapType: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Content-space position for the stable viewport drag actor. */
|
||||
export function getTimelineDragOverlayPosition(
|
||||
drag: DraggedClipState,
|
||||
@@ -73,22 +108,50 @@ function resolveDragMaxStart(scroll: HTMLDivElement | null, pps: number, duratio
|
||||
return Math.max(duration, scroll && pps > 0 ? scroll.scrollWidth / pps : duration);
|
||||
}
|
||||
|
||||
const INSERT_SEAM_PX = CLIP_Y;
|
||||
|
||||
function physicalInsertRow(
|
||||
rowFloat: number,
|
||||
y: number,
|
||||
ctx: Pick<DragPreviewContext, "trackOrder" | "allowedInsertRows">,
|
||||
geometry: TimelineRowGeometry,
|
||||
) {
|
||||
const boundary = Math.round(rowFloat);
|
||||
const nearSeam = Math.abs(y - geometry.getRowTop(boundary)) <= INSERT_SEAM_PX;
|
||||
const row = nearSeam
|
||||
? Math.max(0, Math.min(ctx.trackOrder.length, boundary))
|
||||
: resolveInsertRow(rowFloat, ctx.trackOrder.length);
|
||||
if (row !== null && ctx.allowedInsertRows && !ctx.allowedInsertRows.has(row)) return null;
|
||||
return row;
|
||||
}
|
||||
|
||||
function dragRowAim(
|
||||
drag: DraggedClipState,
|
||||
clientY: number,
|
||||
ctx: DragPreviewContext,
|
||||
geometry: TimelineRowGeometry,
|
||||
) {
|
||||
let y = clientY - (ctx.scroll?.getBoundingClientRect().top ?? 0) + (ctx.scroll?.scrollTop ?? 0);
|
||||
if (drag.started && drag.insertRow !== null) {
|
||||
const top = geometry.getRowTop(drag.insertRow);
|
||||
if (y >= top - INSERT_SEAM_PX && y <= top + TRACK_H + INSERT_SEAM_PX)
|
||||
return { rowFloat: drag.insertRow, insertRow: drag.insertRow };
|
||||
if (y > top + TRACK_H) y -= TRACK_H;
|
||||
}
|
||||
const rowFloat = geometry.getRowFromY(y);
|
||||
return { rowFloat, insertRow: physicalInsertRow(rowFloat, y, ctx, geometry) };
|
||||
}
|
||||
|
||||
/** The drop decision for the pointer's row (see resolveZoneDropPlacement). */
|
||||
function resolveDropPlacement(
|
||||
drag: DraggedClipState,
|
||||
clientY: number,
|
||||
aim: ReturnType<typeof dragRowAim>,
|
||||
previewStart: number,
|
||||
desiredTrack: number,
|
||||
ctx: DragPreviewContext,
|
||||
group: GroupDrag,
|
||||
): { track: number; insertRow: number | null; start: number } {
|
||||
const { scroll, trackOrder, rowHeights, elements } = ctx;
|
||||
const rowFloat = scroll
|
||||
? getTimelineRowFromY(
|
||||
clientY - scroll.getBoundingClientRect().top + scroll.scrollTop,
|
||||
rowHeights,
|
||||
)
|
||||
: 0;
|
||||
const { trackOrder, elements } = ctx;
|
||||
const dragKey = drag.element.key ?? drag.element.id;
|
||||
const audioTracks =
|
||||
ctx.audioTracks ?? new Set(elements.filter(isAudioTimelineElement).map((e) => e.track));
|
||||
@@ -97,7 +160,9 @@ function resolveDropPlacement(
|
||||
audioTracks,
|
||||
elements: group.obstacles,
|
||||
desiredTrack,
|
||||
deliberateInsertRow: resolveInsertRow(rowFloat, trackOrder.length),
|
||||
deliberateInsertRow: aim.insertRow,
|
||||
allowedInsertRows: ctx.allowedInsertRows,
|
||||
groupTracks: ctx.groupTracks,
|
||||
start: previewStart,
|
||||
duration: drag.element.duration,
|
||||
dragKey,
|
||||
@@ -144,13 +209,12 @@ export function computeDragPreview(
|
||||
): DraggedClipState {
|
||||
const { scroll, pps, duration, trackOrder, buildSnapTargets } = ctx;
|
||||
const dragMaxStart = resolveDragMaxStart(scroll, pps, duration);
|
||||
const scrollTop = scroll?.scrollTop ?? drag.originScrollTop;
|
||||
const scrollRectTop = scroll?.getBoundingClientRect().top ?? 0;
|
||||
const originRow = getTimelineRowFromY(
|
||||
drag.originClientY - scrollRectTop + drag.originScrollTop,
|
||||
ctx.rowHeights,
|
||||
);
|
||||
const currentRow = getTimelineRowFromY(clientY - scrollRectTop + scrollTop, ctx.rowHeights);
|
||||
const geometry =
|
||||
ctx.rowGeometry ?? getTimelineRowGeometry(ctx.rowHeights ?? ctx.trackOrder.map(() => TRACK_H));
|
||||
const originRow = geometry.getRowFromY(drag.originClientY - scrollRectTop + drag.originScrollTop);
|
||||
const aim = dragRowAim(drag, clientY, ctx, geometry);
|
||||
const currentRow = aim.rowFloat;
|
||||
// resolveTimelineMove's vertical axis is row indices, which is why the pointer
|
||||
// and scroll pixels are folded into originRow/currentRow above.
|
||||
const nextMove = resolveTimelineMove(
|
||||
@@ -196,7 +260,7 @@ export function computeDragPreview(
|
||||
const origin = drag.element.start;
|
||||
const floored = Math.max(snap.start, group.floor);
|
||||
const previewStart = origin + Math.max(shift.min, Math.min(floored - origin, shift.max));
|
||||
const placement = resolveDropPlacement(drag, clientY, previewStart, nextMove.track, ctx, group);
|
||||
const placement = resolveDropPlacement(drag, aim, previewStart, nextMove.track, ctx, group);
|
||||
const placedShift = placement.start - origin;
|
||||
if (placedShift < shift.min - SHIFT_EPSILON_S || placedShift > shift.max + SHIFT_EPSILON_S) {
|
||||
return { ...drag, started: true };
|
||||
|
||||
@@ -4,8 +4,8 @@ import type { BlockedTimelineEditIntent } from "./timelineEditing";
|
||||
|
||||
/* ── Shared clip-drag state types ───────────────────────────────── */
|
||||
export interface DraggedClipState {
|
||||
/** Pointer captured by the stable viewport coordinator for this gesture. */
|
||||
pointerId: number;
|
||||
/** Pointer captured by the viewport coordinator; null for a keyboard pickup. */
|
||||
pointerId: number | null;
|
||||
element: TimelineElement;
|
||||
originClientX: number;
|
||||
originClientY: number;
|
||||
@@ -26,7 +26,7 @@ export interface DraggedClipState {
|
||||
/**
|
||||
* When non-null, the drop inserts a NEW track at this visual row boundary
|
||||
* (0 = above the top lane, trackOrder.length = below the bottom) instead of
|
||||
* landing on previewTrack. Drives the insertion-line indicator.
|
||||
* landing on previewTrack. Drives the opened preview lane.
|
||||
*/
|
||||
insertRow: number | null;
|
||||
/** Snap target the clip will land on, for the guide highlight. */
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
import type { TimelineElement } from "../store/playerStore";
|
||||
|
||||
/** Audio member tracks mark the audio zone, including members hidden by a group header. */
|
||||
export function timelineAudioRow(
|
||||
order: number[],
|
||||
audioTracks: ReadonlySet<number>,
|
||||
groupTracks?: ReadonlyMap<number, readonly number[]>,
|
||||
): number {
|
||||
return order.findIndex(
|
||||
(track) =>
|
||||
audioTracks.has(track) || groupTracks?.get(track)?.some((member) => audioTracks.has(member)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep a landing track inside the dragged clip's kind-zone: visual clips stay in
|
||||
* the rows ABOVE the first audio lane; audio clips stay AT/BELOW it. Prevents a
|
||||
@@ -31,7 +43,10 @@ export function isInsertAllowedForZone(
|
||||
insertRow: number,
|
||||
audioRow: number,
|
||||
isAudio: boolean,
|
||||
allowedRows?: ReadonlySet<number>,
|
||||
): boolean {
|
||||
if (allowedRows && !allowedRows.has(insertRow)) return false;
|
||||
while (audioRow > 0 && allowedRows && !allowedRows.has(audioRow)) audioRow--;
|
||||
if (audioRow < 0) return true;
|
||||
return isAudio ? insertRow >= audioRow : insertRow <= audioRow;
|
||||
}
|
||||
@@ -52,9 +67,8 @@ function outOfRangeZoneInsertRow(
|
||||
audioRow: number,
|
||||
desired: number,
|
||||
): number {
|
||||
// No lane of this kind yet: fall to the split (audioRow) or the very top.
|
||||
// A visual-only timeline has audioRow -1 (top); an all-audio one has it at 0.
|
||||
if (zoneTracks.length === 0) return audioRow < 0 ? 0 : audioRow;
|
||||
// An empty audio zone opens below visual rows; an empty visual zone opens above audio.
|
||||
if (zoneTracks.length === 0) return audioRow < 0 ? order.length : audioRow;
|
||||
// zoneTracks preserves `order` sequence, so its ends map to the zone boundary
|
||||
// rows: above the zone's min lane → its top boundary, else its bottom.
|
||||
const zoneTop = order.indexOf(zoneTracks[0]);
|
||||
@@ -104,14 +118,31 @@ export function resolveNearestFreeStart(
|
||||
return clear ? origin : best;
|
||||
}
|
||||
|
||||
// Where a dragged clip lands: on the aimed row of its kind, at the nearest free time there. Only an aim outside all
|
||||
// rows, or into a kind with no row yet, opens a track (`insertRow`).
|
||||
function groupHeaderAim(
|
||||
input: Parameters<typeof resolveZoneDropPlacement>[0],
|
||||
):
|
||||
| { kind: "ordinary" | "member"; track: number }
|
||||
| { kind: "origin"; track: number; start: number } {
|
||||
const members = input.groupTracks?.get(input.desiredTrack);
|
||||
if (!members) return { kind: "ordinary", track: input.desiredTrack };
|
||||
const memberTracks = new Set(members);
|
||||
const member = input.order.find(
|
||||
(track) => memberTracks.has(track) && input.audioTracks.has(track) === input.isAudio,
|
||||
);
|
||||
if (member !== undefined) return { kind: "member", track: member };
|
||||
if (!input.origin) throw new Error("Group-header drop requires the clip origin");
|
||||
return { kind: "origin", ...input.origin };
|
||||
}
|
||||
|
||||
// A deliberate seam opens a track; other aims land at the nearest free time on a row of the clip's kind.
|
||||
export function resolveZoneDropPlacement(input: {
|
||||
order: number[];
|
||||
audioTracks: ReadonlySet<number>;
|
||||
elements: TimelineElement[];
|
||||
desiredTrack: number;
|
||||
deliberateInsertRow: number | null;
|
||||
allowedInsertRows?: ReadonlySet<number>;
|
||||
groupTracks?: ReadonlyMap<number, readonly number[]>;
|
||||
start: number;
|
||||
duration: number;
|
||||
dragKey: string;
|
||||
@@ -123,24 +154,33 @@ export function resolveZoneDropPlacement(input: {
|
||||
}): { track: number; insertRow: number | null; start: number } {
|
||||
const { order, audioTracks, elements, desiredTrack, deliberateInsertRow } = input;
|
||||
const { start, duration, dragKey, isAudio, minStart } = input;
|
||||
const audioRow = order.findIndex((t) => audioTracks.has(t));
|
||||
const audioRow = timelineAudioRow(order, audioTracks, input.groupTracks);
|
||||
|
||||
if (
|
||||
deliberateInsertRow !== null &&
|
||||
isInsertAllowedForZone(deliberateInsertRow, audioRow, isAudio)
|
||||
isInsertAllowedForZone(deliberateInsertRow, audioRow, isAudio, input.allowedInsertRows)
|
||||
) {
|
||||
return { track: desiredTrack, insertRow: deliberateInsertRow, start };
|
||||
}
|
||||
|
||||
const desired = clampTrackToZone(desiredTrack, order, audioRow, isAudio);
|
||||
const zoneTracks = order.filter((t) => audioTracks.has(t) === isAudio);
|
||||
const aim = groupHeaderAim(input);
|
||||
if (aim.kind === "origin") return { track: aim.track, start: aim.start, insertRow: null };
|
||||
const firstVisibleAudioRow = order.findIndex((track) => audioTracks.has(track));
|
||||
const zoneBoundary = isAudio ? firstVisibleAudioRow : audioRow;
|
||||
const desired =
|
||||
aim.kind === "member" ? aim.track : clampTrackToZone(aim.track, order, zoneBoundary, isAudio);
|
||||
const zoneTracks = order.filter(
|
||||
(t) => !input.groupTracks?.has(t) && audioTracks.has(t) === isAudio,
|
||||
);
|
||||
// Only when the aim is outside the rows, or the clip's zone has no row yet.
|
||||
if (!zoneTracks.includes(desired)) {
|
||||
const desiredRow = order.indexOf(desired);
|
||||
const insertRow =
|
||||
desiredRow < 0
|
||||
let insertRow =
|
||||
desiredRow < 0 || zoneTracks.length === 0
|
||||
? outOfRangeZoneInsertRow(order, zoneTracks, audioRow, desired)
|
||||
: desiredRow + 1;
|
||||
while (insertRow > 0 && input.allowedInsertRows && !input.allowedInsertRows.has(insertRow))
|
||||
insertRow--;
|
||||
return { track: desired, insertRow, start };
|
||||
}
|
||||
const origin = input.origin?.track === desired ? input.origin.start : null;
|
||||
|
||||
@@ -24,7 +24,7 @@ export function layoutAfterTrackInsert(
|
||||
keys: ReadonlySet<string>;
|
||||
movedStart: (e: TimelineElement) => number;
|
||||
} | null,
|
||||
deps: Pick<DragCommitDeps, "elements" | "trackOrder">,
|
||||
deps: Pick<DragCommitDeps, "elements" | "trackOrder" | "trackInsertLayout">,
|
||||
): {
|
||||
normalized: TimelineElement[];
|
||||
targetTrack: number;
|
||||
@@ -37,18 +37,32 @@ export function layoutAfterTrackInsert(
|
||||
const writableZone = classifyZone(element);
|
||||
const writable = (src: TimelineElement): boolean =>
|
||||
sameSourceFile(src, element) && classifyZone(src) === writableZone;
|
||||
const topologyOrder = [...new Set(elements.filter(writable).map((e) => e.track))].sort(
|
||||
let topologyOrder = [...new Set(elements.filter(writable).map((e) => e.track))].sort(
|
||||
(a, b) => a - b,
|
||||
);
|
||||
const topologyInsertRow = topologyOrder.filter((track) => track < targetTrack).length;
|
||||
const topologyTargetTrack = insertTrackValue(topologyOrder, topologyInsertRow);
|
||||
let topologyInsertRow = topologyOrder.filter((track) => track < targetTrack).length;
|
||||
const grouped = deps.trackInsertLayout;
|
||||
let ranks: Map<number, number> | undefined;
|
||||
if (grouped) {
|
||||
const writableTracks = new Set(topologyOrder);
|
||||
topologyOrder = grouped.trackOrder.filter((track) => writableTracks.has(track));
|
||||
topologyInsertRow = grouped.trackOrder
|
||||
.slice(0, grouped.topologyRows[insertRow])
|
||||
.filter((track) => writableTracks.has(track)).length;
|
||||
ranks = new Map(topologyOrder.map((track, index) => [track, index]));
|
||||
}
|
||||
const topologyTargetTrack = ranks
|
||||
? topologyInsertRow - 0.5
|
||||
: insertTrackValue(topologyOrder, topologyInsertRow);
|
||||
const normalized = normalizeToZones(
|
||||
elements.filter(writable).map((e) => {
|
||||
if (keyOf(e) === editKey) {
|
||||
return { ...e, start: previewStart, track: topologyTargetTrack };
|
||||
}
|
||||
if (multi?.keys.has(keyOf(e))) return { ...e, start: multi.movedStart(e) };
|
||||
return e;
|
||||
const track = ranks ? ranks.get(e.track) : e.track;
|
||||
if (track === undefined) throw new Error("Writable track missing from insertion topology");
|
||||
const start = multi?.keys.has(keyOf(e)) ? multi.movedStart(e) : e.start;
|
||||
return track === e.track && start === e.start ? e : { ...e, start, track };
|
||||
}),
|
||||
);
|
||||
return { normalized, targetTrack, writable };
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { Timeline } from "./Timeline";
|
||||
import type { TimelineProps } from "./TimelineTypes";
|
||||
import { usePlayerStore } from "../store/playerStore";
|
||||
import { TIMELINE_ASSET_MIME } from "../../utils/timelineAssetDrop";
|
||||
|
||||
export async function mountThreeTrackTimeline(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(<Timeline {...props} />));
|
||||
const viewport = host.querySelector<HTMLElement>("[data-timeline-scroll-viewport]")!;
|
||||
const ghost = () => host.querySelector<HTMLElement>('[data-testid="timeline-ghost-lane"]');
|
||||
return { host, root, viewport, ghost };
|
||||
}
|
||||
|
||||
export function dragTimelineFixtureAsset(target: EventTarget, type: string, clientY: number): void {
|
||||
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 target.dispatchEvent(event));
|
||||
}
|
||||
|
||||
export function pointerTimelineFixture(target: EventTarget, type: string, clientY: number): void {
|
||||
act(
|
||||
() =>
|
||||
void target.dispatchEvent(
|
||||
new PointerEvent(type, {
|
||||
bubbles: true,
|
||||
button: 0,
|
||||
pointerId: 1,
|
||||
clientX: 400,
|
||||
clientY,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,23 @@
|
||||
import type { TimelineElement } from "../store/playerStore";
|
||||
|
||||
type UpdateElement = (key: string, updates: Partial<TimelineElement>) => void;
|
||||
const revisionsByUpdater = new WeakMap<UpdateElement, Map<string, number>>();
|
||||
type RevisionScope = "timing" | "membership";
|
||||
const revisionsByUpdater = new WeakMap<UpdateElement, Map<RevisionScope, Map<string, number>>>();
|
||||
|
||||
export function beginTimelineOptimisticGesture(
|
||||
updateElement: UpdateElement,
|
||||
keys: readonly string[],
|
||||
scope: RevisionScope = "timing",
|
||||
): Map<string, number> {
|
||||
let revisions = revisionsByUpdater.get(updateElement);
|
||||
let scopes = revisionsByUpdater.get(updateElement);
|
||||
if (!scopes) {
|
||||
scopes = new Map();
|
||||
revisionsByUpdater.set(updateElement, scopes);
|
||||
}
|
||||
let revisions = scopes.get(scope);
|
||||
if (!revisions) {
|
||||
revisions = new Map();
|
||||
revisionsByUpdater.set(updateElement, revisions);
|
||||
scopes.set(scope, revisions);
|
||||
}
|
||||
const gesture = new Map<string, number>();
|
||||
for (const key of keys) {
|
||||
@@ -25,8 +32,12 @@ export function isLatestTimelineOptimisticGesture(
|
||||
updateElement: UpdateElement,
|
||||
gesture: ReadonlyMap<string, number>,
|
||||
key: string,
|
||||
scope: RevisionScope = "timing",
|
||||
): boolean {
|
||||
return revisionsByUpdater.get(updateElement)?.get(key) === gesture.get(key);
|
||||
return (
|
||||
gesture.has(key) &&
|
||||
revisionsByUpdater.get(updateElement)?.get(scope)?.get(key) === gesture.get(key)
|
||||
);
|
||||
}
|
||||
|
||||
export function rollbackLatestTimelineOptimisticGesture(
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
import { expect, it, vi } from "vitest";
|
||||
import { buildTimelineTrackInsertLayout } from "./timelineTrackInsertLayout";
|
||||
import { computeDragPreview } from "./timelineClipDragPreview";
|
||||
import { commitDraggedClipMove } from "./timelineClipDragCommit";
|
||||
import { timelineAudioRow, resolveZoneDropPlacement } from "./timelineCollision";
|
||||
import { createTimelineRowGeometry } from "./timelineLayout";
|
||||
import type { TimelineElement } from "../store/playerStore";
|
||||
import type { DraggedClipState } from "./timelineClipDragTypes";
|
||||
|
||||
it.each([
|
||||
{ order: [0, 0.5, 1, 3, 2, 4], seam: 4 },
|
||||
{ order: [0, 0.5, 2, 4], seam: 2 },
|
||||
])(
|
||||
"inserts below an expanded or collapsed group block in displayed order ($seam)",
|
||||
async ({ order, seam }) => {
|
||||
const layout = buildTimelineTrackInsertLayout(order, [
|
||||
{ anchorKey: 0.5, memberTracks: [1, 3] },
|
||||
]);
|
||||
const elements: TimelineElement[] = [0, 1, 2, 3, 4].map((track) => ({
|
||||
id: `clip-${track}`,
|
||||
domId: `clip-${track}`,
|
||||
tag: "audio",
|
||||
start: 0,
|
||||
duration: 1,
|
||||
track,
|
||||
audioGroup: track === 1 || track === 3 ? "group" : undefined,
|
||||
}));
|
||||
elements.push({ id: "tail", domId: "tail", tag: "audio", start: 3, duration: 1, track: 4 });
|
||||
const hero = elements[4];
|
||||
const drag: DraggedClipState = {
|
||||
pointerId: 1,
|
||||
element: hero,
|
||||
originClientX: 100,
|
||||
originClientY: 48 + order.indexOf(hero.track) * 48,
|
||||
originScrollLeft: 0,
|
||||
originScrollTop: 0,
|
||||
pointerClientX: 100,
|
||||
pointerClientY: 48 + order.indexOf(hero.track) * 48,
|
||||
pointerOffsetX: 0,
|
||||
pointerOffsetY: 0,
|
||||
previewStart: 0,
|
||||
previewTrack: 4,
|
||||
desiredTrack: 4,
|
||||
insertRow: null,
|
||||
snapTime: null,
|
||||
snapType: null,
|
||||
started: true,
|
||||
};
|
||||
const geometry = createTimelineRowGeometry(
|
||||
order,
|
||||
order.map(() => 48),
|
||||
{ top: 0 },
|
||||
);
|
||||
const context = {
|
||||
scroll: null,
|
||||
pps: 100,
|
||||
duration: 10,
|
||||
trackOrder: order,
|
||||
rowGeometry: geometry,
|
||||
elements,
|
||||
selectedKeys: new Set<string>(),
|
||||
buildSnapTargets: () => [],
|
||||
allowedInsertRows: layout.allowedRows,
|
||||
groupTracks: layout.groupTracks,
|
||||
};
|
||||
const ontoHeader = computeDragPreview(drag, 100, geometry.getRowTop(1) + 24, context);
|
||||
expect(ontoHeader.insertRow).toBeNull();
|
||||
expect(ontoHeader.previewTrack).toBe(order.length === 6 ? 1 : 4);
|
||||
if (order.length === 6) {
|
||||
expect(layout.allowedRows.has(2)).toBe(false);
|
||||
expect(layout.allowedRows.has(3)).toBe(false);
|
||||
const ontoMember = computeDragPreview(drag, 100, 130, context);
|
||||
expect(ontoMember.insertRow).toBeNull();
|
||||
expect(ontoMember.previewTrack).toBe(1);
|
||||
}
|
||||
const preview = computeDragPreview(drag, 100, geometry.getRowTop(seam), context);
|
||||
expect(preview.insertRow).toBe(seam);
|
||||
const moved = new Map(elements.map((element) => [element.id, element]));
|
||||
const persist = vi.fn();
|
||||
commitDraggedClipMove(preview, {
|
||||
elements,
|
||||
trackOrder: order,
|
||||
trackInsertLayout: layout,
|
||||
updateElement: (id, updates) => {
|
||||
moved.set(id, { ...moved.get(id)!, ...updates });
|
||||
},
|
||||
onMoveElements: persist,
|
||||
});
|
||||
await Promise.resolve();
|
||||
expect(persist).toHaveBeenCalledOnce();
|
||||
expect(moved.get("clip-1")?.track).toBe(1);
|
||||
expect(moved.get("clip-3")?.track).toBe(2);
|
||||
expect(moved.get("clip-4")?.track).toBe(3);
|
||||
expect(moved.get("clip-2")?.track).toBe(4);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([{ order: [0, 0.5] }, { order: [0, 0.5, 2] }])(
|
||||
"keeps a collapsed audio group at the zone boundary ($order)",
|
||||
({ order }) => {
|
||||
const layout = buildTimelineTrackInsertLayout(order, [
|
||||
{ anchorKey: 0.5, memberTracks: [1, 3] },
|
||||
]);
|
||||
expect(timelineAudioRow(order, new Set([1, 3, 2]), layout.groupTracks)).toBe(1);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ insertRow: 0, target: 0 },
|
||||
{ insertRow: null, target: 2 },
|
||||
])(
|
||||
"detaches only new-track drops, retaining member-track behavior ($insertRow)",
|
||||
async ({ insertRow, target }) => {
|
||||
const hero: TimelineElement = {
|
||||
id: "hero",
|
||||
domId: "hero",
|
||||
tag: "audio",
|
||||
start: 0,
|
||||
duration: 1,
|
||||
track: 1,
|
||||
audioGroup: "voice",
|
||||
};
|
||||
const member: TimelineElement = {
|
||||
id: "member",
|
||||
domId: "member",
|
||||
tag: "audio",
|
||||
start: 2,
|
||||
duration: 1,
|
||||
track: 2,
|
||||
audioGroup: "voice",
|
||||
};
|
||||
const elements = [hero, member];
|
||||
const order = [0.5, 1, 2];
|
||||
const layout = buildTimelineTrackInsertLayout(order, [
|
||||
{ anchorKey: 0.5, memberTracks: [1, 2] },
|
||||
]);
|
||||
const moved = new Map(elements.map((element) => [element.id, element]));
|
||||
const persist = vi.fn();
|
||||
commitDraggedClipMove(
|
||||
{
|
||||
pointerId: null,
|
||||
element: hero,
|
||||
insertRow,
|
||||
started: true,
|
||||
originClientX: 0,
|
||||
originClientY: 0,
|
||||
originScrollLeft: 0,
|
||||
originScrollTop: 0,
|
||||
pointerClientX: 0,
|
||||
pointerClientY: 0,
|
||||
pointerOffsetX: 0,
|
||||
pointerOffsetY: 0,
|
||||
previewStart: 0,
|
||||
previewTrack: target,
|
||||
desiredTrack: target,
|
||||
snapTime: null,
|
||||
snapType: null,
|
||||
},
|
||||
{
|
||||
elements,
|
||||
trackOrder: order,
|
||||
trackInsertLayout: layout,
|
||||
onMoveElements: persist,
|
||||
updateElement: (id, updates) => {
|
||||
moved.set(id, { ...moved.get(id)!, ...updates });
|
||||
},
|
||||
},
|
||||
);
|
||||
await Promise.resolve();
|
||||
expect(moved.get("hero")?.audioGroup).toBe(insertRow === null ? "voice" : undefined);
|
||||
expect(moved.get("hero")?.track).toBe(target);
|
||||
expect(moved.get("member")?.audioGroup).toBe("voice");
|
||||
const edit = persist.mock.calls[0][0].find(
|
||||
(edit: { element: TimelineElement }) => edit.element.id === "hero",
|
||||
);
|
||||
expect(edit.updates.audioGroup).toBe(insertRow === null ? undefined : null);
|
||||
},
|
||||
);
|
||||
|
||||
it("creates the first visual lane above an all-audio timeline", () => {
|
||||
expect(
|
||||
resolveZoneDropPlacement({
|
||||
order: [0, 1],
|
||||
audioTracks: new Set([0, 1]),
|
||||
elements: [],
|
||||
desiredTrack: 0,
|
||||
deliberateInsertRow: null,
|
||||
start: 2,
|
||||
duration: 1,
|
||||
dragKey: "new-visual",
|
||||
isAudio: false,
|
||||
}),
|
||||
).toEqual({ track: 0, insertRow: 0, start: 2 });
|
||||
});
|
||||
|
||||
it("keeps audible-video groups in the visual zone for header drops and edge inserts", () => {
|
||||
const order = [0, 0.5, 1, 2];
|
||||
const layout = buildTimelineTrackInsertLayout(order, [{ anchorKey: 0.5, memberTracks: [1] }]);
|
||||
const input = {
|
||||
order,
|
||||
audioTracks: new Set<number>(),
|
||||
groupTracks: layout.groupTracks,
|
||||
allowedInsertRows: layout.allowedRows,
|
||||
elements: [],
|
||||
desiredTrack: 0.5,
|
||||
deliberateInsertRow: null,
|
||||
start: 2,
|
||||
duration: 1,
|
||||
dragKey: "video",
|
||||
isAudio: false,
|
||||
origin: { track: 2, start: 2 },
|
||||
};
|
||||
expect(timelineAudioRow(order, input.audioTracks, layout.groupTracks)).toBe(-1);
|
||||
expect(resolveZoneDropPlacement(input)).toEqual({ track: 1, insertRow: null, start: 2 });
|
||||
expect(resolveZoneDropPlacement({ ...input, deliberateInsertRow: 3 })).toEqual({
|
||||
track: 0.5,
|
||||
insertRow: 3,
|
||||
start: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ isAudio: false, target: 1 },
|
||||
{ isAudio: true, target: 3 },
|
||||
])(
|
||||
"maps a mixed-kind group header to its first visible member of the clip kind ($isAudio)",
|
||||
({ isAudio, target }) => {
|
||||
const order = [0, 0.5, 1, 3, 2];
|
||||
const layout = buildTimelineTrackInsertLayout(order, [
|
||||
{ anchorKey: 0.5, memberTracks: [1, 3] },
|
||||
]);
|
||||
expect(
|
||||
resolveZoneDropPlacement({
|
||||
order,
|
||||
audioTracks: new Set([3]),
|
||||
groupTracks: layout.groupTracks,
|
||||
allowedInsertRows: layout.allowedRows,
|
||||
elements: [],
|
||||
desiredTrack: 0.5,
|
||||
deliberateInsertRow: null,
|
||||
start: 2,
|
||||
duration: 1,
|
||||
dragKey: "clip",
|
||||
isAudio,
|
||||
origin: { track: 2, start: 2 },
|
||||
}),
|
||||
).toEqual({ track: target, insertRow: null, start: 2 });
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { TimelineTrackGroupInfo } from "./useTimelineTrackDerivations";
|
||||
import { timelineAudioRow, isInsertAllowedForZone } from "./timelineCollision";
|
||||
|
||||
export function timelineTrackOrderChanged(
|
||||
previous: readonly number[],
|
||||
current: readonly number[],
|
||||
): boolean {
|
||||
return current.length !== previous.length || current.some((key, row) => key !== previous[row]);
|
||||
}
|
||||
|
||||
export interface TimelineTrackInsertLayout {
|
||||
allowedRows: ReadonlySet<number>;
|
||||
trackOrder: number[];
|
||||
topologyRows: number[];
|
||||
groupTracks: ReadonlyMap<number, readonly number[]>;
|
||||
}
|
||||
|
||||
export function keyboardPickupInsertRow(
|
||||
row: number,
|
||||
allowed: ReadonlySet<number> | undefined,
|
||||
): number {
|
||||
while (row > 0 && allowed && !allowed.has(row)) row--;
|
||||
return row;
|
||||
}
|
||||
|
||||
export function nextKeyboardInsertRow(input: {
|
||||
current: number;
|
||||
step: -1 | 1;
|
||||
order: number[];
|
||||
layout: TimelineTrackInsertLayout | undefined;
|
||||
audioTracks: ReadonlySet<number>;
|
||||
isAudio: boolean;
|
||||
}): number | null {
|
||||
const { current, step, order, layout, audioTracks, isAudio } = input;
|
||||
let row = Math.max(0, Math.min(order.length, current + step));
|
||||
const allowed = layout?.allowedRows;
|
||||
while (allowed?.has(row) === false) {
|
||||
row += step;
|
||||
if (row < 0 || row > order.length) return null;
|
||||
}
|
||||
const audioRow = timelineAudioRow(order, audioTracks, layout?.groupTracks);
|
||||
return isInsertAllowedForZone(row, audioRow, isAudio, allowed) ? row : null;
|
||||
}
|
||||
|
||||
/** Groups own one contiguous insertion block, including their collapsed members. */
|
||||
export function buildTimelineTrackInsertLayout(
|
||||
order: number[],
|
||||
groups: readonly Pick<TimelineTrackGroupInfo, "anchorKey" | "memberTracks">[],
|
||||
): TimelineTrackInsertLayout {
|
||||
const allowedRows = new Set(Array.from({ length: order.length + 1 }, (_, row) => row));
|
||||
const anchors = new Map(groups.map((group) => [group.anchorKey, group.memberTracks]));
|
||||
const positions = new Map(order.map((key, row) => [key, row]));
|
||||
for (const group of groups) {
|
||||
const anchor = positions.get(group.anchorKey);
|
||||
if (anchor === undefined) continue;
|
||||
const end = Math.max(anchor, ...group.memberTracks.map((key) => positions.get(key) ?? anchor));
|
||||
for (let row = anchor + 1; row <= end; row++) allowedRows.delete(row);
|
||||
}
|
||||
const tracks = new Set<number>();
|
||||
const topologyRows = [0];
|
||||
for (const key of order) {
|
||||
for (const track of anchors.get(key) ?? [key]) tracks.add(track);
|
||||
topologyRows.push(tracks.size);
|
||||
}
|
||||
return {
|
||||
allowedRows,
|
||||
trackOrder: [...tracks],
|
||||
topologyRows,
|
||||
groupTracks: anchors,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
// @vitest-environment happy-dom
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { useTimelineClipDrag } from "./useTimelineClipDrag";
|
||||
import { createTimelineRowGeometry } from "./timelineLayout";
|
||||
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
||||
import { configureTimelineTestViewport } from "./timelineTestViewport";
|
||||
import { buildTimelineTrackInsertLayout } from "./timelineTrackInsertLayout";
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
afterEach(() => {
|
||||
usePlayerStore.getState().reset();
|
||||
document.body.replaceChildren();
|
||||
});
|
||||
|
||||
function mount(order = [0, 1, 2]) {
|
||||
const elements: TimelineElement[] = [
|
||||
{ id: "top", tag: "div", start: 0, duration: 3, track: 0 },
|
||||
{ id: "hero", domId: "hero", tag: "div", start: 2, duration: 3, track: 1 },
|
||||
{ id: "tail", tag: "div", start: 6, duration: 3, track: 1 },
|
||||
{ id: "bottom", tag: "div", start: 0, duration: 3, track: 2 },
|
||||
];
|
||||
const groups = order.includes(0.5) ? [{ anchorKey: 0.5, memberTracks: [1, 3] }] : [];
|
||||
if (groups.length) {
|
||||
for (const element of elements) element.tag = "audio";
|
||||
elements[1].audioGroup = "voice";
|
||||
elements[2].audioGroup = "voice";
|
||||
elements.push({
|
||||
id: "member",
|
||||
tag: "audio",
|
||||
start: 0,
|
||||
duration: 2,
|
||||
track: 3,
|
||||
audioGroup: "voice",
|
||||
});
|
||||
elements.push({ id: "extra", tag: "audio", start: 0, duration: 2, track: 4 });
|
||||
}
|
||||
for (const element of elements) element.domId = element.id;
|
||||
usePlayerStore.getState().setElements(elements);
|
||||
const scroll = document.createElement("div");
|
||||
configureTimelineTestViewport(scroll, 400);
|
||||
const clip = document.createElement("div");
|
||||
clip.dataset.elId = "hero";
|
||||
clip.dataset.timelineFocusId = "clip-hero";
|
||||
clip.tabIndex = 0;
|
||||
scroll.append(clip);
|
||||
document.body.append(scroll);
|
||||
const host = document.createElement("div");
|
||||
const root = createRoot(host);
|
||||
const onMoveElements = vi.fn();
|
||||
const trackOrderRef = { current: order };
|
||||
const trackInsertLayoutRef = { current: buildTimelineTrackInsertLayout(order, groups) };
|
||||
let state: ReturnType<typeof useTimelineClipDrag>;
|
||||
function Probe() {
|
||||
state = useTimelineClipDrag({
|
||||
scrollRef: { current: scroll },
|
||||
ppsRef: { current: 100 },
|
||||
durationRef: { current: 12 },
|
||||
trackOrderRef,
|
||||
trackInsertLayoutRef,
|
||||
rowGeometryRef: {
|
||||
current: createTimelineRowGeometry(
|
||||
trackOrderRef.current,
|
||||
trackOrderRef.current.map(() => 48),
|
||||
{ top: 0 },
|
||||
),
|
||||
},
|
||||
onMoveElement: vi.fn(),
|
||||
onMoveElements,
|
||||
setShowPopover: () => {},
|
||||
setRangeSelectionRef: { current: null },
|
||||
});
|
||||
return null;
|
||||
}
|
||||
act(() => root.render(<Probe />));
|
||||
clip.focus();
|
||||
const key = (key: string) =>
|
||||
act(() => {
|
||||
clip.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }));
|
||||
});
|
||||
return {
|
||||
key,
|
||||
clip,
|
||||
onMoveElements,
|
||||
root,
|
||||
elements,
|
||||
changeOrder(next: number[]) {
|
||||
trackOrderRef.current = next;
|
||||
trackInsertLayoutRef.current = buildTimelineTrackInsertLayout(next, groups);
|
||||
act(() => root.render(<Probe />));
|
||||
},
|
||||
get drag() {
|
||||
return state.draggedClip;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function expectCanceledPickup(view: ReturnType<typeof mount>) {
|
||||
expect(view.drag).toBeNull();
|
||||
expect(view.onMoveElements).not.toHaveBeenCalled();
|
||||
expect(usePlayerStore.getState().elements).toEqual(view.elements);
|
||||
}
|
||||
|
||||
it("cancels pickup when collapsing a group changes the displayed seam", () => {
|
||||
const view = mount([0, 0.5, 1, 3, 2, 4]);
|
||||
view.key(" ");
|
||||
view.key("ArrowDown");
|
||||
expect(view.drag?.insertRow).toBe(4);
|
||||
view.changeOrder([0, 0.5, 2, 4]);
|
||||
expect(view.drag).toBeNull();
|
||||
view.key("Enter");
|
||||
expectCanceledPickup(view);
|
||||
act(() => view.root.unmount());
|
||||
});
|
||||
|
||||
it("picks up with Space, chooses a seam, then commits at that index once with Enter", async () => {
|
||||
const view = mount();
|
||||
view.key(" ");
|
||||
expect(view.drag).toMatchObject({
|
||||
pointerId: null,
|
||||
started: true,
|
||||
insertRow: 1,
|
||||
previewStart: 2,
|
||||
});
|
||||
view.key("ArrowUp");
|
||||
expect(view.drag?.insertRow).toBe(0);
|
||||
await act(async () => view.key("Enter"));
|
||||
expect(view.drag).toBeNull();
|
||||
expect(view.onMoveElements).toHaveBeenCalledTimes(1);
|
||||
const moved = usePlayerStore.getState().elements;
|
||||
expect(moved.find((e) => e.id === "hero")?.track).toBe(0);
|
||||
expect(moved.find((e) => e.id === "top")?.track).toBe(1);
|
||||
view.key("Enter");
|
||||
expect(view.onMoveElements).toHaveBeenCalledTimes(1);
|
||||
act(() => view.root.unmount());
|
||||
});
|
||||
|
||||
it("Escape cancels the opened lane without moving a clip or adding a track", () => {
|
||||
const view = mount();
|
||||
view.key(" ");
|
||||
view.key("ArrowDown");
|
||||
expect(view.drag?.insertRow).toBe(2);
|
||||
view.key("Escape");
|
||||
expectCanceledPickup(view);
|
||||
act(() => view.root.unmount());
|
||||
});
|
||||
|
||||
it("leaves Space on a nested fade slider to the slider", () => {
|
||||
const view = mount();
|
||||
const slider = document.createElement("div");
|
||||
slider.setAttribute("role", "slider");
|
||||
slider.tabIndex = 0;
|
||||
view.clip.append(slider);
|
||||
slider.focus();
|
||||
const event = new KeyboardEvent("keydown", { key: " ", bubbles: true, cancelable: true });
|
||||
act(() => {
|
||||
slider.dispatchEvent(event);
|
||||
});
|
||||
expect(view.drag).toBeNull();
|
||||
expect(event.defaultPrevented).toBe(false);
|
||||
act(() => view.root.unmount());
|
||||
});
|
||||
@@ -16,6 +16,7 @@ import type { StackingPatch } from "./timelineStackingSync";
|
||||
import type { TimelineEditCallbacks } from "./timelineCallbacks";
|
||||
import {
|
||||
computeDragPreview,
|
||||
createKeyboardClipDrag,
|
||||
computeResizePreview,
|
||||
trimPreviewTime,
|
||||
previewGroupResize,
|
||||
@@ -29,6 +30,19 @@ import type {
|
||||
import { getTimelineElementIndexes } from "../lib/timelineElementIndexes";
|
||||
import { dropMisalignedTrimPartners, linkedGestureKeys } from "./audioClipLink";
|
||||
import { isLinkedSelectionOn } from "../../utils/linkedClipPreferences";
|
||||
import { useTimelineClipCapabilities } from "./timelineReadOnly";
|
||||
import { timelineClipFocusId } from "./timelineNavigationIdentity";
|
||||
import {
|
||||
handleClipPickupKeyboardEvent,
|
||||
scrollKeyboardInsertRow,
|
||||
timelineKeyboardEventTarget,
|
||||
} from "./useTimelineKeyboardActor";
|
||||
import {
|
||||
timelineTrackOrderChanged,
|
||||
nextKeyboardInsertRow,
|
||||
keyboardPickupInsertRow,
|
||||
type TimelineTrackInsertLayout,
|
||||
} from "./timelineTrackInsertLayout";
|
||||
import type { TimelineRowGeometry } from "./timelineLayout";
|
||||
import {
|
||||
mountTimelineClipDragGestureLifecycle,
|
||||
@@ -50,17 +64,10 @@ interface UseTimelineClipDragInput {
|
||||
ppsRef: React.RefObject<number>;
|
||||
durationRef: React.RefObject<number>;
|
||||
trackOrderRef: React.RefObject<number[]>;
|
||||
trackInsertLayoutRef?: React.RefObject<TimelineTrackInsertLayout | undefined>;
|
||||
rowGeometryRef?: React.RefObject<TimelineRowGeometry>;
|
||||
onMoveElement?: (
|
||||
element: TimelineElement,
|
||||
updates: Pick<TimelineElement, "start" | "track">,
|
||||
) => Promise<void> | void;
|
||||
onMoveElements?: (
|
||||
edits: Array<{
|
||||
element: TimelineElement;
|
||||
updates: Pick<TimelineElement, "start" | "track">;
|
||||
}>,
|
||||
) => Promise<void> | void;
|
||||
onMoveElement?: TimelineEditCallbacks["onMoveElement"];
|
||||
onMoveElements?: TimelineEditCallbacks["onMoveElements"];
|
||||
onResizeElement?: (
|
||||
element: TimelineElement,
|
||||
updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
|
||||
@@ -92,6 +99,7 @@ export function useTimelineClipDrag({
|
||||
ppsRef,
|
||||
durationRef,
|
||||
trackOrderRef,
|
||||
trackInsertLayoutRef,
|
||||
rowGeometryRef,
|
||||
onMoveElement,
|
||||
onMoveElements,
|
||||
@@ -107,6 +115,8 @@ export function useTimelineClipDrag({
|
||||
refreshAfterLaneMove,
|
||||
sessionEpoch = 0,
|
||||
}: UseTimelineClipDragInput) {
|
||||
const getClipCapabilities = useTimelineClipCapabilities();
|
||||
const commitGestureRef = useRef<() => void>(() => {});
|
||||
const updateElement = usePlayerStore((s) => s.updateElement);
|
||||
const rawBeatTimes = usePlayerStore((s) => s.beatAnalysis?.beatTimes ?? EMPTY_BEAT_TIMES);
|
||||
const rawBeatStrengths = usePlayerStore((s) => s.beatAnalysis?.beatStrengths ?? EMPTY_BEAT_TIMES);
|
||||
@@ -207,7 +217,7 @@ export function useTimelineClipDrag({
|
||||
const cancelGestureRef = useRef<
|
||||
(options?: { updateReact?: boolean; suppressClick?: boolean }) => boolean
|
||||
>(() => false);
|
||||
const beginGesture = useCallback((kind: TimelineGestureKind, pointerId: number) => {
|
||||
const beginGesture = useCallback((kind: TimelineGestureKind, pointerId: number | null) => {
|
||||
if (lifecycleRef.current.phase === "active") cancelGestureRef.current();
|
||||
lifecycleRef.current = {
|
||||
kind,
|
||||
@@ -310,14 +320,25 @@ export function useTimelineClipDrag({
|
||||
pps: ppsRef.current,
|
||||
duration: durationRef.current,
|
||||
trackOrder: trackOrderRef.current,
|
||||
allowedInsertRows: trackInsertLayoutRef?.current?.allowedRows,
|
||||
groupTracks: trackInsertLayoutRef?.current?.groupTracks,
|
||||
rowHeights: rowGeometryRef?.current.rowHeights,
|
||||
rowGeometry: rowGeometryRef?.current,
|
||||
elements: elementsRef.current,
|
||||
selectedKeys: gestureSelectedKeysRef.current,
|
||||
buildSnapTargets,
|
||||
audioTracks: dragAudioTracksRef.current,
|
||||
});
|
||||
},
|
||||
[scrollRef, ppsRef, durationRef, trackOrderRef, rowGeometryRef, buildSnapTargets],
|
||||
[
|
||||
scrollRef,
|
||||
ppsRef,
|
||||
durationRef,
|
||||
trackOrderRef,
|
||||
trackInsertLayoutRef,
|
||||
rowGeometryRef,
|
||||
buildSnapTargets,
|
||||
],
|
||||
);
|
||||
|
||||
// Recompute the trim preview for a pointer x. Shared by the pointermove resize
|
||||
@@ -445,6 +466,8 @@ export function useTimelineClipDrag({
|
||||
|
||||
useMountEffect(() =>
|
||||
mountTimelineClipDragGestureLifecycle({
|
||||
trackInsertLayoutRef,
|
||||
commitGestureRef,
|
||||
onStackingPatchesRef,
|
||||
refreshAfterLaneMoveRef,
|
||||
readZIndexRef,
|
||||
@@ -479,10 +502,91 @@ export function useTimelineClipDrag({
|
||||
}),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const movePickup = (drag: DraggedClipState, step: -1 | 1, viewport: HTMLDivElement | null) => {
|
||||
const audioTracks = getTimelineElementIndexes(elementsRef.current).audioTracks;
|
||||
const row = nextKeyboardInsertRow({
|
||||
current: drag.insertRow ?? 0,
|
||||
step,
|
||||
order: trackOrderRef.current,
|
||||
layout: trackInsertLayoutRef?.current,
|
||||
audioTracks,
|
||||
isAudio: audioTracks.has(drag.element.track),
|
||||
});
|
||||
if (row === null) return;
|
||||
publishDraggedClip({ ...drag, insertRow: row });
|
||||
if (viewport && rowGeometryRef)
|
||||
scrollKeyboardInsertRow(viewport, rowGeometryRef.current, row);
|
||||
};
|
||||
const activePickup = (
|
||||
event: KeyboardEvent,
|
||||
drag: DraggedClipState,
|
||||
viewport: HTMLDivElement | null,
|
||||
) => {
|
||||
handleClipPickupKeyboardEvent(event, {
|
||||
move: (step) => movePickup(drag, step, viewport),
|
||||
commit: commitGestureRef.current,
|
||||
cancel: cancelGestureRef.current,
|
||||
focus: () =>
|
||||
usePlayerStore
|
||||
.getState()
|
||||
.requestTimelineFocus(timelineClipFocusId(drag.element.key ?? drag.element.id)),
|
||||
});
|
||||
};
|
||||
const pickup = (event: KeyboardEvent, viewport: HTMLDivElement) => {
|
||||
const target = timelineKeyboardEventTarget(event.target, viewport);
|
||||
const key = target?.dataset.elId;
|
||||
if (key === undefined || !onMoveElementRef.current) return;
|
||||
const element = getTimelineElementIndexes(elementsRef.current).byKey.get(key);
|
||||
if (!element || !getClipCapabilities(element).canMove) return;
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
setShowPopover(false);
|
||||
setRangeSelectionRef.current?.(null);
|
||||
const row = keyboardPickupInsertRow(
|
||||
trackOrderRef.current.indexOf(element.track),
|
||||
trackInsertLayoutRef?.current?.allowedRows,
|
||||
);
|
||||
setDraggedClip(createKeyboardClipDrag(element, row, viewport.scrollLeft, viewport.scrollTop));
|
||||
};
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
const drag = draggedClipRef.current;
|
||||
const viewport = scrollRef.current;
|
||||
if (drag) {
|
||||
if (drag.pointerId === null) activePickup(event, drag, viewport);
|
||||
return;
|
||||
}
|
||||
if (resizingClipRef.current) return;
|
||||
if (event.key !== " " || event.repeat) return;
|
||||
if (viewport) pickup(event, viewport);
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown, true);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown, true);
|
||||
}, [
|
||||
getClipCapabilities,
|
||||
publishDraggedClip,
|
||||
rowGeometryRef,
|
||||
scrollRef,
|
||||
setDraggedClip,
|
||||
setRangeSelectionRef,
|
||||
setShowPopover,
|
||||
trackOrderRef,
|
||||
trackInsertLayoutRef,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
cancelGestureRef.current();
|
||||
}, [sessionEpoch]);
|
||||
|
||||
const previousOrderRef = useRef(trackOrderRef.current);
|
||||
useEffect(() => {
|
||||
const order = trackOrderRef.current;
|
||||
const previous = previousOrderRef.current;
|
||||
previousOrderRef.current = order;
|
||||
if (draggedClipRef.current && timelineTrackOrderChanged(previous, order))
|
||||
cancelGestureRef.current();
|
||||
});
|
||||
|
||||
return {
|
||||
draggedClip,
|
||||
setDraggedClip,
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
// @vitest-environment happy-dom
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { expect, it } from "vitest";
|
||||
import { useTimelineDisplayLayout } from "./useTimelineTrackLayout";
|
||||
import { createTimelineRowGeometry } from "./timelineLayout";
|
||||
import type { DraggedClipState } from "./timelineClipDragTypes";
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
it("opens one full lane at the insertion index without changing committed tracks", () => {
|
||||
const order = [7, 3, 9];
|
||||
const geometry = createTimelineRowGeometry(order, [48, 104, 48], { top: 0 });
|
||||
const host = document.createElement("div");
|
||||
const root = createRoot(host);
|
||||
const drag: DraggedClipState = {
|
||||
pointerId: 1,
|
||||
element: { id: "clip", tag: "div", start: 1, duration: 2, track: 9 },
|
||||
originClientX: 0,
|
||||
originClientY: 0,
|
||||
originScrollLeft: 0,
|
||||
originScrollTop: 0,
|
||||
pointerClientX: 0,
|
||||
pointerClientY: 0,
|
||||
pointerOffsetX: 0,
|
||||
pointerOffsetY: 0,
|
||||
previewStart: 1,
|
||||
previewTrack: 3,
|
||||
insertRow: 1,
|
||||
snapTime: null,
|
||||
snapType: null,
|
||||
started: true,
|
||||
};
|
||||
function Probe({ active }: { active: DraggedClipState | null }) {
|
||||
const layout = useTimelineDisplayLayout(active, order, geometry);
|
||||
return (
|
||||
<output>
|
||||
{JSON.stringify({
|
||||
order: layout.displayTrackOrder,
|
||||
pinned: layout.draggedRowKey,
|
||||
heights: layout.displayRowHeights,
|
||||
below: layout.rowGeometry.getRowTop(2),
|
||||
})}
|
||||
</output>
|
||||
);
|
||||
}
|
||||
act(() => root.render(<Probe active={drag} />));
|
||||
const seen = JSON.parse(host.textContent!);
|
||||
expect(seen.order).toHaveLength(4);
|
||||
expect(seen.pinned).toBe(-1);
|
||||
expect(seen.order.filter((key: number) => order.includes(key))).toEqual(order);
|
||||
expect(order.includes(seen.order[1])).toBe(false);
|
||||
expect(seen.heights).toEqual([48, 48, 104, 48]);
|
||||
expect(seen.below).toBe(120);
|
||||
expect(order).toEqual([7, 3, 9]);
|
||||
act(() => root.render(<Probe active={null} />));
|
||||
expect(JSON.parse(host.textContent!).order).toEqual(order);
|
||||
act(() => root.unmount());
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useMemo, type FocusEvent, type KeyboardEvent, type RefObject } from "react";
|
||||
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
||||
import type { TimelineRowGeometry } from "./timelineLayout";
|
||||
import { RULER_H, TRACK_H, type TimelineRowGeometry } from "./timelineLayout";
|
||||
import {
|
||||
isTimelineNavigationKey,
|
||||
locateTimelineLogicalTarget,
|
||||
@@ -17,14 +17,92 @@ interface TimelineKeyboardActorInput {
|
||||
onDrillDown?: (element: TimelineElement) => void;
|
||||
}
|
||||
|
||||
function eventTarget(event: FocusEvent | KeyboardEvent): HTMLElement | null {
|
||||
if (!(event.target instanceof Element)) return null;
|
||||
type ClipPickupAction =
|
||||
| { kind: "move"; step: -1 | 1 }
|
||||
| { kind: "commit" }
|
||||
| { kind: "cancel" }
|
||||
| { kind: "focus" }
|
||||
| { kind: "consume" };
|
||||
|
||||
function clipPickupKeyboardAction(event: {
|
||||
key: string;
|
||||
repeat: boolean;
|
||||
}): ClipPickupAction | null {
|
||||
switch (event.key) {
|
||||
case "ArrowUp":
|
||||
return { kind: "move", step: -1 };
|
||||
case "ArrowDown":
|
||||
return { kind: "move", step: 1 };
|
||||
case "Enter":
|
||||
return { kind: event.repeat ? "focus" : "commit" };
|
||||
case "Escape":
|
||||
return { kind: "cancel" };
|
||||
case " ":
|
||||
return { kind: "consume" };
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function handleClipPickupKeyboardEvent(
|
||||
event: globalThis.KeyboardEvent,
|
||||
actions: {
|
||||
move: (step: -1 | 1) => void;
|
||||
commit: () => void;
|
||||
cancel: () => void;
|
||||
focus: () => void;
|
||||
},
|
||||
): void {
|
||||
const action = clipPickupKeyboardAction(event);
|
||||
if (!action) return;
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
switch (action.kind) {
|
||||
case "move":
|
||||
actions.move(action.step);
|
||||
break;
|
||||
case "commit":
|
||||
actions.commit();
|
||||
actions.focus();
|
||||
break;
|
||||
case "cancel":
|
||||
actions.cancel();
|
||||
actions.focus();
|
||||
break;
|
||||
case "focus":
|
||||
actions.focus();
|
||||
break;
|
||||
case "consume":
|
||||
break;
|
||||
default: {
|
||||
const unreachable: never = action;
|
||||
throw new Error(`Unknown pickup action: ${unreachable}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function scrollKeyboardInsertRow(
|
||||
viewport: HTMLDivElement,
|
||||
geometry: TimelineRowGeometry,
|
||||
row: number,
|
||||
): void {
|
||||
const top = geometry.getRowTop(row);
|
||||
if (top < viewport.scrollTop + RULER_H) viewport.scrollTop = Math.max(0, top - RULER_H);
|
||||
else if (top + TRACK_H > viewport.scrollTop + viewport.clientHeight)
|
||||
viewport.scrollTop = top + TRACK_H - viewport.clientHeight;
|
||||
}
|
||||
|
||||
export function timelineKeyboardEventTarget(
|
||||
target: EventTarget | null,
|
||||
container: HTMLElement,
|
||||
): HTMLElement | null {
|
||||
if (!(target instanceof Element)) return null;
|
||||
// Header actions stay native Tab stops because they have no row-level shortcut.
|
||||
// The nearest interactive ancestor wins so their events never masquerade as row events.
|
||||
const target = event.target.closest<HTMLElement>(
|
||||
const nearest = target.closest<HTMLElement>(
|
||||
"button, input, select, textarea, a[href], [contenteditable], [role='slider'], [data-timeline-focus-id]",
|
||||
);
|
||||
return target?.dataset.timelineFocusId && event.currentTarget.contains(target) ? target : null;
|
||||
return nearest?.dataset.timelineFocusId && container.contains(nearest) ? nearest : null;
|
||||
}
|
||||
|
||||
function viewportPageSize(
|
||||
@@ -87,7 +165,8 @@ export function useTimelineKeyboardActor({
|
||||
|
||||
const onFocus = useCallback(
|
||||
(event: FocusEvent<HTMLElement>) => {
|
||||
const id = eventTarget(event)?.dataset.timelineFocusId;
|
||||
const id = timelineKeyboardEventTarget(event.target, event.currentTarget)?.dataset
|
||||
.timelineFocusId;
|
||||
if (id && id !== focusedTargetId) usePlayerStore.getState().requestTimelineFocus(id);
|
||||
},
|
||||
// ponytail: This closure must see the current id or coordinator-driven focus bumps the nonce twice.
|
||||
@@ -98,7 +177,7 @@ export function useTimelineKeyboardActor({
|
||||
// One handler owns navigation, context-menu, and disclosure keyboard semantics.
|
||||
// fallow-ignore-next-line complexity
|
||||
(event: KeyboardEvent<HTMLElement>) => {
|
||||
const targetElement = eventTarget(event);
|
||||
const targetElement = timelineKeyboardEventTarget(event.target, event.currentTarget);
|
||||
const id = targetElement?.dataset.timelineFocusId;
|
||||
if (!targetElement || !id) return;
|
||||
const located = locateTimelineLogicalTarget(logicalRows, id);
|
||||
|
||||
@@ -155,6 +155,7 @@ export function useTimelineProviderState({
|
||||
trackStyles,
|
||||
trackOrder,
|
||||
trackOrderRef,
|
||||
trackInsertLayoutRef,
|
||||
laneCounts,
|
||||
rowGeometry,
|
||||
rowGeometryRef,
|
||||
@@ -213,6 +214,7 @@ export function useTimelineProviderState({
|
||||
ppsRef,
|
||||
durationRef,
|
||||
trackOrderRef,
|
||||
trackInsertLayoutRef,
|
||||
rowGeometryRef,
|
||||
onMoveElement: pinnedOnMoveElement,
|
||||
onMoveElements: pinnedOnMoveElements,
|
||||
@@ -288,7 +290,7 @@ export function useTimelineProviderState({
|
||||
allowHorizontal: zoomMode === "manual",
|
||||
viewport,
|
||||
sessionEpoch,
|
||||
draggedRowKey: draggedClip?.started ? draggedClip.previewTrack : undefined,
|
||||
draggedRowKey: displayLayout.draggedRowKey,
|
||||
resizingElementIds,
|
||||
clipContextMenuRowKey: clipContextMenu?.element.track,
|
||||
keyframeContextMenuRowKey: kfContextMenu?.element.track,
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
// @vitest-environment happy-dom
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { expect, it } from "vitest";
|
||||
import { createTimelineRowGeometry, type TimelineRowGeometry } from "./timelineLayout";
|
||||
import { useTimelineRowVirtualization } from "./useTimelineRowVirtualization";
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
it("leaves the viewport still when a live preview lane is removed before the committed row arrives", () => {
|
||||
const scroll = document.createElement("div");
|
||||
scroll.scrollTop = 160;
|
||||
const scrollRef = { current: scroll };
|
||||
const lastScrollLeftRef = { current: 0 };
|
||||
const host = document.createElement("div");
|
||||
const root = createRoot(host);
|
||||
const normal = createTimelineRowGeometry([0, 1, 2, 3, 4, 5], [48, 48, 48, 48, 48, 48]);
|
||||
const preview = createTimelineRowGeometry([0, -1, 1, 2, 3, 4, 5], [48, 48, 48, 48, 48, 48, 48]);
|
||||
const committed = createTimelineRowGeometry([0, 1, 2, 3, 4, 5, 6], [48, 48, 48, 48, 48, 48, 48]);
|
||||
function Probe({
|
||||
geometry,
|
||||
draggedRowKey,
|
||||
}: {
|
||||
geometry: TimelineRowGeometry;
|
||||
draggedRowKey?: number;
|
||||
}) {
|
||||
useTimelineRowVirtualization({
|
||||
scrollRef,
|
||||
rowGeometry: geometry,
|
||||
draggedRowKey,
|
||||
sessionEpoch: 0,
|
||||
viewport: {
|
||||
scrollTop: 160,
|
||||
scrollLeft: 0,
|
||||
clientWidth: 800,
|
||||
clientHeight: 200,
|
||||
scrollWidth: 800,
|
||||
scrollHeight: 600,
|
||||
isScrolling: false,
|
||||
},
|
||||
elements: [],
|
||||
selectedElementId: null,
|
||||
lastScrollLeftRef,
|
||||
syncScrollViewport: () => {},
|
||||
});
|
||||
return null;
|
||||
}
|
||||
act(() => root.render(<Probe geometry={normal} />));
|
||||
act(() => root.render(<Probe geometry={preview} draggedRowKey={-1} />));
|
||||
expect(scroll.scrollTop).toBe(160);
|
||||
act(() => root.render(<Probe geometry={normal} />));
|
||||
expect(scroll.scrollTop).toBe(160);
|
||||
act(() => root.render(<Probe geometry={committed} />));
|
||||
expect(scroll.scrollTop).toBe(160);
|
||||
act(() => root.unmount());
|
||||
});
|
||||
@@ -103,6 +103,7 @@ export function useTimelineRowVirtualization({
|
||||
});
|
||||
|
||||
const previousLayoutRef = useRef(rowGeometry);
|
||||
const previousDraggedRowKeyRef = useRef(draggedRowKey);
|
||||
const previousSessionEpochRef = useRef(sessionEpoch);
|
||||
useLayoutEffect(() => {
|
||||
const scroll = scrollRef.current;
|
||||
@@ -120,7 +121,7 @@ export function useTimelineRowVirtualization({
|
||||
previousGeometry,
|
||||
rowGeometry,
|
||||
scroll.scrollTop,
|
||||
draggedRowKey !== undefined,
|
||||
draggedRowKey !== undefined || previousDraggedRowKeyRef.current !== undefined,
|
||||
);
|
||||
if (nextScrollTop !== scroll.scrollTop) {
|
||||
scroll.scrollTop = nextScrollTop;
|
||||
@@ -128,6 +129,7 @@ export function useTimelineRowVirtualization({
|
||||
}
|
||||
}
|
||||
previousLayoutRef.current = rowGeometry;
|
||||
previousDraggedRowKeyRef.current = draggedRowKey;
|
||||
}, [draggedRowKey, lastScrollLeftRef, rowGeometry, scrollRef, sessionEpoch, syncScrollViewport]);
|
||||
|
||||
return {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { elementAutomationLanes, groupAutomationLanes } from "./automationLaneDa
|
||||
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
||||
import type { DraggedClipState } from "./timelineClipDragTypes";
|
||||
import { useTimelineTrackDerivations } from "./useTimelineTrackDerivations";
|
||||
import { buildTimelineTrackInsertLayout } from "./timelineTrackInsertLayout";
|
||||
import {
|
||||
TRACK_H,
|
||||
createTimelineRowGeometry,
|
||||
@@ -246,6 +247,12 @@ export function useTimelineTrackLayout(
|
||||
useTimelineTrackDerivations(expandedElements);
|
||||
const trackOrderRef = useRef(trackOrder);
|
||||
trackOrderRef.current = trackOrder;
|
||||
const trackInsertLayout = useMemo(
|
||||
() => (groups.length > 0 ? buildTimelineTrackInsertLayout(trackOrder, groups) : undefined),
|
||||
[trackOrder, groups],
|
||||
);
|
||||
const trackInsertLayoutRef = useRef(trackInsertLayout);
|
||||
trackInsertLayoutRef.current = trackInsertLayout;
|
||||
const { laneCounts, rowGeometry, rowGeometryRef, rowHeights } = useTimelineRowHeights(
|
||||
tracks,
|
||||
gsapAnimations,
|
||||
@@ -260,6 +267,7 @@ export function useTimelineTrackLayout(
|
||||
trackStyles,
|
||||
trackOrder,
|
||||
trackOrderRef,
|
||||
trackInsertLayoutRef,
|
||||
laneCounts,
|
||||
rowGeometry,
|
||||
rowGeometryRef,
|
||||
@@ -283,27 +291,40 @@ function useDisplayRowHeights(
|
||||
);
|
||||
}
|
||||
|
||||
function insertPreviewTrack(trackOrder: number[], preview: number) {
|
||||
const result: number[] = [];
|
||||
let inserted = false;
|
||||
for (const key of trackOrder) {
|
||||
if (!inserted && Number.isInteger(key) && key > preview) {
|
||||
result.push(preview);
|
||||
inserted = true;
|
||||
}
|
||||
result.push(key);
|
||||
}
|
||||
if (!inserted) result.push(preview);
|
||||
return result;
|
||||
}
|
||||
|
||||
function useDisplayTrackOrder(draggedClip: DraggedClipState | null, trackOrder: number[]) {
|
||||
const started = draggedClip?.started === true;
|
||||
const insertRow = draggedClip?.insertRow ?? null;
|
||||
const previewTrack = draggedClip?.previewTrack;
|
||||
return useMemo(() => {
|
||||
if (!draggedClip?.started || trackOrder.includes(draggedClip.previewTrack)) return trackOrder;
|
||||
if (!started || previewTrack === undefined) return trackOrder;
|
||||
if (insertRow !== null) {
|
||||
const pendingKey = trackOrder.reduce((lowest, key) => Math.min(lowest, key), 0) - 1;
|
||||
const displayOrder = [...trackOrder];
|
||||
displayOrder.splice(insertRow, 0, pendingKey);
|
||||
return displayOrder;
|
||||
}
|
||||
if (trackOrder.includes(previewTrack)) return trackOrder;
|
||||
// A group's members sit out of raw numeric order (pulled under their
|
||||
// anchor row), so a plain numeric sort here would undo that grouping the
|
||||
// moment a clip drags onto a brand-new track. Insert the new preview
|
||||
// track only relative to other REAL (integer) tracks, leaving any
|
||||
// fractional group-anchor keys exactly where grouping placed them.
|
||||
const preview = draggedClip.previewTrack;
|
||||
const result: number[] = [];
|
||||
let inserted = false;
|
||||
for (const key of trackOrder) {
|
||||
if (!inserted && Number.isInteger(key) && key > preview) {
|
||||
result.push(preview);
|
||||
inserted = true;
|
||||
}
|
||||
result.push(key);
|
||||
}
|
||||
if (!inserted) result.push(preview);
|
||||
return result;
|
||||
}, [draggedClip, trackOrder]);
|
||||
return insertPreviewTrack(trackOrder, previewTrack);
|
||||
}, [started, insertRow, previewTrack, trackOrder]);
|
||||
}
|
||||
|
||||
export function useTimelineDisplayLayout(
|
||||
@@ -317,7 +338,15 @@ export function useTimelineDisplayLayout(
|
||||
() => createTimelineRowGeometry(displayTrackOrder, displayRowHeights, rowGeometry.padding),
|
||||
[displayTrackOrder, displayRowHeights, rowGeometry.padding],
|
||||
);
|
||||
let draggedRowKey: number | undefined;
|
||||
if (draggedClip?.started) {
|
||||
draggedRowKey =
|
||||
draggedClip.insertRow !== null
|
||||
? displayRowGeometry.rowKeys[draggedClip.insertRow]
|
||||
: draggedClip.previewTrack;
|
||||
}
|
||||
return {
|
||||
draggedRowKey,
|
||||
displayTrackOrder,
|
||||
displayRowHeights: displayRowGeometry.rowHeights,
|
||||
rowGeometry: displayRowGeometry,
|
||||
|
||||
@@ -24,4 +24,11 @@ describe("timelineElementsChanged", () => {
|
||||
const linked = { ...clip, link: "lk-1" };
|
||||
expect(timelineElementsChanged([linked], [clip])).toBe(true);
|
||||
});
|
||||
|
||||
it("sees restored group membership with identical timing and lane", () => {
|
||||
const grouped = { ...clip, audioGroup: "G" };
|
||||
expect(timelineElementsChanged([clip], [grouped])).toBe(true);
|
||||
expect(timelineElementsChanged([grouped], [clip])).toBe(true);
|
||||
expect(timelineElementsChanged([grouped], [{ ...grouped }])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ const RENDERED_FIELDS: readonly (keyof TimelineElement)[] = [
|
||||
"volume",
|
||||
"playbackRate",
|
||||
"hidden",
|
||||
"audioGroup",
|
||||
"audioGroupVolume",
|
||||
"audioGroupHidden",
|
||||
"fadeIn",
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { bindThumbnailPageLifecycle } from "./thumbnailPageLifecycle";
|
||||
import {
|
||||
ThumbnailScheduler,
|
||||
type ThumbnailRequest,
|
||||
type ThumbnailLoadedResult,
|
||||
} from "./thumbnailScheduler";
|
||||
|
||||
function mountLifetime() {
|
||||
const scheduler = new ThumbnailScheduler();
|
||||
const page = new EventTarget();
|
||||
const document = Object.assign(new EventTarget(), { hidden: false });
|
||||
const unbind = bindThumbnailPageLifecycle(page, document, scheduler);
|
||||
return { scheduler, page, document, unbind };
|
||||
}
|
||||
|
||||
describe("thumbnail document lifetime", () => {
|
||||
it("cancels every active thumbnail kind on pagehide and resumes retained leases", async () => {
|
||||
const { scheduler, page, unbind } = mountLifetime();
|
||||
const signals: AbortSignal[] = [];
|
||||
const kinds = ["composition", "image", "video", "waveform"] as const;
|
||||
const requests = kinds.map((kind): ThumbnailRequest => {
|
||||
let attempts = 0;
|
||||
return {
|
||||
key: kind,
|
||||
projectId: "scene",
|
||||
sessionEpoch: 0,
|
||||
kind,
|
||||
priority: "visible",
|
||||
load: (signal: AbortSignal) => {
|
||||
signals.push(signal);
|
||||
attempts++;
|
||||
if (attempts > 1)
|
||||
return Promise.resolve<ThumbnailLoadedResult>({
|
||||
value: { kind: "image", url: kind, aspect: 1 },
|
||||
weight: 1,
|
||||
});
|
||||
return new Promise<ThumbnailLoadedResult>((_, reject) =>
|
||||
signal.addEventListener("abort", () =>
|
||||
reject(new DOMException("Aborted", "AbortError")),
|
||||
),
|
||||
);
|
||||
},
|
||||
};
|
||||
});
|
||||
const leases = requests.map((request) => scheduler.acquire(request, () => {}));
|
||||
expect(signals).toHaveLength(4);
|
||||
page.dispatchEvent(new Event("pagehide"));
|
||||
expect(signals.map((signal) => signal.aborted)).toEqual([true, true, true, true]);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(requests.map((request) => scheduler.getSnapshot(request).status)).toEqual([
|
||||
"queued",
|
||||
"queued",
|
||||
"queued",
|
||||
"queued",
|
||||
]);
|
||||
page.dispatchEvent(new Event("pageshow"));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(signals).toHaveLength(8);
|
||||
expect(requests.map((request) => scheduler.getSnapshot(request).status)).toEqual([
|
||||
"ready",
|
||||
"ready",
|
||||
"ready",
|
||||
"ready",
|
||||
]);
|
||||
leases.forEach((lease) => lease.release());
|
||||
unbind();
|
||||
});
|
||||
|
||||
it("holds new jobs while away and preserves a preview reload hold on pageshow", async () => {
|
||||
const { scheduler, page, document, unbind } = mountLifetime();
|
||||
const started: string[] = [];
|
||||
const make = (kind: "composition" | "image"): ThumbnailRequest => ({
|
||||
key: kind,
|
||||
projectId: "scene",
|
||||
kind,
|
||||
priority: "visible",
|
||||
load: async () => {
|
||||
started.push(kind);
|
||||
return { value: { kind: "image", url: kind, aspect: 1 }, weight: 1 };
|
||||
},
|
||||
});
|
||||
scheduler.setPreviewReloading(true);
|
||||
page.dispatchEvent(new Event("pagehide"));
|
||||
const leases = [make("composition"), make("image")].map((request) =>
|
||||
scheduler.acquire(request, () => {}),
|
||||
);
|
||||
expect(started).toEqual([]);
|
||||
document.hidden = true;
|
||||
page.dispatchEvent(new Event("pageshow"));
|
||||
expect(started).toEqual([]);
|
||||
document.hidden = false;
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
expect(started).toEqual(["image"]);
|
||||
scheduler.setPreviewReloading(false);
|
||||
expect(started).toEqual(["image", "composition"]);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
leases.forEach((lease) => lease.release());
|
||||
unbind();
|
||||
});
|
||||
|
||||
it("removes the lifecycle listeners when unbound", () => {
|
||||
const { scheduler, page, unbind } = mountLifetime();
|
||||
unbind();
|
||||
page.dispatchEvent(new Event("pagehide"));
|
||||
let started = false;
|
||||
const lease = scheduler.acquire(
|
||||
{
|
||||
key: "poster",
|
||||
projectId: "scene",
|
||||
kind: "image",
|
||||
priority: "visible",
|
||||
load: async () => {
|
||||
started = true;
|
||||
return { value: { kind: "image", url: "poster", aspect: 1 }, weight: 1 };
|
||||
},
|
||||
},
|
||||
() => {},
|
||||
);
|
||||
expect(started).toBe(true);
|
||||
lease.release();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { thumbnailScheduler, type ThumbnailScheduler } from "./thumbnailScheduler";
|
||||
|
||||
export function bindThumbnailPageLifecycle(
|
||||
page: EventTarget,
|
||||
document: EventTarget & { readonly hidden: boolean },
|
||||
scheduler: ThumbnailScheduler = thumbnailScheduler,
|
||||
): () => void {
|
||||
let left = false;
|
||||
const sync = () => scheduler.setPageHidden(left || document.hidden);
|
||||
const hide = () => {
|
||||
left = true;
|
||||
sync();
|
||||
};
|
||||
const show = () => {
|
||||
left = false;
|
||||
sync();
|
||||
};
|
||||
page.addEventListener("pagehide", hide);
|
||||
page.addEventListener("pageshow", show);
|
||||
document.addEventListener("visibilitychange", sync);
|
||||
sync();
|
||||
return () => {
|
||||
page.removeEventListener("pagehide", hide);
|
||||
page.removeEventListener("pageshow", show);
|
||||
document.removeEventListener("visibilitychange", sync);
|
||||
};
|
||||
}
|
||||
@@ -117,6 +117,7 @@ export class ThumbnailScheduler {
|
||||
private nextSequence = 1;
|
||||
private scrolling = false;
|
||||
private previewReloading = false;
|
||||
private pageHidden = false;
|
||||
private cacheBytes = 0;
|
||||
private waveformCacheBytes = 0;
|
||||
private readonly activeByBucket = { video: 0, composition: 0, general: 0 };
|
||||
@@ -226,11 +227,27 @@ export class ThumbnailScheduler {
|
||||
}
|
||||
for (const entry of this.entries.values()) {
|
||||
if (entry.state !== "loading" || entry.request.kind !== "composition") continue;
|
||||
entry.preempted = true;
|
||||
entry.controller?.abort();
|
||||
this.preempt(entry);
|
||||
}
|
||||
}
|
||||
|
||||
setPageHidden(hidden: boolean): void {
|
||||
if (this.pageHidden === hidden) return;
|
||||
this.pageHidden = hidden;
|
||||
if (!hidden) {
|
||||
this.pump();
|
||||
return;
|
||||
}
|
||||
for (const entry of this.entries.values()) {
|
||||
if (entry.state === "loading") this.preempt(entry);
|
||||
}
|
||||
}
|
||||
|
||||
private preempt(entry: ThumbnailEntry): void {
|
||||
entry.preempted = true;
|
||||
entry.controller?.abort();
|
||||
}
|
||||
|
||||
/** Evict project cache entries that are no longer owned by mounted consumers. */
|
||||
invalidateProject(projectId: string): void {
|
||||
for (const [key, entry] of this.entries) {
|
||||
@@ -269,6 +286,7 @@ export class ThumbnailScheduler {
|
||||
}
|
||||
|
||||
private pump(): void {
|
||||
if (this.pageHidden) return;
|
||||
const queued = Array.from(this.entries.values())
|
||||
.filter((entry) => entry.state === "queued" && entry.leases.size > 0)
|
||||
.sort((left, right) => {
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Grouped track drop fixture</title>
|
||||
</head>
|
||||
<body>
|
||||
<main
|
||||
id="root"
|
||||
data-composition-id="root"
|
||||
data-no-timeline
|
||||
data-width="1280"
|
||||
data-height="720"
|
||||
data-duration="4"
|
||||
>
|
||||
<audio
|
||||
id="group-first"
|
||||
class="clip"
|
||||
data-label="First group member"
|
||||
data-start="0"
|
||||
data-duration="2"
|
||||
data-track-index="0"
|
||||
data-audio-group="Voices"
|
||||
></audio>
|
||||
<audio
|
||||
id="outside"
|
||||
class="clip"
|
||||
data-label="Outside group"
|
||||
data-start="0"
|
||||
data-duration="3"
|
||||
data-track-index="1"
|
||||
></audio>
|
||||
<audio
|
||||
id="group-last"
|
||||
class="clip"
|
||||
data-label="Last group member"
|
||||
data-start="3"
|
||||
data-duration="1"
|
||||
data-track-index="2"
|
||||
data-audio-group="Voices"
|
||||
></audio>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1 @@
|
||||
{ "$schema": "https://hyperframes.heygen.com/schema/hyperframes.json" }
|
||||
@@ -0,0 +1,151 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>New track fixture</title>
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
width: 1280px;
|
||||
height: 720px;
|
||||
background: #e5e3da;
|
||||
color: #232623;
|
||||
font: 20px Arial;
|
||||
}
|
||||
main {
|
||||
position: relative;
|
||||
width: 1280px;
|
||||
height: 720px;
|
||||
}
|
||||
p {
|
||||
position: absolute;
|
||||
left: 64px;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main
|
||||
id="ghost-lane"
|
||||
data-composition-id="ghost-lane"
|
||||
data-width="1280"
|
||||
data-height="720"
|
||||
data-start="0"
|
||||
data-duration="24"
|
||||
data-fps="30"
|
||||
data-no-timeline
|
||||
>
|
||||
<p
|
||||
class="clip"
|
||||
id="Opening-title"
|
||||
data-start="0"
|
||||
data-duration="5"
|
||||
data-track-index="0"
|
||||
style="z-index: 20; top: 30px"
|
||||
>
|
||||
Opening title
|
||||
</p>
|
||||
<p
|
||||
class="clip"
|
||||
id="Product-walkthrough-final-approved-cut-with-alternate-opening-and-captions"
|
||||
data-start="0.4"
|
||||
data-duration="5"
|
||||
data-track-index="1"
|
||||
style="z-index: 19; top: 70px"
|
||||
>
|
||||
Product walkthrough final approved cut with alternate opening and captions
|
||||
</p>
|
||||
<p
|
||||
class="clip"
|
||||
id="Detail-shot"
|
||||
data-start="0.8"
|
||||
data-duration="5"
|
||||
data-track-index="2"
|
||||
style="z-index: 18; top: 110px"
|
||||
>
|
||||
Detail shot
|
||||
</p>
|
||||
<p
|
||||
class="clip"
|
||||
id="Supporting-graphics"
|
||||
data-start="1.2"
|
||||
data-duration="5"
|
||||
data-track-index="3"
|
||||
style="z-index: 17; top: 150px"
|
||||
>
|
||||
Supporting graphics
|
||||
</p>
|
||||
<p
|
||||
class="clip"
|
||||
id="Closing-title"
|
||||
data-start="1.6"
|
||||
data-duration="5"
|
||||
data-track-index="4"
|
||||
style="z-index: 16; top: 190px"
|
||||
>
|
||||
Closing title
|
||||
</p>
|
||||
<p
|
||||
class="clip"
|
||||
id="Music-title"
|
||||
data-start="2"
|
||||
data-duration="5"
|
||||
data-track-index="5"
|
||||
style="z-index: 15; top: 230px"
|
||||
>
|
||||
Music title
|
||||
</p>
|
||||
<p
|
||||
class="clip"
|
||||
id="Voiceover-title"
|
||||
data-start="2.4"
|
||||
data-duration="5"
|
||||
data-track-index="6"
|
||||
style="z-index: 14; top: 270px"
|
||||
>
|
||||
Voiceover title
|
||||
</p>
|
||||
<p
|
||||
class="clip"
|
||||
id="Lower-thirds"
|
||||
data-start="2.8"
|
||||
data-duration="5"
|
||||
data-track-index="7"
|
||||
style="z-index: 13; top: 310px"
|
||||
>
|
||||
Lower thirds
|
||||
</p>
|
||||
<p
|
||||
class="clip"
|
||||
id="Screen-recording"
|
||||
data-start="3.2"
|
||||
data-duration="5"
|
||||
data-track-index="8"
|
||||
style="z-index: 12; top: 350px"
|
||||
>
|
||||
Screen recording
|
||||
</p>
|
||||
<p
|
||||
class="clip"
|
||||
id="Logo-animation"
|
||||
data-start="3.6"
|
||||
data-duration="5"
|
||||
data-track-index="9"
|
||||
style="z-index: 11; top: 390px"
|
||||
>
|
||||
Logo animation
|
||||
</p>
|
||||
<p
|
||||
class="clip"
|
||||
id="second-cut"
|
||||
data-start="10"
|
||||
data-duration="5"
|
||||
data-track-index="1"
|
||||
style="z-index: 19; top: 70px"
|
||||
>
|
||||
Second cut
|
||||
</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,163 @@
|
||||
#!/usr/bin/env node
|
||||
import assert from "node:assert/strict";
|
||||
import { createServer, request } from "node:http";
|
||||
import { copyFileSync, mkdirSync, readFileSync, rmSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { launchStudioChrome } from "./chrome-executable.mjs";
|
||||
|
||||
const backend = new URL(process.env.STUDIO_URL ?? "http://127.0.0.1:5190");
|
||||
assert.equal(backend.protocol, "http:", "The local regression proxy requires HTTP");
|
||||
const studio = fileURLToPath(new URL("../../", import.meta.url));
|
||||
const fixture = join(dirname(fileURLToPath(import.meta.url)), "fixtures/ghost-lane");
|
||||
const projects = [];
|
||||
const held = new Map();
|
||||
const proxy = createServer((incoming, response) => {
|
||||
const url = new URL(incoming.url ?? "/", backend);
|
||||
// Cache-only poster reads never enter the server's render queue.
|
||||
if (
|
||||
/\/api\/projects\/[^/]+\/thumbnail\//.test(url.pathname) &&
|
||||
url.searchParams.get("cached") !== "1"
|
||||
) {
|
||||
held.set(response, incoming.url);
|
||||
response.once("close", () => held.delete(response));
|
||||
return;
|
||||
}
|
||||
forward(incoming, response, url.pathname + url.search);
|
||||
});
|
||||
function forward(incoming, response, path) {
|
||||
const outgoing = request(
|
||||
backend,
|
||||
{
|
||||
path,
|
||||
method: incoming.method,
|
||||
headers: incoming.headers,
|
||||
},
|
||||
(upstream) => {
|
||||
response.writeHead(upstream.statusCode ?? 502, upstream.headers);
|
||||
upstream.pipe(response);
|
||||
},
|
||||
);
|
||||
outgoing.on("error", () => {
|
||||
if (!response.destroyed) {
|
||||
response.writeHead(502);
|
||||
response.end("Upstream unavailable");
|
||||
}
|
||||
});
|
||||
response.once("close", () => {
|
||||
if (!response.writableEnded) outgoing.destroy();
|
||||
});
|
||||
incoming.pipe(outgoing);
|
||||
}
|
||||
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
async function waitFor(predicate, message, bound = 15_000) {
|
||||
const deadline = Date.now() + bound;
|
||||
while (!predicate()) {
|
||||
assert(Date.now() < deadline, message);
|
||||
await pause(25);
|
||||
}
|
||||
}
|
||||
const heldFor = (project) =>
|
||||
[...held.values()].filter((path) => path.includes(`/projects/${project}/`)).length;
|
||||
const clip =
|
||||
'[data-clip][data-el-id$="Product-walkthrough-final-approved-cut-with-alternate-opening-and-captions"]';
|
||||
let browser;
|
||||
try {
|
||||
await new Promise((resolve) => proxy.listen(0, "127.0.0.1", resolve));
|
||||
const address = proxy.address();
|
||||
assert(address && typeof address !== "string");
|
||||
const proxyStatus = await new Promise((resolve, reject) => {
|
||||
const probe = request(
|
||||
{
|
||||
hostname: "127.0.0.1",
|
||||
port: address.port,
|
||||
path: "http://127.0.0.1:1/",
|
||||
signal: AbortSignal.timeout(5000),
|
||||
},
|
||||
(response) => {
|
||||
response.resume();
|
||||
response.once("error", reject);
|
||||
response.once("end", () => resolve(response.statusCode));
|
||||
},
|
||||
);
|
||||
probe.once("error", reject);
|
||||
probe.end();
|
||||
});
|
||||
assert.equal(proxyStatus, 200, "An absolute request target cannot replace the Studio backend");
|
||||
({ browser } = await launchStudioChrome());
|
||||
let previous;
|
||||
let page;
|
||||
for (let index = 0; index < 3; index++) {
|
||||
const id = `thumbnail-lifetime-${process.pid}-${index}`;
|
||||
const dir = join(studio, "data/projects", id);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
projects.push({ id, dir });
|
||||
for (const name of ["index.html", "hyperframes.json"])
|
||||
copyFileSync(join(fixture, name), join(dir, name));
|
||||
page = await browser.newPage();
|
||||
await page.bringToFront();
|
||||
if (previous) {
|
||||
await previous.waitForFunction(() => document.hidden, { timeout: 5000 });
|
||||
await previous.evaluate(() => new Promise((resolve) => setTimeout(resolve, 0)));
|
||||
const client = await previous.createCDPSession();
|
||||
// Background documents can freeze their timers while requests still hold sockets.
|
||||
await client.send("Page.setWebLifecycleState", { state: "frozen" });
|
||||
}
|
||||
await page.setViewport({ width: 1440, height: 900 });
|
||||
await page.goto(`http://127.0.0.1:${address.port}/#project/${id}`, {
|
||||
waitUntil: "domcontentloaded",
|
||||
timeout: 60_000,
|
||||
});
|
||||
await page.waitForFunction(
|
||||
(selector) =>
|
||||
document.querySelector(selector)?.closest("[data-timeline-row]")?.dataset.timelineRow ===
|
||||
"1",
|
||||
{ timeout: 30_000 },
|
||||
clip,
|
||||
);
|
||||
await waitFor(() => heldFor(id) >= 2, "Each project starts real held thumbnail requests");
|
||||
previous = page;
|
||||
}
|
||||
const box = await (await page.$(clip)).boundingBox();
|
||||
const view = await (await page.$("[data-timeline-scroll-viewport]")).boundingBox();
|
||||
const row = await (await page.$('[data-timeline-row="2"]')).boundingBox();
|
||||
assert(box && view && row);
|
||||
await page.mouse.move(box.x + Math.min(40, box.width / 2), box.y + box.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(Math.min(view.x + view.width - 120, box.x + 60), row.y, { steps: 8 });
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
document
|
||||
.querySelector("[data-timeline-new-track-lane]")
|
||||
?.getAttribute("data-timeline-new-track-lane") === "2",
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
const dropped = Date.now();
|
||||
await page.mouse.up();
|
||||
const saved = join(projects[2].dir, "index.html");
|
||||
await waitFor(() => {
|
||||
const tag = readFileSync(saved, "utf8").match(
|
||||
/<[^>]*\bid="Product-walkthrough-final-approved-cut-with-alternate-opening-and-captions"[^>]*>/,
|
||||
)?.[0];
|
||||
return /\bdata-track-index="2"/.test(tag ?? "");
|
||||
}, "Dropped clip persists within 15 seconds with old-project thumbnails in flight");
|
||||
assert.equal(heldFor(projects[0].id), 0, "First hidden document releases its thumbnail sockets");
|
||||
assert.equal(heldFor(projects[1].id), 0, "Second hidden document releases its thumbnail sockets");
|
||||
assert(
|
||||
heldFor(projects[2].id) >= 2,
|
||||
"Current document still has unfinished thumbnails at the save",
|
||||
);
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
persistedMs: Date.now() - dropped,
|
||||
oldThumbnailRequests: 0,
|
||||
currentThumbnailRequests: heldFor(projects[2].id),
|
||||
saveBoundMs: 15_000,
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
await browser?.close();
|
||||
proxy.closeAllConnections();
|
||||
await new Promise((resolve) => proxy.close(resolve));
|
||||
for (const { dir } of projects) rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
import { readNodeRequestBody } from "./vite.request-body.js";
|
||||
import { bindNodeRequestSignal } from "./vite.request-signal.js";
|
||||
import { watch } from "chokidar";
|
||||
import { createProjectSignatureCache, createViteAdapter } from "./vite.adapter";
|
||||
import { previewConfigPayload } from "./vite.preview-config";
|
||||
@@ -226,6 +227,7 @@ function devProjectApi(): Plugin {
|
||||
// API middleware
|
||||
server.middlewares.use(async (req, res, next) => {
|
||||
if (!req.url?.startsWith("/api/")) return next();
|
||||
const requestSignal = bindNodeRequestSignal(res);
|
||||
try {
|
||||
const api = await getApi();
|
||||
const url = new URL(req.url, `http://${req.headers.host}`);
|
||||
@@ -243,6 +245,7 @@ function devProjectApi(): Plugin {
|
||||
method: req.method,
|
||||
headers,
|
||||
body,
|
||||
signal: requestSignal.signal,
|
||||
});
|
||||
const response = await api.fetch(fetchReq);
|
||||
await bridgeHonoResponse(response, res);
|
||||
@@ -252,6 +255,8 @@ function devProjectApi(): Plugin {
|
||||
res.writeHead(500, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "Internal server error" }));
|
||||
}
|
||||
} finally {
|
||||
requestSignal.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { createServer, get } from "node:http";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { bindNodeRequestSignal } from "./vite.request-signal";
|
||||
|
||||
// A real connection is required: completing a request body is not a disconnect.
|
||||
describe("Studio dev request cancellation", () => {
|
||||
it("aborts a held response when its client disconnects", async () => {
|
||||
let received!: () => void;
|
||||
let disconnected!: () => void;
|
||||
const ready = new Promise<void>((resolve) => {
|
||||
received = resolve;
|
||||
});
|
||||
const closed = new Promise<void>((resolve) => {
|
||||
disconnected = resolve;
|
||||
});
|
||||
let signal: AbortSignal | undefined;
|
||||
const server = createServer((_, response) => {
|
||||
const request = bindNodeRequestSignal(response);
|
||||
signal = request.signal;
|
||||
signal.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
request.dispose();
|
||||
disconnected();
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
received();
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") throw new Error("Expected a TCP listener");
|
||||
const client = get(`http://127.0.0.1:${address.port}/thumbnail`);
|
||||
client.on("error", (error) => expect(error).toMatchObject({ code: "ECONNRESET" }));
|
||||
try {
|
||||
await ready;
|
||||
expect(signal?.aborted).toBe(false);
|
||||
client.destroy();
|
||||
await closed;
|
||||
expect(signal?.aborted).toBe(true);
|
||||
} finally {
|
||||
client.destroy();
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
|
||||
it("does not abort completed work after its listener is disposed", async () => {
|
||||
let signal: AbortSignal | undefined;
|
||||
const server = createServer((_, response) => {
|
||||
const request = bindNodeRequestSignal(response);
|
||||
signal = request.signal;
|
||||
response.end("complete");
|
||||
request.dispose();
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") throw new Error("Expected a TCP listener");
|
||||
try {
|
||||
const response = await fetch(`http://127.0.0.1:${address.port}/thumbnail`);
|
||||
expect(await response.text()).toBe("complete");
|
||||
expect(signal?.aborted).toBe(false);
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { ServerResponse } from "node:http";
|
||||
|
||||
export function bindNodeRequestSignal(response: ServerResponse): {
|
||||
signal: AbortSignal;
|
||||
dispose: () => void;
|
||||
} {
|
||||
const controller = new AbortController();
|
||||
const abort = () => controller.abort();
|
||||
response.once("close", abort);
|
||||
return { signal: controller.signal, dispose: () => response.off("close", abort) };
|
||||
}
|
||||
Reference in New Issue
Block a user