fix(studio): harden composition timeline reliability (#2615)

* fix(studio): preserve composition playback continuity

* feat(studio): drag compositions into the timeline

* fix(studio): collapse expanded composition move aliases

* fix(studio): make timeline cuts atomic

* fix(studio): group inspector gesture history

* test(studio): cover masked text selection

* fix(studio): harden composition timeline reliability

* fix(studio): satisfy CI source gates

* fix(studio): harden composition mutation requests
This commit is contained in:
Miguel Ángel
2026-07-17 14:15:30 -04:00
committed by GitHub
parent 2be8a62c00
commit 2b65b4efce
93 changed files with 3925 additions and 758 deletions
+27
View File
@@ -1102,6 +1102,33 @@ describe("initSandboxRuntimeModular", () => {
expect(hookHost.style.visibility).toBe("visible");
});
it("seeks child compositions in source time using host offset and playback rate", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-root", "true");
root.setAttribute("data-start", "0");
root.setAttribute("data-duration", "20");
document.body.appendChild(root);
const child = document.createElement("div");
child.setAttribute("data-composition-id", "child");
child.setAttribute("data-start", "3");
child.setAttribute("data-duration", "8");
child.setAttribute("data-playback-start", "1.5");
child.setAttribute("data-playback-rate", "2");
root.appendChild(child);
const childTimeline = createMockTimeline(6);
window.__timelines = { main: createMockTimeline(20), child: childTimeline };
initSandboxRuntimeModular();
window.__player?.renderSeek(5);
expect(childTimeline.time()).toBeCloseTo(5.5);
window.__player?.renderSeek(10);
expect(childTimeline.time()).toBe(6);
});
it("keeps the root GSAP render nudge for normal frames but not silent probes", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
+15 -17
View File
@@ -20,6 +20,8 @@ import {
import { forceDispatchSeekEvent } from "./adapters/seek-dispatch";
import { createWaapiAdapter } from "./adapters/waapi";
import {
readElementPlaybackRate,
readElementPlaybackStart,
refreshRuntimeMediaCache,
resolveRuntimeMediaClipDuration,
syncRuntimeMedia,
@@ -2656,17 +2658,15 @@ export function initSandboxRuntimeModular(): void {
if (!node) continue;
const start = resolveStartForElement(node, 0);
if (!Number.isFinite(start)) continue;
const authoredDuration = resolveDurationForElement(node, {
includeAuthoredTimingAttrs: true,
});
const timelineDuration = getTimelineDurationSeconds(timeline);
const duration =
authoredDuration != null && authoredDuration > 0 ? authoredDuration : timelineDuration;
const sourceTime =
readElementPlaybackStart(node) +
Math.max(0, timeSeconds - start) * readElementPlaybackRate(node);
const localTime = Math.max(
0,
duration != null && duration > 0
? Math.min(duration, timeSeconds - start)
: timeSeconds - start,
timelineDuration != null && timelineDuration > 0
? Math.min(timelineDuration, sourceTime)
: sourceTime,
);
seekRuntimeTimeline(timeline, localTime, "runtime.init.transport.childTimeline", options);
}
@@ -2809,15 +2809,13 @@ export function initSandboxRuntimeModular(): void {
} catch (err) {
swallow("runtime.init.transport.seek", err);
}
// Sibling timelines (registered in __timelines but not nested under
// the root) are paused alongside the master. We do NOT seek them to
// absolute position `t` here — child timelines nested under the root
// are already propagated via tl.totalTime(), and seeking them again
// at absolute `t` would clobber their offset-relative position.
// Play/pause propagation for siblings happens in the player.play()
// and player.pause() overrides via the adapter layer.
} else {
seekStandaloneRegisteredTimelines(t, opts);
// Root propagation cannot represent an authored child source offset or
// playback rate. Re-seek registered children below with their host's
// explicit source-time contract.
}
seekStandaloneRegisteredTimelines(t, opts);
if (tl && opts?.activateChildren) {
activateSiblingTimelines(tl);
}
for (const adapter of state.deterministicAdapters) {
if (adapter.name === "gsap" && tl) continue;
+17 -3
View File
@@ -1,9 +1,23 @@
import { swallow } from "./diagnostics";
import { interpolateVolumeGain, type VolumeKeyframe } from "./mediaVolumeEnvelope.js";
import { normalizePlaybackRate } from "./playbackRate.js";
export function readElementPlaybackRate(el: HTMLMediaElement): number {
const raw = el.defaultPlaybackRate;
return Number.isFinite(raw) && raw > 0 ? Math.max(0.1, Math.min(5, raw)) : 1;
export function readElementPlaybackRate(el: Element): number {
const authored = Number.parseFloat(el.getAttribute("data-playback-rate") ?? "");
const raw =
Number.isFinite(authored) && authored > 0
? authored
: el instanceof HTMLMediaElement
? el.defaultPlaybackRate
: 1;
return normalizePlaybackRate(raw);
}
export function readElementPlaybackStart(el: Element): number {
const raw = Number.parseFloat(
el.getAttribute("data-playback-start") ?? el.getAttribute("data-media-start") ?? "",
);
return Number.isFinite(raw) && raw >= 0 ? raw : 0;
}
/**
@@ -0,0 +1,3 @@
export function normalizePlaybackRate(raw: number): number {
return Number.isFinite(raw) && raw > 0 ? Math.max(0.1, Math.min(5, raw)) : 1;
}
@@ -312,10 +312,32 @@ describe("collectRuntimeTimelinePayload", () => {
comp.setAttribute("data-composition-id", "scene-1");
comp.setAttribute("data-start", "0");
comp.setAttribute("data-duration", "10");
comp.setAttribute("data-playback-start", "1.5");
comp.setAttribute("data-playback-rate", "2");
root.appendChild(comp);
const result = collectRuntimeTimelinePayload(defaultParams);
expect(result.clips[0].kind).toBe("composition");
expect(result.clips[0].playbackStart).toBe(1.5);
expect(result.clips[0].playbackRate).toBe(2);
});
it("defaults a legacy composition host playback window to zero at unit rate", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-duration", "20");
document.body.appendChild(root);
const comp = document.createElement("div");
comp.id = "scene-legacy";
comp.setAttribute("data-composition-id", "scene-legacy");
comp.setAttribute("data-start", "0");
comp.setAttribute("data-duration", "10");
root.appendChild(comp);
const clip = collectRuntimeTimelinePayload(defaultParams).clips[0];
expect(clip.playbackStart).toBe(0);
expect(clip.playbackRate).toBe(1);
});
it("collects scenes from composition nodes", () => {
+7 -1
View File
@@ -6,7 +6,7 @@ import type {
} from "./types";
import { stableClipId } from "./clipTree";
import { swallow } from "./diagnostics";
import { readElementPlaybackRate } from "./media";
import { readElementPlaybackRate, readElementPlaybackStart } from "./media";
import { resolveCssStackingContextId } from "./stackingContext";
import { createRuntimeStartTimeResolver } from "./startResolver";
import { isSceneLikeCompositionId } from "../slideshow/index.js";
@@ -412,6 +412,8 @@ export function collectRuntimeTimelinePayload(params: {
parentCompositionId: compositionContext.parentCompositionId,
nodePath: null,
compositionSrc: toAbsoluteAssetUrl(node.getAttribute("data-composition-src")),
playbackStart: readElementPlaybackStart(node),
playbackRate: readElementPlaybackRate(node),
assetUrl: resolveNodeAssetUrl(node),
timelineRole: node.getAttribute("data-timeline-role"),
timelineLabel: node.getAttribute("data-timeline-label"),
@@ -521,6 +523,8 @@ export function collectRuntimeTimelinePayload(params: {
parentCompositionId: rootCompositionIdForGsap,
nodePath: null,
compositionSrc: null,
playbackStart: readElementPlaybackStart(el),
playbackRate: readElementPlaybackRate(el),
assetUrl: null,
timelineRole: el.getAttribute("data-timeline-role"),
timelineLabel: el.getAttribute("data-timeline-label"),
@@ -576,6 +580,8 @@ export function collectRuntimeTimelinePayload(params: {
parentCompositionId: rootCompositionIdForGsap,
nodePath: null,
compositionSrc: null,
playbackStart: readElementPlaybackStart(el),
playbackRate: readElementPlaybackRate(el),
assetUrl: null,
timelineRole,
timelineLabel: el.getAttribute("data-timeline-label"),
+2
View File
@@ -64,6 +64,8 @@ export type RuntimeTimelineClip = {
parentCompositionId: string | null;
nodePath: string | null;
compositionSrc: string | null;
playbackStart: number;
playbackRate: number;
assetUrl: string | null;
timelineRole: string | null;
timelineLabel: string | null;
@@ -0,0 +1,177 @@
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { parseHTML } from "linkedom";
import { CompositionInsertionError, insertCompositionIntoSource } from "./compositionInsertion";
const dirs: string[] = [];
afterEach(() => {
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true });
});
function project(): string {
const dir = mkdtempSync(join(tmpdir(), "hf-comp-insert-"));
dirs.push(dir);
return dir;
}
const parent = `<!doctype html><html><body><div data-composition-id="main" data-width="1920" data-height="1080" data-duration="6"><div id="occupied" data-start="1" data-duration="5" data-track-index="2"></div></div></body></html>`;
const child = `<template><div data-composition-id="headline" data-width="1920" data-height="1080" data-duration="4.9"><h1>Title</h1></div></template>`;
function writeFixture(dir: string): void {
writeFileSync(join(dir, "index.html"), parent);
writeFileSync(join(dir, "headline.html"), child);
}
describe("insertCompositionIntoSource", () => {
it("inserts template-root compositions with stable timing and spills a collision", () => {
const dir = project();
writeFixture(dir);
const result = insertCompositionIntoSource({
projectDir: dir,
targetPath: "index.html",
sourcePath: "headline.html",
parentSource: parent,
start: 2,
desiredTrack: 2,
});
const host = parseHTML(result.html).document.getElementById(result.hostId);
expect(result.track).toBe(3);
expect(host?.getAttribute("data-composition-src")).toBe("headline.html");
expect(host?.getAttribute("data-playback-start")).toBe("0");
expect(host?.getAttribute("data-duration")).toBe("4.9");
expect(host?.getAttribute("data-hf-id")).toMatch(/^hf-/);
expect(result.html).toContain('data-duration="6.9"');
});
it("gives repeated sources distinct host identities", () => {
const dir = project();
writeFixture(dir);
const first = insertCompositionIntoSource({
projectDir: dir,
targetPath: "index.html",
sourcePath: "headline.html",
parentSource: parent,
start: 0,
desiredTrack: 0,
});
writeFileSync(join(dir, "index.html"), first.html);
const second = insertCompositionIntoSource({
projectDir: dir,
targetPath: "index.html",
sourcePath: "headline.html",
parentSource: first.html,
start: 5,
desiredTrack: 0,
});
expect(second.hostId).not.toBe(first.hostId);
expect(second.html.match(/data-composition-src="headline.html"/g)).toHaveLength(2);
});
it("reserves existing composition identities even when host ids are missing", () => {
const dir = project();
const existingParent = `<!doctype html><html><body>
<div data-composition-id="main" data-width="1920" data-height="1080" data-duration="6">
<div data-composition-id="headline" data-composition-src="headline.html" data-start="0" data-duration="1" data-track-index="0"></div>
</div>
</body></html>`;
writeFileSync(join(dir, "index.html"), existingParent);
writeFileSync(join(dir, "headline.html"), child);
const result = insertCompositionIntoSource({
projectDir: dir,
targetPath: "index.html",
sourcePath: "headline.html",
parentSource: existingParent,
start: 2,
desiredTrack: 0,
});
const document = parseHTML(result.html).document;
const ids = Array.from(document.querySelectorAll("[data-composition-id]")).map((element) =>
element.getAttribute("data-composition-id"),
);
expect(result.hostId).toBe("headline_2");
expect(new Set(ids).size).toBe(ids.length);
});
it("ignores nested composition internals when resolving a parent track collision", () => {
const dir = project();
const inlineParent = `<!doctype html><html><body>
<div data-composition-id="main" data-width="1920" data-height="1080" data-duration="6">
<div data-composition-id="nested" data-composition-src="nested.html" data-start="0" data-duration="6" data-track-index="0">
<div data-start="2" data-duration="3" data-track-index="2"></div>
</div>
</div>
</body></html>`;
writeFileSync(join(dir, "index.html"), inlineParent);
writeFileSync(join(dir, "headline.html"), child);
const result = insertCompositionIntoSource({
projectDir: dir,
targetPath: "index.html",
sourcePath: "headline.html",
parentSource: inlineParent,
start: 2,
desiredTrack: 2,
});
expect(result.track).toBe(2);
});
it("inserts into a template-root parent composition", () => {
const dir = project();
const templateParent = `<template><div data-composition-id="parent" data-width="1920" data-height="1080" data-duration="2"></div></template>`;
writeFileSync(join(dir, "parent.html"), templateParent);
writeFileSync(join(dir, "headline.html"), child);
const result = insertCompositionIntoSource({
projectDir: dir,
targetPath: "parent.html",
sourcePath: "headline.html",
parentSource: templateParent,
start: 1,
desiredTrack: 0,
});
expect(result.html).toContain('data-composition-src="headline.html"');
expect(result.html).toContain('data-duration="5.9"');
});
it("rejects self-nesting, transitive cycles, missing files, and invalid durations", () => {
const dir = project();
writeFixture(dir);
writeFileSync(
join(dir, "middle.html"),
`<div data-composition-id="middle" data-width="1" data-height="1" data-duration="1"><div data-composition-src="index.html"></div></div>`,
);
writeFileSync(
join(dir, "cycle-source.html"),
`<div data-composition-id="cycle" data-width="1" data-height="1" data-duration="1"><div data-composition-src="middle.html"></div></div>`,
);
writeFileSync(
join(dir, "invalid.html"),
`<div data-composition-id="invalid" data-width="1" data-height="1" data-duration="0"></div>`,
);
const insert = (sourcePath: string) =>
insertCompositionIntoSource({
projectDir: dir,
targetPath: "index.html",
sourcePath,
parentSource: parent,
start: 0,
desiredTrack: 0,
});
expect(() => insert("index.html")).toThrow(/cycle/);
expect(() => insert("cycle-source.html")).toThrow(/cycle/);
expect(() => insert("missing.html")).toThrow(CompositionInsertionError);
expect(() => insert("invalid.html")).toThrow(/valid data-composition-duration/);
expect(() => insert("../outside.html")).toThrow(CompositionInsertionError);
});
});
@@ -0,0 +1,223 @@
import { existsSync, readFileSync, realpathSync } from "node:fs";
import { randomUUID } from "node:crypto";
import { dirname, relative, resolve, sep } from "node:path";
import { parseHTML } from "linkedom";
import { isSafePath, resolveWithinProject } from "./safePath.js";
export class CompositionInsertionError extends Error {
constructor(
message: string,
readonly status: 400 | 404,
) {
super(message);
}
}
function descendants(root: Document | Element, selector: string): Element[] {
const found = Array.from(root.querySelectorAll(selector));
for (const template of root.querySelectorAll("template")) {
found.push(...descendants(template, selector));
}
return [...new Set(found)];
}
function compositionRoot(source: string): { document: Document; root: Element } {
const document = parseHTML(source).document;
const root = descendants(document, "[data-composition-id]")[0];
if (!root) throw new CompositionInsertionError("Composition source has no root", 400);
return { document, root };
}
function positiveAttribute(root: Element, ...names: string[]): number {
for (const name of names) {
const value = Number.parseFloat(root.getAttribute(name) ?? "");
if (Number.isFinite(value) && value > 0) return value;
}
throw new CompositionInsertionError(`Composition source has no valid ${names[0]}`, 400);
}
function canonicalProjectPath(projectDir: string, candidate: string | null): string {
if (!candidate) {
throw new CompositionInsertionError("Composition source escapes the project", 400);
}
if (!existsSync(candidate)) {
throw new CompositionInsertionError("Composition source was not found", 404);
}
const canonical = realpathSync(candidate);
if (!isSafePath(realpathSync(projectDir), canonical)) {
throw new CompositionInsertionError("Composition source escapes the project", 400);
}
return canonical;
}
function validateSourcePath(sourcePath: string): void {
if (!sourcePath.trim() || sourcePath.includes("\0") || /^[a-z]+:/i.test(sourcePath)) {
throw new CompositionInsertionError("Invalid composition source path", 400);
}
}
function canonicalProjectFile(projectDir: string, sourcePath: string): string {
validateSourcePath(sourcePath);
return canonicalProjectPath(projectDir, resolveWithinProject(projectDir, sourcePath));
}
function canonicalDependency(projectDir: string, ownerAbs: string, sourcePath: string): string {
validateSourcePath(sourcePath);
return canonicalProjectPath(
projectDir,
resolveWithinProject(projectDir, relative(projectDir, resolve(dirname(ownerAbs), sourcePath))),
);
}
function validateDependencyGraph(projectDir: string, targetAbs: string, sourceAbs: string): void {
const visited = new Set<string>();
const visiting = new Set<string>();
const visit = (file: string) => {
if (file === targetAbs) {
throw new CompositionInsertionError("Composition insertion would create a cycle", 400);
}
if (visiting.has(file)) {
throw new CompositionInsertionError("Composition dependency cycle detected", 400);
}
if (visited.has(file)) return;
visiting.add(file);
const source = readFileSync(file, "utf-8");
const { document } = compositionRoot(source);
for (const host of descendants(document, "[data-composition-src]")) {
const dependency = host.getAttribute("data-composition-src");
if (dependency) {
visit(canonicalDependency(projectDir, file, dependency));
}
}
visiting.delete(file);
visited.add(file);
};
visit(sourceAbs);
}
function numberAttribute(element: Element, name: string, fallback = 0): number {
const value = Number.parseFloat(element.getAttribute(name) ?? "");
return Number.isFinite(value) ? value : fallback;
}
function rangesOverlap(start: number, duration: number, other: Element): boolean {
const otherStart = numberAttribute(other, "data-start");
const otherDuration = numberAttribute(other, "data-duration");
return start < otherStart + otherDuration && otherStart < start + duration;
}
function resolveTrack(
root: Element,
desiredTrack: number,
start: number,
duration: number,
): number {
const clips = descendants(root, "[data-start][data-duration]").filter(
(element) =>
element !== root && element.parentElement?.closest("[data-composition-id]") === root,
);
const tracks = [...new Set(clips.map((clip) => numberAttribute(clip, "data-track-index")))].sort(
(a, b) => a - b,
);
const isFree = (track: number) =>
!clips.some(
(clip) =>
numberAttribute(clip, "data-track-index") === track && rangesOverlap(start, duration, clip),
);
if (isFree(desiredTrack)) return desiredTrack;
const row = tracks.indexOf(desiredTrack);
for (let index = row - 1; index >= 0; index--) {
const track = tracks[index];
if (track !== undefined && isFree(track)) return track;
}
for (let index = Math.max(0, row + 1); index < tracks.length; index++) {
const track = tracks[index];
if (track !== undefined && isFree(track)) return track;
}
return Math.max(desiredTrack, ...tracks, -1) + 1;
}
function uniqueHostId(root: Element, base: string): string {
const ids = new Set([
...descendants(root, "[id]").map((element) => element.id),
...descendants(root, "[data-composition-id]").flatMap((element) => {
const id = element.getAttribute("data-composition-id");
return id ? [id] : [];
}),
]);
if (!ids.has(base)) return base;
let suffix = 2;
while (ids.has(`${base}_${suffix}`)) suffix += 1;
return `${base}_${suffix}`;
}
function relativeSourcePath(targetAbs: string, sourceAbs: string): string {
return relative(dirname(targetAbs), sourceAbs).split(sep).join("/");
}
export function insertCompositionIntoSource(input: {
projectDir: string;
targetPath: string;
sourcePath: string;
parentSource: string;
start: number;
desiredTrack: number;
}): { html: string; hostId: string; track: number; duration: number } {
const targetAbs = canonicalProjectFile(input.projectDir, input.targetPath);
const sourceAbs = canonicalProjectFile(input.projectDir, input.sourcePath);
validateDependencyGraph(input.projectDir, targetAbs, sourceAbs);
const source = readFileSync(sourceAbs, "utf-8");
const sourceComposition = compositionRoot(source).root;
const duration = positiveAttribute(
sourceComposition,
"data-composition-duration",
"data-duration",
);
const width = positiveAttribute(sourceComposition, "data-width");
const height = positiveAttribute(sourceComposition, "data-height");
const { document, root } = compositionRoot(input.parentSource);
const parentDuration = positiveAttribute(root, "data-duration", "data-composition-duration");
const base =
(sourceComposition.getAttribute("data-composition-id") ?? "composition")
.replace(/[^a-zA-Z0-9_-]+/g, "-")
.replace(/^-+|-+$/g, "") || "composition";
const hostId = uniqueHostId(root, base);
const track = resolveTrack(
root,
Math.max(0, Math.round(input.desiredTrack)),
input.start,
duration,
);
const zIndex =
Math.max(
0,
...descendants(root, "[style]").map((element) => {
const match = /(?:^|;)\s*z-index\s*:\s*(-?\d+)/i.exec(element.getAttribute("style") ?? "");
return match?.[1] ? Number.parseInt(match[1], 10) : 0;
}),
) + 1;
const host = document.createElement("div");
host.id = hostId;
host.className = "clip";
host.setAttribute("data-hf-id", `hf-${randomUUID()}`);
host.setAttribute("data-composition-id", hostId);
host.setAttribute("data-composition-src", relativeSourcePath(targetAbs, sourceAbs));
host.setAttribute("data-start", String(Math.round(input.start * 100) / 100));
host.setAttribute("data-duration", String(duration));
host.setAttribute("data-playback-start", "0");
host.setAttribute("data-track-index", String(track));
host.setAttribute("data-width", String(width));
host.setAttribute("data-height", String(height));
host.setAttribute(
"style",
`position: absolute; left: 0px; top: 0px; width: ${width}px; height: ${height}px; z-index: ${zIndex}`,
);
root.appendChild(host);
if (input.start + duration > parentDuration) {
const name = root.hasAttribute("data-duration") ? "data-duration" : "data-composition-duration";
root.setAttribute(name, String(Math.round((input.start + duration) * 100) / 100));
}
return { html: document.toString(), hostId, track, duration };
}
@@ -258,7 +258,13 @@ export function splitElementInHtml(
target: SourceMutationTarget,
splitTime: number,
newId: string,
fallbackTiming?: { start: number; duration: number },
fallbackTiming?: {
start: number;
duration: number;
playbackStart?: number;
playbackRate?: number;
stampPlaybackStart?: boolean;
},
): SplitElementResult {
const { document, wrappedFragment } = parseSourceDocument(source);
const el = findTargetElement(document, target);
@@ -292,6 +298,19 @@ export function splitElementInHtml(
const clone = el.cloneNode(true);
if (!isHTMLElement(clone)) return { html: source, matched: false, newId: null };
clone.setAttribute("id", newId);
const compositionId = clone.getAttribute("data-composition-id");
if (compositionId) {
const usedCompositionIds = new Set(
Array.from(document.querySelectorAll("[data-composition-id]"), (node) =>
node.getAttribute("data-composition-id"),
),
);
const base = `${compositionId}-split`;
let nextCompositionId = base;
let suffix = 2;
while (usedCompositionIds.has(nextCompositionId)) nextCompositionId = `${base}-${suffix++}`;
clone.setAttribute("data-composition-id", nextCompositionId);
}
clone.removeAttribute("data-hf-id");
// Descendants carry their own data-hf-id; leaving them duplicates the id of
// every nested node (e.g. an inner <span>), so strip them on the clone too.
@@ -306,11 +325,16 @@ export function splitElementInHtml(
? "data-playback-start"
: el.hasAttribute("data-media-start")
? "data-media-start"
: null;
: fallbackTiming?.stampPlaybackStart
? "data-playback-start"
: null;
if (playbackStartAttr) {
const currentTrim = parseFloat(el.getAttribute(playbackStartAttr) ?? "0") || 0;
const currentTrim =
parseFloat(el.getAttribute(playbackStartAttr) ?? "") || fallbackTiming?.playbackStart || 0;
const rateRaw = parseFloat(el.getAttribute("data-playback-rate") ?? "");
const rate = Number.isFinite(rateRaw) ? rateRaw : 1;
const rate =
Number.isFinite(rateRaw) && rateRaw > 0 ? rateRaw : (fallbackTiming?.playbackRate ?? 1);
el.setAttribute(playbackStartAttr, String(Math.round(currentTrim * 1000) / 1000));
clone.setAttribute(
playbackStartAttr,
String(Math.round((currentTrim + firstDuration * rate) * 1000) / 1000),
@@ -70,6 +70,25 @@ describe("splitElementInHtml", () => {
expect(result.html).toMatch(/id="box-split"[^>]*class="clip"/);
});
it("gives a split composition host a unique composition id", () => {
const composition = source.replace(
'id="box" class="clip"',
'id="box" class="clip" data-composition-id="headline" data-composition-src="headline.html"',
);
const first = splitElementInHtml(composition, { id: "box" }, 3, "box-split");
const second = splitElementInHtml(first.html, { id: "box-split" }, 4, "box-split-2");
const { document } = parseHTML(second.html);
const compositionIds = Array.from(
document.querySelectorAll("[data-composition-id]"),
(element) => element.getAttribute("data-composition-id"),
);
expect(compositionIds).toHaveLength(4);
expect(new Set(compositionIds)).toHaveLength(4);
expect(compositionIds).toEqual(expect.arrayContaining(["root", "headline", "headline-split"]));
});
it("returns matched false for out-of-range split time", () => {
expect(splitElementInHtml(source, { id: "box" }, 0.5, "box-split").matched).toBe(false);
expect(splitElementInHtml(source, { id: "box" }, 7.5, "box-split").matched).toBe(false);
@@ -103,6 +122,20 @@ describe("splitElementInHtml", () => {
const result = splitElementInHtml(mediaSource, { id: "box" }, 3, "box-split");
expect(result.html).toMatch(/id="box-split"[^>]*data-playback-start="2"/);
});
it("stamps a legacy composition offset and advances the second half by playback rate", () => {
const result = splitElementInHtml(source, { id: "box" }, 3, "box-split", {
start: 1,
duration: 6,
playbackStart: 1.5,
playbackRate: 2,
stampPlaybackStart: true,
});
const { document } = parseHTML(result.html);
expect(document.getElementById("box")?.getAttribute("data-playback-start")).toBe("1.5");
expect(document.getElementById("box-split")?.getAttribute("data-playback-start")).toBe("5.5");
});
});
describe("wrapElementsInHtml / unwrapElementsFromHtml", () => {
@@ -84,7 +84,115 @@ function postElementPatchBatches(
});
}
function postCutBatch(
app: Hono,
files: Array<{
path: string;
expectedVersion: string;
targets: Array<{
target: { id?: string; hfId?: string; selector?: string; selectorIndex?: number };
originalId?: string;
splitTime: number;
elementStart: number;
elementDuration: number;
}>;
}>,
): Promise<Response> {
return app.request("http://localhost/projects/demo/file-mutations/split-batch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ files, transactionToken: "cut-test" }),
});
}
describe("registerFileRoutes", () => {
it("CAS-inserts one composition host and leaves stale requests side-effect free", async () => {
const projectDir = createProjectDir();
const before = `<!doctype html><html><body><div data-composition-id="main" data-width="640" data-height="360" data-duration="2"></div></body></html>`;
writeFileSync(join(projectDir, "index.html"), before);
writeFileSync(
join(projectDir, "child.html"),
`<template><div data-composition-id="child" data-width="640" data-height="360" data-duration="3"></div></template>`,
);
const app = new Hono();
registerFileRoutes(app, createAdapter(projectDir));
const insert = (expectedVersion: string) =>
app.request("http://localhost/projects/demo/file-mutations/insert-composition/index.html", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sourcePath: "child.html", start: 4, track: 0, expectedVersion }),
});
const response = await insert(fileContentVersion(before));
const result = (await response.json()) as { after: string; hostId: string; version: string };
expect(response.status).toBe(200);
expect(result.after).toBe(readFileSync(join(projectDir, "index.html"), "utf-8"));
expect(result.after).toContain('data-duration="7"');
expect(result.after).toContain(`id="${result.hostId}"`);
expect(result.version).toBe(fileContentVersion(result.after));
const committed = result.after;
const stale = await insert(fileContentVersion(before));
expect(stale.status).toBe(409);
expect(readFileSync(join(projectDir, "index.html"), "utf-8")).toBe(committed);
});
it.each([
["index.html", 400],
["missing.html", 404],
["../outside.html", 400],
])("rejects invalid composition source %s without writing", async (sourcePath, status) => {
const projectDir = createProjectDir();
const before = `<!doctype html><html><body><div data-composition-id="main" data-width="640" data-height="360" data-duration="2"></div></body></html>`;
writeFileSync(join(projectDir, "index.html"), before);
const app = new Hono();
registerFileRoutes(app, createAdapter(projectDir));
const response = await app.request(
"http://localhost/projects/demo/file-mutations/insert-composition/index.html",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
sourcePath,
start: 0,
track: 0,
expectedVersion: fileContentVersion(before),
}),
},
);
expect(response.status).toBe(status);
expect(readFileSync(join(projectDir, "index.html"), "utf-8")).toBe(before);
});
it("returns 404 when the composition insertion target does not exist", async () => {
const projectDir = createProjectDir();
writeFileSync(
join(projectDir, "child.html"),
`<template><div data-composition-id="child" data-duration="3"></div></template>`,
);
const app = new Hono();
registerFileRoutes(app, createAdapter(projectDir));
const response = await app.request(
"http://localhost/projects/demo/file-mutations/insert-composition/missing.html",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
sourcePath: "child.html",
start: 0,
track: 0,
expectedVersion: "missing",
}),
},
);
expect(response.status).toBe(404);
});
it("returns empty content for missing files when caller marks the read optional", async () => {
const projectDir = createProjectDir();
const app = new Hono();
@@ -554,6 +662,159 @@ describe("registerFileRoutes", () => {
expect(response.headers.get("etag")).toBe(payload.version);
});
it("folds multiple same-file cuts and writes one canonical file result", async () => {
const projectDir = createProjectDir();
const before =
'<div id="a" data-start="0" data-duration="4">A</div><div id="b" data-start="0" data-duration="4">B</div>';
writeFileSync(join(projectDir, "index.html"), before);
const app = new Hono();
registerFileRoutes(app, createAdapter(projectDir));
const response = await postCutBatch(app, [
{
path: "index.html",
expectedVersion: fileContentVersion(before),
targets: [
{
target: { id: "a" },
originalId: "a",
splitTime: 2,
elementStart: 0,
elementDuration: 4,
},
{
target: { id: "b" },
originalId: "b",
splitTime: 2,
elementStart: 0,
elementDuration: 4,
},
],
},
]);
const payload = (await response.json()) as {
files: Array<{ after: string; version: string; splitCount: number }>;
};
expect(response.status).toBe(200);
expect(payload.files).toHaveLength(1);
expect(payload.files[0].splitCount).toBe(2);
expect(payload.files[0].after).toContain('id="a-split"');
expect(payload.files[0].after).toContain('id="b-split"');
expect(readFileSync(join(projectDir, "index.html"), "utf-8")).toBe(payload.files[0].after);
expect(consumeFileWriteReceipt(join(projectDir, "index.html"))).toEqual({
path: "index.html",
version: payload.files[0].version,
writeToken: "cut-test",
});
});
it("cuts multiple id-less selector targets against their original indices", async () => {
const projectDir = createProjectDir();
const before =
'<div class="clip" data-start="0" data-duration="4">A</div><div class="other" data-start="0" data-duration="4">Other</div><div class="clip" data-start="0" data-duration="4">B</div>';
writeFileSync(join(projectDir, "index.html"), before);
const app = new Hono();
registerFileRoutes(app, createAdapter(projectDir));
const response = await postCutBatch(app, [
{
path: "index.html",
expectedVersion: fileContentVersion(before),
targets: [
{
target: { selector: ".clip", selectorIndex: 0 },
splitTime: 2,
elementStart: 0,
elementDuration: 4,
},
{
target: { selector: ".other", selectorIndex: 0 },
splitTime: 2,
elementStart: 0,
elementDuration: 4,
},
{
target: { selector: ".clip", selectorIndex: 1 },
splitTime: 2,
elementStart: 0,
elementDuration: 4,
},
],
},
]);
const payload = (await response.json()) as {
files?: Array<{ after: string; splitCount: number }>;
};
expect(response.status).toBe(200);
expect(payload.files?.[0]?.splitCount).toBe(3);
expect(payload.files?.[0]?.after.match(/class="clip"/g) ?? []).toHaveLength(4);
expect(payload.files?.[0]?.after.match(/class="other"/g) ?? []).toHaveLength(2);
expect(payload.files?.[0]?.after).toContain(">A</div>");
expect(payload.files?.[0]?.after).toContain(">B</div>");
});
it("rejects a stale multi-file cut before writing either file", async () => {
const projectDir = createProjectDir();
const beforeA = '<div id="a" data-start="0" data-duration="4">A</div>';
const beforeB = '<div id="b" data-start="0" data-duration="4">B</div>';
writeFileSync(join(projectDir, "index.html"), beforeA);
writeFileSync(join(projectDir, "b.html"), beforeB);
const app = new Hono();
registerFileRoutes(app, createAdapter(projectDir));
const target = (id: string) => ({
target: { id },
originalId: id,
splitTime: 2,
elementStart: 0,
elementDuration: 4,
});
const response = await postCutBatch(app, [
{
path: "index.html",
expectedVersion: fileContentVersion(beforeA),
targets: [target("a")],
},
{ path: "b.html", expectedVersion: '"stale"', targets: [target("b")] },
]);
expect(response.status).toBe(409);
expect(readFileSync(join(projectDir, "index.html"), "utf-8")).toBe(beforeA);
expect(readFileSync(join(projectDir, "b.html"), "utf-8")).toBe(beforeB);
});
it("serializes rapid cuts so a stale successor cannot fragment the first result", async () => {
const projectDir = createProjectDir();
const before = '<div id="clip" data-start="0" data-duration="4">Clip</div>';
writeFileSync(join(projectDir, "index.html"), before);
const app = new Hono();
registerFileRoutes(app, createAdapter(projectDir));
const request = () =>
postCutBatch(app, [
{
path: "index.html",
expectedVersion: fileContentVersion(before),
targets: [
{
target: { id: "clip" },
originalId: "clip",
splitTime: 2,
elementStart: 0,
elementDuration: 4,
},
],
},
]);
const [first, second] = await Promise.all([request(), request()]);
expect([first.status, second.status].sort()).toEqual([200, 409]);
const after = readFileSync(join(projectDir, "index.html"), "utf-8");
expect(after.match(/id="clip-split"/g) ?? []).toHaveLength(1);
});
// A realistic sub-composition: markup + GSAP wrapped in a <template>, tweens
// targeting element variables resolved from querySelector, with interleaved
// gsap.set() calls. This is the shape every scaffolded composition uses.
+372
View File
@@ -75,6 +75,10 @@ import {
type ElementRebase,
} from "../helpers/sourceMutation.js";
import { parseHTML } from "linkedom";
import {
CompositionInsertionError,
insertCompositionIntoSource,
} from "../helpers/compositionInsertion.js";
// ── Server cutover flag ─────────────────────────────────────────────────────
@@ -190,6 +194,61 @@ interface ElementPatchBatchFileResult {
backupPath?: string | null;
}
interface AtomicCutTarget {
target: MutationTarget;
originalId?: string;
splitTime: number;
elementStart: number;
elementDuration: number;
playbackStart?: number;
playbackRate?: number;
isComposition?: boolean;
}
interface AtomicCutFileRequest {
path: string;
expectedVersion: string;
targets: AtomicCutTarget[];
}
function isAtomicCutTarget(value: unknown): value is AtomicCutTarget {
if (!value || typeof value !== "object") return false;
const target = value as Partial<AtomicCutTarget>;
return (
!!target.target &&
typeof target.target === "object" &&
Number.isFinite(target.splitTime) &&
Number.isFinite(target.elementStart) &&
Number.isFinite(target.elementDuration) &&
Number(target.elementDuration) > 0
);
}
function isAtomicCutFileRequest(value: unknown): value is AtomicCutFileRequest {
if (!value || typeof value !== "object") return false;
const file = value as Partial<AtomicCutFileRequest>;
return (
typeof file.path === "string" &&
file.path.length > 0 &&
typeof file.expectedVersion === "string" &&
Array.isArray(file.targets) &&
file.targets.length > 0 &&
file.targets.every(isAtomicCutTarget)
);
}
let atomicCutTail: Promise<unknown> = Promise.resolve();
/** Serialize cut actions so a rapid second gesture observes the first one's bytes. */
function serializeAtomicCut<T>(task: () => Promise<T>): Promise<T> {
const next = atomicCutTail.then(task, task);
atomicCutTail = next.then(
() => undefined,
() => undefined,
);
return next;
}
function isElementPatchRequest(value: unknown): value is ElementPatchRequest {
if (typeof value !== "object" || value === null) return false;
if (!("target" in value) || typeof value.target !== "object" || value.target === null) {
@@ -1853,6 +1912,103 @@ async function executeGsapMutationRecast(
}
}
interface FoldedAtomicCutFile {
path: string;
absPath: string;
before: string;
after: string;
splitCount: number;
skippedSelectors: string[];
}
/** Fold every split and optional GSAP retarget for one file without touching disk. */
async function foldAtomicCutFile(
c: RouteContext,
file: AtomicCutFileRequest,
absPath: string,
before: string,
): Promise<FoldedAtomicCutFile | Response> {
let after = before;
let splitCount = 0;
const skippedSelectors = new Set<string>();
const respond = (data: unknown, status?: number) =>
status ? c.json(data, status) : c.json(data);
const orderedTargets = file.targets
.map((cut, index) => ({ cut, index }))
.sort((left, right) => {
const locatorKey = (entry: AtomicCutTarget): string | null =>
!entry.target.id && !entry.target.hfId && entry.target.selector
? entry.target.selector
: null;
const leftKey = locatorKey(left.cut);
const rightKey = locatorKey(right.cut);
if (leftKey && rightKey) {
return (
leftKey.localeCompare(rightKey) ||
(right.cut.target.selectorIndex ?? 0) - (left.cut.target.selectorIndex ?? 0)
);
}
if (leftKey) return -1;
if (rightKey) return 1;
return left.index - right.index;
})
.map(({ cut }) => cut);
for (const cut of orderedTargets) {
const baseId = cut.originalId || cut.target.id || "clip";
const split = splitElementInHtml(after, cut.target, cut.splitTime, `${baseId}-split`, {
start: cut.elementStart,
duration: cut.elementDuration,
playbackStart: cut.playbackStart,
playbackRate: cut.playbackRate,
stampPlaybackStart: cut.isComposition,
});
if (!split.matched || !split.newId) {
return c.json(
{ error: `Cut target was not found or was outside its authored bounds in ${file.path}` },
400,
);
}
after = split.html;
splitCount++;
if (!cut.originalId) continue;
const block = extractGsapScriptBlock(after);
if (!block) continue;
const result = await executeGsapMutation(
{
type: "split-animations",
originalId: cut.originalId,
newId: split.newId,
splitTime: cut.splitTime,
elementStart: cut.elementStart,
elementDuration: cut.elementDuration,
},
block,
respond,
);
if (result instanceof Response) return result;
let script = typeof result === "string" ? result : result.script;
if (typeof result !== "string") {
for (const selector of result.skippedSelectors) skippedSelectors.add(selector);
}
if (script !== block.scriptText) {
const parser = await loadGsapParser();
script = parser.syncPositionHoldsBeforeKeyframes(script);
after = block.replaceScript(script);
}
}
return {
path: file.path,
absPath,
before,
after,
splitCount,
skippedSelectors: [...skippedSelectors],
};
}
// ── Upload file processing ──────────────────────────────────────────────────
async function processUploadedFiles(
@@ -2109,6 +2265,81 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
});
});
api.post("/projects/:id/file-mutations/insert-composition/*", async (c) => {
const ctx = await resolveFileMutationContext(c, adapter, "insert-composition");
if ("error" in ctx) return ctx.error;
const body = (await c.req.json().catch(() => null)) as {
sourcePath?: unknown;
start?: unknown;
track?: unknown;
expectedVersion?: unknown;
} | null;
if (
!body ||
typeof body.sourcePath !== "string" ||
typeof body.start !== "number" ||
!Number.isFinite(body.start) ||
body.start < 0 ||
typeof body.track !== "number" ||
!Number.isFinite(body.track) ||
typeof body.expectedVersion !== "string"
) {
return c.json({ error: "sourcePath, finite placement, and expectedVersion required" }, 400);
}
let before: string;
try {
before = readFileSync(ctx.absPath, "utf-8");
} catch (error) {
if (!error || typeof error !== "object" || !("code" in error) || error.code !== "ENOENT") {
throw error;
}
return c.json({ error: "not found" }, 404);
}
const currentVersion = fileContentVersion(before);
if (body.expectedVersion !== currentVersion) {
return c.json({ error: "file conflict", currentVersion, currentContent: before }, 409);
}
let insertion: ReturnType<typeof insertCompositionIntoSource>;
try {
insertion = insertCompositionIntoSource({
projectDir: ctx.project.dir,
targetPath: ctx.filePath,
sourcePath: body.sourcePath,
parentSource: before,
start: body.start,
desiredTrack: body.track,
});
} catch (error) {
if (error instanceof CompositionInsertionError) {
return c.json({ error: error.message }, error.status);
}
throw error;
}
const backup = snapshotBeforeWrite(ctx.project.dir, ctx.absPath);
if (backup.error) return c.json({ error: `backup failed: ${backup.error}` }, 500);
writeFileSync(ctx.absPath, insertion.html, "utf-8");
const version = fileContentVersion(insertion.html);
const writeToken = createWriteToken(c.req.header("X-Hyperframes-Write-Token"));
recordFileWriteReceipt(ctx.absPath, { path: ctx.filePath, version, writeToken });
c.header("ETag", version);
return c.json({
ok: true,
path: ctx.filePath,
hostId: insertion.hostId,
track: insertion.track,
duration: insertion.duration,
before,
after: insertion.html,
version,
writeToken,
backupPath: backupPathForResponse(ctx.project.dir, backup.backupPath),
});
});
api.post("/projects/:id/file-mutations/remove-element/*", async (c) => {
const ctx = await resolveFileMutationContext(c, adapter, "remove-element");
if ("error" in ctx) return ctx.error;
@@ -2131,6 +2362,147 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
);
});
api.post("/projects/:id/file-mutations/split-batch", async (c) => {
const body = (await c.req.json().catch(() => null)) as {
files?: unknown;
transactionToken?: unknown;
} | null;
if (
!Array.isArray(body?.files) ||
body.files.length === 0 ||
!body.files.every(isAtomicCutFileRequest)
) {
return c.json({ error: "files with path, expectedVersion, and cut targets required" }, 400);
}
const files = body.files as AtomicCutFileRequest[];
const project = await adapter.resolveProject(c.req.param("id"));
if (!project) return c.json({ error: "not found" }, 404);
return serializeAtomicCut(async () => {
const seen = new Set<string>();
const prepared: FoldedAtomicCutFile[] = [];
for (const file of files) {
const absPath = resolveWithinProject(project.dir, file.path);
if (!absPath) return c.json({ error: `forbidden path: ${file.path}` }, 403);
if (seen.has(absPath)) return c.json({ error: `duplicate path: ${file.path}` }, 400);
seen.add(absPath);
let before: string;
try {
before = readFileSync(absPath, "utf-8");
} catch {
return c.json({ error: `not found: ${file.path}` }, 404);
}
const currentVersion = fileContentVersion(before);
if (currentVersion !== file.expectedVersion) {
return c.json(
{
error: `file conflict: ${file.path}`,
path: file.path,
currentVersion,
currentContent: before,
},
409,
);
}
let folded: FoldedAtomicCutFile | Response;
try {
folded = await foldAtomicCutFile(c, file, absPath, before);
} catch (error) {
const message = error instanceof Error ? error.message : "Cut transform failed";
return c.json({ error: message }, 400);
}
if (folded instanceof Response) return folded;
prepared.push(folded);
}
// Lazy GSAP parsing above can yield; revalidate every base before the first write.
for (const file of prepared) {
const current = readFileSync(file.absPath, "utf-8");
if (current !== file.before) {
return c.json(
{
error: `file conflict: ${file.path}`,
path: file.path,
currentVersion: fileContentVersion(current),
currentContent: current,
},
409,
);
}
}
const backups = new Map<string, string | null>();
for (const file of prepared) {
const backup = snapshotBeforeWrite(project.dir, file.absPath);
if (backup.error) {
return c.json(
{ error: `Failed to create backup for ${file.path}: ${backup.error}` },
500,
);
}
backups.set(file.path, backupPathForResponse(project.dir, backup.backupPath));
}
const writeToken = createWriteToken(
typeof body.transactionToken === "string"
? body.transactionToken
: c.req.header("X-Hyperframes-Write-Token"),
);
const written: FoldedAtomicCutFile[] = [];
try {
for (const file of prepared) {
writeFileSync(file.absPath, file.after, "utf-8");
written.push(file);
recordFileWriteReceipt(file.absPath, {
path: file.path,
version: fileContentVersion(file.after),
writeToken,
});
}
} catch (error) {
const conflicts: string[] = [];
for (const file of written.reverse()) {
try {
const current = readFileSync(file.absPath, "utf-8");
if (current !== file.after) {
conflicts.push(file.path);
continue;
}
writeFileSync(file.absPath, file.before, "utf-8");
recordFileWriteReceipt(file.absPath, {
path: file.path,
version: fileContentVersion(file.before),
writeToken,
});
} catch {
conflicts.push(file.path);
}
}
return c.json(
{
error: error instanceof Error ? error.message : "Cut write failed",
outcome: conflicts.length ? "aborted-with-conflicts" : "aborted-restored",
conflicts,
},
conflicts.length ? 409 : 500,
);
}
const result = prepared.map((file) => ({
path: file.path,
before: file.before,
after: file.after,
version: fileContentVersion(file.after),
writeToken,
backupPath: backups.get(file.path) ?? null,
splitCount: file.splitCount,
skippedSelectors: file.skippedSelectors,
}));
return c.json({ ok: true, outcome: "committed", files: result });
});
});
api.post("/projects/:id/file-mutations/split-element/*", async (c) => {
const ctx = await resolveFileMutationContext(c, adapter, "split-element");
if ("error" in ctx) return ctx.error;
+15 -10
View File
@@ -23,7 +23,6 @@ import { useDomEditSession } from "./hooks/useDomEditSession";
import { useSdkSelectionSync } from "./hooks/useSdkSelectionSync";
import { useStudioSdkSessions } from "./hooks/useStudioSdkSessions";
import { useBlockHandlers } from "./hooks/useBlockHandlers";
import { useAddAssetAtPlayhead } from "./hooks/useAddAssetAtPlayhead";
import { useAppHotkeys } from "./hooks/useAppHotkeys";
import { useClipboard } from "./hooks/useClipboard";
import { deleteSelectedKeyframes } from "./hooks/timelineEditingHelpers";
@@ -58,6 +57,7 @@ import { FileManagerProvider } from "./contexts/FileManagerContext";
import { DomEditProvider } from "./contexts/DomEditContext";
import { StudioSplash } from "./components/StudioSplash";
import { useServerConnection } from "./hooks/useServerConnection";
import { useTimelineAddAtPlayhead } from "./hooks/useTimelineAddAtPlayhead";
import {
normalizeStudioCompositionPath,
readStudioUrlStateFromWindow,
@@ -65,7 +65,6 @@ import {
} from "./utils/studioUrlState";
import { trackStudioSessionStart } from "./telemetry/events";
import { hasFiredSessionStart, markSessionStartFired } from "./telemetry/config";
type CanvasRect = { left: number; top: number; width: number; height: number };
// fallow-ignore-next-line complexity
export function StudioApp() {
const { projectId, resolving, waitingForServer } = useServerConnection();
@@ -100,9 +99,6 @@ export function StudioApp() {
const timelineDuration = usePlayerStore((s) => s.duration);
const isPlaying = usePlayerStore((s) => s.isPlaying);
const isMasterView = !activeCompPath || activeCompPath === "index.html";
const activePreviewUrl = activeCompPath
? `/api/projects/${projectId}/preview/comp/${activeCompPath}`
: null;
const effectiveTimelineDuration = useMemo(() => {
const maxEnd =
timelineElements.length > 0
@@ -196,7 +192,13 @@ export function StudioApp() {
},
[timelineEditing.handleTimelineGroupMove],
);
const handleAddAssetAtPlayhead = useAddAssetAtPlayhead(timelineEditing.handleTimelineAssetDrop);
const {
addAssetAtPlayhead: handleAddAssetAtPlayhead,
addCompositionAtPlayhead: handleAddCompositionAtPlayhead,
} = useTimelineAddAtPlayhead(
timelineEditing.handleTimelineAssetDrop,
timelineEditing.handleTimelineCompositionDrop,
);
const {
activeBlockParams,
setActiveBlockParams,
@@ -338,7 +340,9 @@ export function StudioApp() {
const renderClipContent = useRenderClipContent({
projectIdRef: fileManager.projectIdRef,
compIdToSrc,
activePreviewUrl,
activePreviewUrl: activeCompPath
? `/api/projects/${projectId}/preview/comp/${activeCompPath}`
: null,
effectiveTimelineDuration,
});
const compositionDimensions = useCompositionDimensions();
@@ -369,14 +373,13 @@ export function StudioApp() {
});
handleToggleRecordingRef.current = handleToggleRecording;
const recordingToggle = STUDIO_KEYFRAMES_ENABLED ? handleToggleRecording : undefined;
const canvasRectRef = useRef<CanvasRect | null>(null);
const canvasRectRef = useRef<DOMRect | null>(null);
useLayoutEffect(() => {
if (gestureState !== "recording" || !previewIframe) {
canvasRectRef.current = null;
return;
}
const r = previewIframe.getBoundingClientRect();
canvasRectRef.current = { left: r.left, top: r.top, width: r.width, height: r.height };
canvasRectRef.current = previewIframe.getBoundingClientRect();
}, [gestureState, previewIframe]);
const handlePreviewIframeRef = useCallback(
(iframe: HTMLIFrameElement | null) => {
@@ -511,6 +514,7 @@ export function StudioApp() {
lintFindingCount={lintModal?.length ?? findingsByFile.size}
lintFindingsByFile={findingsByFile}
onAddAssetToTimeline={handleAddAssetAtPlayhead}
onAddCompositionToTimeline={handleAddCompositionAtPlayhead}
/>
}
right={
@@ -540,6 +544,7 @@ export function StudioApp() {
handleTimelineElementDelete={timelineEditing.handleTimelineElementDelete}
handleTimelineAssetDrop={timelineEditing.handleTimelineAssetDrop}
handleTimelineBlockDrop={handleTimelineBlockDrop}
handleTimelineCompositionDrop={timelineEditing.handleTimelineCompositionDrop}
handlePreviewBlockDrop={handlePreviewBlockDrop}
handleTimelineFileDrop={timelineEditing.handleTimelineFileDrop}
handleTimelineElementMove={timelineEditing.handleTimelineElementMove}
@@ -42,6 +42,10 @@ export interface EditorShellProps extends TimelineEditCallbackDeps {
blockName: string,
placement: TimelineDropPlacement,
) => Promise<void> | void;
handleTimelineCompositionDrop?: (
sourcePath: string,
placement: TimelineDropPlacement,
) => Promise<void> | void;
handlePreviewBlockDrop?: (
blockName: string,
position: { left: number; top: number },
@@ -72,6 +76,7 @@ export function EditorShell({
handleTimelineElementDelete,
handleTimelineAssetDrop,
handleTimelineBlockDrop,
handleTimelineCompositionDrop,
handlePreviewBlockDrop,
handleTimelineFileDrop,
handleTimelineElementMove,
@@ -140,6 +145,7 @@ export function EditorShell({
onFileDrop={handleTimelineFileDrop}
onAssetDrop={handleTimelineAssetDrop}
onBlockDrop={handleTimelineBlockDrop}
onCompositionDrop={handleTimelineCompositionDrop}
onDeleteElement={handleTimelineElementDelete}
previewOverlay={
<PreviewOverlays
@@ -174,6 +180,10 @@ interface EditorShellBodyProps {
onFileDrop: (files: File[], placement?: TimelineDropPlacement) => Promise<void> | void;
onAssetDrop: (assetPath: string, placement: TimelineDropPlacement) => Promise<void> | void;
onBlockDrop?: (blockName: string, placement: TimelineDropPlacement) => Promise<void> | void;
onCompositionDrop?: (
sourcePath: string,
placement: TimelineDropPlacement,
) => Promise<void> | void;
onDeleteElement: (element: TimelineElement) => Promise<void> | void;
}
@@ -189,6 +199,7 @@ function EditorShellBody({
onFileDrop,
onAssetDrop,
onBlockDrop,
onCompositionDrop,
onDeleteElement,
}: EditorShellBodyProps) {
const { compositionStack, updateCompositionStack, containerRef } = useNLEContext();
@@ -233,6 +244,7 @@ function EditorShellBody({
onFileDrop={onFileDrop}
onAssetDrop={onAssetDrop}
onBlockDrop={onBlockDrop}
onCompositionDrop={onCompositionDrop}
onDeleteElement={onDeleteElement}
onSelectTimelineElement={onSelectTimelineElement}
timelineFooter={
@@ -19,6 +19,7 @@ export interface StudioLeftSidebarProps {
lintFindingCount?: number;
lintFindingsByFile?: Map<string, { count: number; messages: string[] }>;
onAddAssetToTimeline?: (path: string) => void;
onAddCompositionToTimeline?: (path: string) => void;
}
// fallow-ignore-next-line complexity
@@ -32,6 +33,7 @@ export function StudioLeftSidebar({
lintFindingCount,
lintFindingsByFile,
onAddAssetToTimeline,
onAddCompositionToTimeline,
}: StudioLeftSidebarProps) {
const {
leftCollapsed,
@@ -150,6 +152,7 @@ export function StudioLeftSidebar({
onAddBlock={onAddBlock}
onPreviewBlock={onPreviewBlock}
onAddAssetToTimeline={onAddAssetToTimeline}
onAddCompositionToTimeline={onAddCompositionToTimeline}
/>
{/* Vertical resize divider: 3px visible seam, 8px pointer-capture zone via
the absolutely-positioned inner hit area. The outer element is w-[3px] so
@@ -9,6 +9,57 @@ import { DomEditSelectionChrome } from "./DomEditSelectionChrome";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
describe("DomEditSelectionChrome crop composition", () => {
it("renders overlay-only transparent chrome at headline geometry without changing composition bytes", () => {
const composition = document.implementation.createHTMLDocument();
composition.body.innerHTML = `
<section class="hl-block"><div class="hl-mask" style="overflow:hidden;background:transparent">
<h1 class="hl-text">Launch title</h1>
</div></section>
`;
const headline = composition.querySelector<HTMLElement>(".hl-text")!;
const before = composition.documentElement.outerHTML;
const selection = {
element: headline,
selector: ".hl-text",
capabilities: {
canCrop: false,
canApplyManualOffset: false,
canApplyManualSize: false,
canApplyManualRotation: false,
},
} as unknown as DomEditSelection;
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
act(() => {
root.render(
<DomEditSelectionChrome
selection={selection}
overlayRect={{ left: 44, top: 52, width: 220, height: 48, editScaleX: 1, editScaleY: 1 }}
allowCanvasMovement={false}
boxRef={createRef()}
boxChromeClass="border border-studio-accent/80"
boxClipPath={undefined}
selectionKey="headline"
groupSelectionCount={0}
blockedMoveRef={createRef()}
gestures={{ startGesture: vi.fn() } as never}
onStyleCommit={vi.fn()}
onBoxMouseDown={vi.fn()}
onBoxClick={vi.fn()}
/>,
);
});
const chrome = host.querySelector<HTMLElement>('[data-dom-edit-selection-box="true"]')!;
expect(chrome.style.cssText).toContain("left: 44px");
expect(chrome.style.cssText).toContain("width: 220px");
expect(chrome.style.background).toBe("");
expect(chrome.className).not.toMatch(/bg-/);
expect(composition.documentElement.outerHTML).toBe(before);
act(() => root.unmount());
host.remove();
});
it("places rotated crop UI in exactly one oriented coordinate plane", () => {
const element = document.createElement("div");
element.id = "clip";
@@ -78,6 +78,7 @@ export function PropertyPanelFlat({
onClearSelection,
onUngroup,
onSetStyle,
onPreviewStyle,
onSetAttribute,
onSetAttributes,
onSetAttributeLive,
@@ -86,6 +87,7 @@ export function PropertyPanelFlat({
onRemoveBackground,
onSetText,
onSetTextFieldStyle,
onPreviewTextFieldStyle,
onAddTextField,
onRemoveTextField,
onAskAgent,
@@ -147,6 +149,7 @@ export function PropertyPanelFlat({
| "onClearSelection"
| "onUngroup"
| "onSetStyle"
| "onPreviewStyle"
| "onSetAttribute"
| "onSetAttributes"
| "onSetAttributeLive"
@@ -155,6 +158,7 @@ export function PropertyPanelFlat({
| "onRemoveBackground"
| "onSetText"
| "onSetTextFieldStyle"
| "onPreviewTextFieldStyle"
| "onAddTextField"
| "onRemoveTextField"
| "onAskAgent"
@@ -360,6 +364,7 @@ export function PropertyPanelFlat({
onImportFonts={onImportFonts}
onSetText={onSetText}
onSetTextFieldStyle={onSetTextFieldStyle}
onPreviewTextFieldStyle={onPreviewTextFieldStyle}
onAddTextField={onAddTextField}
onRemoveTextField={onRemoveTextField}
/>
@@ -382,6 +387,7 @@ export function PropertyPanelFlat({
styles={styles}
assets={assets}
onSetStyle={onSetStyle}
onPreviewStyle={onPreviewStyle}
onImportAssets={onImportAssets}
gsapBorderRadius={gsapBorderRadius}
/>
@@ -0,0 +1,105 @@
// @vitest-environment jsdom
import { existsSync, readFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { patchElementInHtml } from "@hyperframes/studio-server/source-mutation";
import { describe, expect, it } from "vitest";
import { buildDomEditStylePatchOperation } from "./domEditing";
const fixtureDir = join(
dirname(fileURLToPath(import.meta.url)),
"../../../tests/e2e/fixtures/composition-reliability",
);
function fixture(path: string): string {
return readFileSync(join(fixtureDir, path), "utf8");
}
function parse(html: string): Document {
return new DOMParser().parseFromString(html, "text/html");
}
function inTemplate(document: Document, selector: string): Element | null {
for (const template of Array.from(document.querySelectorAll("template"))) {
const match = template.content.querySelector(selector);
if (match) return match;
}
return null;
}
describe("composition reliability acceptance fixture", () => {
const indexSource = fixture("index.html");
const titleSource = fixture("compositions/title-card.html");
const nestedSource = fixture("compositions/nested-shell.html");
it("owns repeated root hosts, a nested host, transparent headline topology, and collisions", () => {
const index = parse(indexSource);
const repeated = Array.from(
index.querySelectorAll('[data-composition-src="compositions/title-card.html"]'),
);
expect(repeated).toHaveLength(2);
expect(repeated.map((host) => host.getAttribute("data-start"))).toEqual(["0", "4"]);
expect(
index.querySelector('[data-composition-src="compositions/nested-shell.html"]'),
).toBeTruthy();
const nested = parse(nestedSource);
const nestedHost = inTemplate(nested, '[data-composition-src="title-card.html"]');
expect(nestedHost).toBeTruthy();
const nestedDependency = nestedHost?.getAttribute("data-composition-src");
expect(
nestedDependency ? existsSync(resolve(fixtureDir, "compositions", nestedDependency)) : false,
).toBe(true);
const title = parse(titleSource);
const mask = inTemplate(title, ".hl-mask");
const headline = inTemplate(title, ".hl-mask > .hl-text");
expect(mask?.textContent).toContain("Reliable compositions");
expect(inTemplate(title, "style")?.textContent).toMatch(
/\.hl-mask\s*\{[^}]*overflow:\s*hidden;[^}]*background:\s*transparent;/,
);
expect(headline?.tagName).toBe("H1");
const collisionA = index.querySelector('[data-hf-id="collision-a"]')!;
const collisionB = index.querySelector('[data-hf-id="collision-b"]')!;
const layered = index.querySelector('[data-hf-id="layer-overlap"]')!;
expect(collisionA.getAttribute("data-track-index")).toBe(
collisionB.getAttribute("data-track-index"),
);
expect(
Number(collisionA.getAttribute("data-start")) +
Number(collisionA.getAttribute("data-duration")),
).toBe(Number(collisionB.getAttribute("data-start")));
expect(layered.getAttribute("data-start")).toBe(collisionB.getAttribute("data-start"));
expect(layered.getAttribute("data-track-index")).not.toBe(
collisionB.getAttribute("data-track-index"),
);
});
it("keeps timeline host edits in the root source and headline color in the template source", () => {
const moved = patchElementInHtml(indexSource, { hfId: "title-host-a" }, [
{ type: "attribute", property: "start", value: "5" },
{ type: "attribute", property: "duration", value: "2" },
]);
expect(moved.matched).toBe(true);
expect(moved.html).toContain('data-hf-id="title-host-a"');
expect(moved.html).toContain('data-start="5"');
expect(moved.html).toContain('data-duration="2"');
expect(moved.html).not.toContain('data-hf-id="title-text"');
expect(titleSource).not.toContain("#12b886");
const recolored = patchElementInHtml(titleSource, { hfId: "title-text" }, [
buildDomEditStylePatchOperation("color", "#12b886"),
]);
expect(recolored.matched).toBe(true);
const recoloredDocument = parse(recolored.html);
expect(
inTemplate(recoloredDocument, '[data-hf-id="title-text"]')?.getAttribute("style"),
).toContain("color: #12b886");
expect(
inTemplate(recoloredDocument, '[data-hf-id="title-mask"]')?.getAttribute("style"),
).toBeNull();
expect(recolored.html).not.toContain('data-hf-id="title-host-a"');
expect(indexSource).not.toContain("#12b886");
});
});
@@ -739,6 +739,40 @@ describe("resolveDomEditSelection", () => {
expect(selection?.selector).toBe("#copy");
});
it("keeps a transparent overflow mask structural when directly selecting its headline", async () => {
const document = createDocument(`
<template id="source-template"></template>
<section class="hl-block">
<div class="hl-mask" style="overflow: hidden; background: transparent">
<h1 class="hl-text">Launch title</h1>
</div>
</section>
`);
const headline = document.querySelector<HTMLElement>(".hl-text")!;
setElementRect(headline, { left: 44, top: 52, width: 220, height: 48 });
const selection = await resolveDomEditSelection(headline, {
activeCompositionPath: "index.html",
isMasterView: false,
preferClipAncestor: false,
});
expect(selection?.element).toBe(headline);
expect(selection?.selector).toBe(".hl-text");
expect(selection?.textFields).toMatchObject([{ source: "self", tagName: "h1" }]);
expect(selection?.boundingBox).toEqual({ x: 44, y: 52, width: 220, height: 48 });
// Explicit layer navigation remains free to resolve the structural mask.
const mask = document.querySelector<HTMLElement>(".hl-mask")!;
expect(
(
await resolveDomEditSelection(mask, {
activeCompositionPath: "index.html",
isMasterView: false,
preferClipAncestor: false,
})
)?.element,
).toBe(mask);
});
// fallow-ignore-next-line code-duplication
it("collects simple child text blocks as separate editable fields", async () => {
const document = createDocument(`
@@ -11,6 +11,15 @@ afterEach(() => {
document.body.innerHTML = "";
});
function renderColorField(onCommit: (value: string) => void): void {
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
act(() => {
root.render(<ColorField flat label="Color" value="rgb(255, 176, 32)" onCommit={onCommit} />);
});
}
describe("ColorField flat trigger", () => {
it("renders label and value inline with a small swatch, no boxed border", () => {
const host = document.createElement("div");
@@ -25,4 +34,26 @@ describe("ColorField flat trigger", () => {
expect(host.textContent).toContain("Color");
act(() => root.unmount());
});
it("persists one keyboard slider gesture on keyup", () => {
const onCommit = vi.fn();
renderColorField(onCommit);
const trigger = document.querySelector<HTMLButtonElement>('[data-flat-color-trigger="true"]');
if (!trigger) throw new Error("Color trigger was not rendered");
act(() => {
trigger.click();
});
const hue = document.querySelector<HTMLElement>('[role="slider"][aria-label="Hue"]');
if (!hue) throw new Error("Hue slider was not rendered");
act(() => {
hue.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "ArrowRight" }));
});
expect(onCommit).not.toHaveBeenCalled();
act(() => {
hue.dispatchEvent(new KeyboardEvent("keyup", { bubbles: true, key: "ArrowRight" }));
});
expect(onCommit).toHaveBeenCalledOnce();
});
});
@@ -12,6 +12,7 @@ import {
import { resolveFloatingPanelPosition, type FloatingPosition } from "./floatingPanel";
import { colorFromCss, FIELD, LABEL } from "./propertyPanelHelpers";
import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
import { useInspectorGestureTransaction } from "./useInspectorGestureTransaction";
const COLOR_PICKER_SIZE = { width: 292, height: 386 };
@@ -29,7 +30,10 @@ function ColorSlider({
background,
thumbColor,
disabled,
onCommit,
onPreview,
onInteractionStart,
onInteractionEnd,
onInteractionCancel,
}: {
label: string;
value: number;
@@ -40,21 +44,24 @@ function ColorSlider({
background: string;
thumbColor: string;
disabled?: boolean;
onCommit: (nextValue: number) => void;
onPreview: (nextValue: number) => void;
onInteractionStart: () => void;
onInteractionEnd: () => void;
onInteractionCancel: () => void;
}) {
const trackRef = useRef<HTMLDivElement | null>(null);
const percent = ((value - min) / (max - min)) * 100;
const commitFromClientX = (clientX: number) => {
const previewFromClientX = (clientX: number) => {
const rect = trackRef.current?.getBoundingClientRect();
if (!rect || rect.width <= 0) return;
const rawValue = min + ((clientX - rect.left) / rect.width) * (max - min);
const stepped = Math.round(rawValue / step) * step;
onCommit(Math.max(min, Math.min(max, stepped)));
onPreview(Math.max(min, Math.min(max, stepped)));
};
const commitKeyboardValue = (nextValue: number) => {
onCommit(Math.max(min, Math.min(max, nextValue)));
const previewKeyboardValue = (nextValue: number) => {
onPreview(Math.max(min, Math.min(max, nextValue)));
};
return (
@@ -78,32 +85,52 @@ function ColorSlider({
style={{ background }}
onPointerDown={(event) => {
if (disabled) return;
onInteractionStart();
event.currentTarget.setPointerCapture(event.pointerId);
commitFromClientX(event.clientX);
previewFromClientX(event.clientX);
}}
onPointerUp={(event) => {
onInteractionEnd();
event.currentTarget.blur();
}}
onPointerCancel={onInteractionCancel}
onPointerMove={(event) => {
if (disabled || event.buttons !== 1) return;
commitFromClientX(event.clientX);
previewFromClientX(event.clientX);
}}
onKeyDown={(event) => {
if (disabled) return;
if (event.key === "Escape") {
event.preventDefault();
onInteractionCancel();
return;
}
if (event.key === "ArrowRight" || event.key === "ArrowUp") {
event.preventDefault();
commitKeyboardValue(value + step);
onInteractionStart();
previewKeyboardValue(value + step);
} else if (event.key === "ArrowLeft" || event.key === "ArrowDown") {
event.preventDefault();
commitKeyboardValue(value - step);
onInteractionStart();
previewKeyboardValue(value - step);
} else if (event.key === "Home") {
event.preventDefault();
commitKeyboardValue(min);
onInteractionStart();
previewKeyboardValue(min);
} else if (event.key === "End") {
event.preventDefault();
commitKeyboardValue(max);
onInteractionStart();
previewKeyboardValue(max);
}
}}
onKeyUp={(event) => {
if (
["ArrowRight", "ArrowUp", "ArrowLeft", "ArrowDown", "Home", "End"].includes(event.key)
) {
onInteractionEnd();
}
}}
onBlur={onInteractionEnd}
>
<div
className="pointer-events-none absolute top-1/2 h-6 w-6 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white shadow-[0_0_0_1px_rgba(0,0,0,0.85),0_6px_14px_rgba(0,0,0,0.5)]"
@@ -122,13 +149,19 @@ export function ColorField({
label,
value,
disabled,
onReset,
flat,
mixed,
onPreview,
onCommit,
}: {
label: string;
value: string;
disabled?: boolean;
onReset?: () => void;
flat?: boolean;
mixed?: boolean;
onPreview?: (nextValue: string) => void;
onCommit: (nextValue: string) => void;
}) {
const track = useTrackDesignInput();
@@ -137,6 +170,8 @@ export function ColorField({
const [open, setOpen] = useState(false);
const [panelPosition, setPanelPosition] = useState<FloatingPosition | null>(null);
const [draftColor, setDraftColor] = useState<ParsedColor>(() => colorFromCss(value));
const draftColorRef = useRef(draftColor);
draftColorRef.current = draftColor;
const [hexDraft, setHexDraft] = useState(() => toHexColor(colorFromCss(value)).toUpperCase());
const hsv = rgbToHsv(draftColor);
const hueColor = formatCssColor({
@@ -149,6 +184,33 @@ export function ColorField({
const brightnessPercent = Math.round(hsv.value * 100);
const alphaPercent = Math.round(draftColor.alpha * 100);
const updateColorDraft = useCallback((nextValue: string) => {
const nextColor = parseCssColor(nextValue);
if (!nextColor) return;
setDraftColor(nextColor);
setHexDraft(toHexColor(nextColor).toUpperCase());
}, []);
const persistColorValue = useCallback(
(nextValue: string) => {
if (nextValue !== value) track("color", label);
onCommit(nextValue);
},
[label, onCommit, track, value],
);
const {
begin: beginColorGesture,
preview: previewColorGesture,
settle: settleColorGesture,
cancel: cancelColorGesture,
} = useInspectorGestureTransaction({
sourceValue: value,
onPreview: (nextValue) => {
updateColorDraft(nextValue);
onPreview?.(nextValue);
},
onCommit: persistColorValue,
});
useEffect(() => {
const nextColor = colorFromCss(value);
setDraftColor(nextColor);
@@ -189,10 +251,14 @@ export function ColorField({
const target = event.target as Node | null;
if (!target) return;
if (panelRef.current?.contains(target) || buttonRef.current?.contains(target)) return;
settleColorGesture();
setOpen(false);
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") setOpen(false);
if (event.key === "Escape") {
cancelColorGesture();
setOpen(false);
}
};
document.addEventListener("pointerdown", handlePointerDown);
document.addEventListener("keydown", handleKeyDown);
@@ -200,14 +266,10 @@ export function ColorField({
document.removeEventListener("pointerdown", handlePointerDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [open]);
}, [cancelColorGesture, open, settleColorGesture]);
const commitColor = (nextColor: ParsedColor) => {
setDraftColor(nextColor);
setHexDraft(toHexColor(nextColor).toUpperCase());
const nextValue = formatCssColor(nextColor);
if (nextValue !== value) track("color", label);
onCommit(nextValue);
const previewColor = (nextColor: ParsedColor) => {
previewColorGesture(formatCssColor(nextColor));
};
const commitHsv = (nextHsv: { hue?: number; saturation?: number; value?: number }) => {
@@ -216,7 +278,7 @@ export function ColorField({
saturation: nextHsv.saturation ?? hsv.saturation,
value: nextHsv.value ?? hsv.value,
});
commitColor({ ...rgb, alpha: draftColor.alpha });
previewColor({ ...rgb, alpha: draftColorRef.current.alpha });
};
const updateSaturationValue = (clientX: number, clientY: number, target: HTMLDivElement) => {
@@ -231,7 +293,8 @@ export function ColorField({
const normalized = nextHex.trim().startsWith("#") ? nextHex.trim() : `#${nextHex.trim()}`;
const parsed = parseCssColor(normalized);
if (!parsed) return;
commitColor({ ...parsed, alpha: draftColor.alpha });
const nextValue = formatCssColor({ ...parsed, alpha: draftColorRef.current.alpha });
updateColorDraft(nextValue);
};
const picker = open
@@ -251,7 +314,10 @@ export function ColorField({
</div>
<button
type="button"
onClick={() => setOpen(false)}
onClick={() => {
settleColorGesture();
setOpen(false);
}}
className="flex h-7 w-7 items-center justify-center rounded-lg text-neutral-500 transition-colors hover:bg-neutral-900 hover:text-neutral-200"
aria-label="Close color picker"
>
@@ -263,6 +329,7 @@ export function ColorField({
className="relative h-36 cursor-crosshair overflow-hidden rounded-xl border border-neutral-700 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.06)]"
style={{ backgroundColor: hueColor }}
onPointerDown={(event) => {
beginColorGesture();
event.currentTarget.setPointerCapture(event.pointerId);
updateSaturationValue(event.clientX, event.clientY, event.currentTarget);
}}
@@ -270,6 +337,8 @@ export function ColorField({
if (event.buttons !== 1) return;
updateSaturationValue(event.clientX, event.clientY, event.currentTarget);
}}
onPointerUp={settleColorGesture}
onPointerCancel={cancelColorGesture}
>
<div className="absolute inset-0 bg-gradient-to-r from-white to-transparent" />
<div className="absolute inset-0 bg-gradient-to-t from-black to-transparent" />
@@ -316,7 +385,10 @@ export function ColorField({
background="linear-gradient(90deg, #f00, #ff0, #0f0, #0ff, #00f, #f0f, #f00)"
thumbColor={hueColor}
disabled={disabled}
onCommit={(nextHue) => commitHsv({ hue: nextHue })}
onInteractionStart={beginColorGesture}
onPreview={(nextHue) => commitHsv({ hue: nextHue })}
onInteractionEnd={settleColorGesture}
onInteractionCancel={cancelColorGesture}
/>
<ColorSlider
@@ -329,7 +401,12 @@ export function ColorField({
background={`linear-gradient(90deg, transparent, ${opaqueColor})`}
thumbColor={currentColor}
disabled={disabled}
onCommit={(nextAlpha) => commitColor({ ...draftColor, alpha: nextAlpha })}
onInteractionStart={beginColorGesture}
onPreview={(nextAlpha) =>
previewColor({ ...draftColorRef.current, alpha: nextAlpha })
}
onInteractionEnd={settleColorGesture}
onInteractionCancel={cancelColorGesture}
/>
<label className="grid gap-1.5">
@@ -337,6 +414,20 @@ export function ColorField({
<input
value={hexDraft}
onChange={(event) => handleHexCommit(event.target.value)}
onBlur={() => {
const normalized = hexDraft.trim().startsWith("#")
? hexDraft.trim()
: `#${hexDraft.trim()}`;
const parsed = parseCssColor(normalized);
if (parsed) {
const nextValue = formatCssColor({
...parsed,
alpha: draftColorRef.current.alpha,
});
persistColorValue(nextValue);
}
setHexDraft(toHexColor(draftColorRef.current).toUpperCase());
}}
className={`${FIELD} h-10 w-full text-[11px] font-medium outline-none`}
spellCheck={false}
/>
@@ -349,6 +440,7 @@ export function ColorField({
const openPicker = () => {
if (disabled) return;
if (open) settleColorGesture();
setOpen((current) => !current);
if (!open) {
requestAnimationFrame(updatePanelPosition);
@@ -370,9 +462,19 @@ export function ColorField({
>
<span
className="h-4 w-4 flex-shrink-0 rounded-[4px]"
style={{ backgroundColor: value || "transparent" }}
style={{ backgroundColor: open ? currentColor : value || "transparent" }}
/>
<span className="font-mono text-[11px] text-panel-text-0">{value}</span>
<span className="font-mono text-[11px] text-panel-text-0">
{open ? currentColor : value}
</span>
{mixed && (
<span
data-color-mixed-indicator="true"
className="rounded bg-panel-hover px-1.5 py-0.5 text-[9px] font-medium text-panel-text-4"
>
Mixed
</span>
)}
</button>
{picker}
</div>
@@ -381,7 +483,19 @@ export function ColorField({
return (
<div className="grid min-w-0 gap-1.5">
<span className={LABEL}>{label}</span>
<div className="flex items-center justify-between gap-2">
<span className={LABEL}>{label}</span>
{onReset && (
<button
type="button"
disabled={disabled}
onClick={onReset}
className="rounded bg-panel-hover px-1.5 py-0.5 text-[9px] font-medium text-panel-text-4 transition-colors hover:text-panel-text-0 disabled:cursor-not-allowed disabled:opacity-40"
>
Reset
</button>
)}
</div>
<button
type="button"
disabled={disabled}
@@ -397,6 +511,14 @@ export function ColorField({
<span className="min-w-0 flex-1 truncate text-[11px] font-medium text-neutral-100">
{value}
</span>
{mixed && (
<span
data-color-mixed-indicator="true"
className="rounded bg-panel-hover px-1.5 py-0.5 text-[9px] font-medium text-panel-text-4"
>
Mixed
</span>
)}
</button>
{picker}
</div>
@@ -0,0 +1,178 @@
import { useEffect, useRef, useState } from "react";
import { adjustNumericToken, parseNumericToken } from "./propertyPanelHelpers";
import { useInspectorGestureTransaction } from "./useInspectorGestureTransaction";
function arrowDirection(key: string): 1 | -1 | null {
if (key === "ArrowUp") return 1;
if (key === "ArrowDown") return -1;
return null;
}
export function CommitField({
value,
disabled,
liveCommit,
align = "left",
onPreview,
onCommit,
}: {
value: string;
disabled?: boolean;
liveCommit?: boolean;
align?: "left" | "right";
onPreview?: (nextValue: string) => void;
onCommit: (nextValue: string) => void;
}) {
const [draft, setDraft] = useState(value);
const valueRef = useRef(value);
const draftRef = useRef(draft);
const inputRef = useRef<HTMLInputElement>(null);
const focusedRef = useRef(false);
const dirtyRef = useRef(false);
valueRef.current = value;
draftRef.current = draft;
const gestureActiveRef = useRef(false);
const gestureSettleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const gestureTransaction = useInspectorGestureTransaction({
sourceValue: value,
onPreview: (nextValue) => {
setDraft(nextValue);
onPreview?.(nextValue);
},
onCommit,
});
const gestureTransactionRef = useRef(gestureTransaction);
gestureTransactionRef.current = gestureTransaction;
const clearGestureSettleTimer = () => {
if (!gestureSettleTimerRef.current) return;
clearTimeout(gestureSettleTimerRef.current);
gestureSettleTimerRef.current = null;
};
const settleGesture = () => {
clearGestureSettleTimer();
if (!gestureActiveRef.current) return false;
gestureActiveRef.current = false;
gestureTransaction.settle();
return true;
};
const scheduleGestureSettle = () => {
clearGestureSettleTimer();
gestureSettleTimerRef.current = setTimeout(() => {
gestureSettleTimerRef.current = null;
if (!gestureActiveRef.current) return;
gestureActiveRef.current = false;
gestureTransactionRef.current.settle();
}, 250);
};
const cancelGesture = () => {
clearGestureSettleTimer();
gestureActiveRef.current = false;
gestureTransaction.cancel();
};
const commitDraft = (nextValue: string) => {
setDraft(nextValue);
onPreview?.(nextValue);
if (nextValue !== valueRef.current) onCommit(nextValue);
};
const cancelGestureFromKeyEvent = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (!gestureActiveRef.current) return false;
event.preventDefault();
event.stopPropagation();
cancelGesture();
return true;
};
const previewNumericKeyStep = (event: React.KeyboardEvent<HTMLInputElement>) => {
const direction = arrowDirection(event.key);
if (direction === null) return;
const nextDraft = adjustNumericToken(draftRef.current, direction, event);
if (!nextDraft) return;
event.preventDefault();
dirtyRef.current = false;
gestureActiveRef.current = true;
gestureTransaction.preview(nextDraft);
scheduleGestureSettle();
};
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "z") {
cancelGestureFromKeyEvent(event);
return;
}
if (event.key === "Escape") {
cancelGestureFromKeyEvent(event);
return;
}
if (event.key === "Enter") {
event.currentTarget.blur();
return;
}
previewNumericKeyStep(event);
};
useEffect(() => {
if (focusedRef.current && dirtyRef.current) return;
setDraft(value);
}, [value]);
useEffect(() => {
const el = inputRef.current;
if (!el) return;
const handler = (event: WheelEvent) => {
if (disabled || document.activeElement !== el) return;
const delta = event.deltaY === 0 ? event.deltaX : event.deltaY;
if (delta === 0) return;
const nextDraft = adjustNumericToken(draftRef.current, delta < 0 ? 1 : -1, event);
if (!nextDraft) return;
event.preventDefault();
event.stopPropagation();
dirtyRef.current = false;
gestureActiveRef.current = true;
gestureTransactionRef.current.preview(nextDraft);
scheduleGestureSettle();
};
el.addEventListener("wheel", handler, { passive: false });
return () => {
el.removeEventListener("wheel", handler);
clearGestureSettleTimer();
};
}, [disabled]);
return (
<input
ref={inputRef}
type="text"
value={draft}
disabled={disabled}
onFocus={() => {
focusedRef.current = true;
}}
onChange={(event) => {
settleGesture();
dirtyRef.current = true;
setDraft(event.target.value);
if (liveCommit) onPreview?.(event.target.value);
}}
onBlur={() => {
if (settleGesture()) {
focusedRef.current = false;
return;
}
const wasDirty = dirtyRef.current;
focusedRef.current = false;
dirtyRef.current = false;
if (wasDirty && (!liveCommit || parseNumericToken(draft))) {
commitDraft(draft);
} else {
setDraft(valueRef.current);
if (wasDirty && liveCommit) onPreview?.(valueRef.current);
}
}}
onKeyDown={handleKeyDown}
title={parseNumericToken(value) ? "Scroll or use Arrow keys to adjust" : undefined}
className={`min-w-0 w-full bg-transparent text-[11px] font-medium text-neutral-100 outline-none disabled:cursor-not-allowed disabled:text-neutral-600 ${
align === "right" ? "text-right" : "text-left"
}`}
/>
);
}
@@ -89,6 +89,40 @@ describe("FlatRow", () => {
expect(onCommit).toHaveBeenCalledWith("24px");
act(() => root.unmount());
});
it("persists a rapid numeric arrow-key burst as one commit", () => {
vi.useFakeTimers();
const onCommit = vi.fn();
const onPreview = vi.fn();
const { host, root } = renderInto(
<FlatRow
label="Size"
value="22px"
tier="explicitDefault"
liveCommit
onPreview={onPreview}
onCommit={onCommit}
/>,
);
const input = host.querySelector<HTMLInputElement>("input");
if (!input) throw new Error("expected an input");
for (let step = 0; step < 8; step += 1) {
act(() => {
input.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "ArrowUp" }));
});
}
expect(input.value).toBe("30px");
expect(onPreview).toHaveBeenLastCalledWith("30px");
expect(onCommit).not.toHaveBeenCalled();
act(() => vi.advanceTimersByTime(250));
expect(onCommit).toHaveBeenCalledOnce();
expect(onCommit).toHaveBeenCalledWith("30px");
act(() => root.unmount());
vi.useRealTimers();
});
});
describe("FlatSegmentedRow", () => {
@@ -20,6 +20,7 @@ export function FlatRow({
liveCommit,
suffix,
dropdown,
onPreview,
onCommit,
onReset,
}: {
@@ -31,6 +32,7 @@ export function FlatRow({
suffix?: ReactNode;
/** Renders a trailing 10px caret-down, for select-backed rows. */
dropdown?: boolean;
onPreview?: (nextValue: string) => void;
onCommit: (nextValue: string) => void;
onReset?: () => void;
}) {
@@ -52,6 +54,7 @@ export function FlatRow({
disabled={disabled}
liveCommit={liveCommit}
align="right"
onPreview={onPreview}
onCommit={(nextValue) => {
track("metric", label);
onCommit(nextValue);
@@ -238,7 +238,11 @@ describe("FlatStyleSection — Stroke and Radius", () => {
(input) => !host.contains(input),
);
if (!hexInput) throw new Error("expected the color picker's hex input");
act(() => setInputValue(hexInput, "#112233"));
act(() => {
setInputValue(hexInput, "#112233");
hexInput.focus();
hexInput.blur();
});
expect(onSetStyle).toHaveBeenCalledWith("border-color", "rgb(17, 34, 51)");
act(() => root.unmount());
});
@@ -43,6 +43,7 @@ function FlatFillFields({
styles,
assets,
onSetStyle,
onPreviewStyle,
onImportAssets,
}: {
projectId: string;
@@ -50,6 +51,7 @@ function FlatFillFields({
styles: Record<string, string>;
assets: string[];
onSetStyle: (prop: string, value: string) => void | Promise<void>;
onPreviewStyle?: (prop: string, value: string) => void;
onImportAssets?: (files: FileList) => Promise<string[]>;
}) {
const styleEditingDisabled = !element.capabilities.canEditStyles;
@@ -105,6 +107,7 @@ function FlatFillFields({
label="Color"
value={styles["background-color"] ?? "transparent"}
disabled={styleEditingDisabled}
onPreview={(next) => onPreviewStyle?.("background-color", next)}
onCommit={(next) => onSetStyle("background-color", next)}
/>
) : preferredFillMode === "Gradient" ? (
@@ -453,6 +456,7 @@ export function FlatStyleSection({
styles,
assets,
onSetStyle,
onPreviewStyle,
onImportAssets,
gsapBorderRadius,
}: {
@@ -461,6 +465,7 @@ export function FlatStyleSection({
styles: Record<string, string>;
assets: string[];
onSetStyle: (prop: string, value: string) => void | Promise<void>;
onPreviewStyle?: (prop: string, value: string) => void;
onImportAssets?: (files: FileList) => Promise<string[]>;
gsapBorderRadius?: { tl: number; tr: number; br: number; bl: number } | null;
}) {
@@ -473,6 +478,7 @@ export function FlatStyleSection({
styles={styles}
assets={assets}
onSetStyle={onSetStyle}
onPreviewStyle={onPreviewStyle}
onImportAssets={onImportAssets}
/>
<FlatStrokeRow styles={styles} disabled={styleEditingDisabled} onSetStyle={onSetStyle} />
@@ -256,8 +256,9 @@ describe("FlatTextFieldEditor controls", () => {
act(() => root.unmount());
});
it("live-commits the Size field on input, without requiring blur/Enter", async () => {
it("previews Size input immediately and persists once on blur", () => {
const onSetTextFieldStyle = vi.fn();
const onPreviewTextFieldStyle = vi.fn();
const { host, root } = renderInto(
<FlatTextSection
element={makeSingleFieldElement()}
@@ -265,6 +266,7 @@ describe("FlatTextFieldEditor controls", () => {
fontAssets={[]}
onSetText={vi.fn()}
onSetTextFieldStyle={onSetTextFieldStyle}
onPreviewTextFieldStyle={onPreviewTextFieldStyle}
onAddTextField={vi.fn()}
onRemoveTextField={vi.fn()}
/>,
@@ -275,6 +277,7 @@ describe("FlatTextFieldEditor controls", () => {
const input = sizeLabel?.parentElement?.querySelector<HTMLInputElement>("input");
if (!input) throw new Error("expected the Size row's input");
act(() => {
input.focus();
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
"value",
@@ -282,9 +285,10 @@ describe("FlatTextFieldEditor controls", () => {
nativeInputValueSetter?.call(input, "24px");
input.dispatchEvent(new Event("input", { bubbles: true }));
});
// liveCommit debounces on a 120ms timer — no blur/Enter dispatched here.
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 160));
expect(onPreviewTextFieldStyle).toHaveBeenCalledWith("a", "font-size", "24px");
expect(onSetTextFieldStyle).not.toHaveBeenCalled();
act(() => {
input.blur();
});
expect(onSetTextFieldStyle).toHaveBeenCalledWith("a", "font-size", "24px");
act(() => root.unmount());
@@ -47,6 +47,7 @@ function FlatTextFieldEditor({
onImportFonts,
onSetText,
onSetTextFieldStyle,
onPreviewTextFieldStyle,
autoFocus = false,
}: {
field: DomEditSelection["textFields"][number];
@@ -55,6 +56,7 @@ function FlatTextFieldEditor({
onImportFonts?: (files: FileList | File[]) => Promise<ImportedFontAsset[]>;
onSetText: (value: string, fieldKey?: string) => void;
onSetTextFieldStyle: (fieldKey: string, property: string, value: string) => void;
onPreviewTextFieldStyle?: (fieldKey: string, property: string, value: string) => void;
autoFocus?: boolean;
}) {
const track = useTrackDesignInput();
@@ -100,6 +102,7 @@ function FlatTextFieldEditor({
value={field.computedStyles["font-size"] || styles["font-size"] || "16px"}
tier={resolveValueTier(field.inlineStyles["font-size"], styles["font-size"] || "16px")}
liveCommit
onPreview={(next) => onPreviewTextFieldStyle?.(field.key, "font-size", next)}
onCommit={(next) => onSetTextFieldStyle(field.key, "font-size", next)}
/>
<div className="flex min-h-[30px] items-center justify-between">
@@ -220,6 +223,7 @@ function FlatTextFieldEditor({
flat
label="Color"
value={value ?? getTextFieldColor(field, styles)}
onPreview={(next) => onPreviewTextFieldStyle?.(field.key, "color", next)}
onCommit={onCommit ?? ((next) => onSetTextFieldStyle(field.key, "color", next))}
/>
)}
@@ -235,6 +239,7 @@ export function FlatTextSection({
onImportFonts,
onSetText,
onSetTextFieldStyle,
onPreviewTextFieldStyle,
onAddTextField,
onRemoveTextField,
}: {
@@ -244,6 +249,7 @@ export function FlatTextSection({
onImportFonts?: (files: FileList | File[]) => Promise<ImportedFontAsset[]>;
onSetText: (value: string, fieldKey?: string) => void;
onSetTextFieldStyle: (fieldKey: string, property: string, value: string) => void;
onPreviewTextFieldStyle?: (fieldKey: string, property: string, value: string) => void;
onAddTextField: (afterFieldKey?: string) => string | Promise<string | null> | null;
onRemoveTextField: (fieldKey: string) => void;
}) {
@@ -288,6 +294,7 @@ export function FlatTextSection({
onImportFonts={onImportFonts}
onSetText={onSetText}
onSetTextFieldStyle={onSetTextFieldStyle}
onPreviewTextFieldStyle={onPreviewTextFieldStyle}
autoFocus
/>
</div>
@@ -303,6 +310,7 @@ export function FlatTextSection({
onImportFonts={onImportFonts}
onSetText={onSetText}
onSetTextFieldStyle={onSetTextFieldStyle}
onPreviewTextFieldStyle={onPreviewTextFieldStyle}
/>
<button
type="button"
@@ -356,7 +356,10 @@ describe.each(["classic", "flat"] as const)("shared %s input telemetry", (ui) =>
(input) => input.value === "#FF0000",
);
if (!hex) throw new Error("expected color hex input");
act(() => changeInput(hex, "#00FF00"));
act(() => {
changeInput(hex, "#00FF00");
blurInput(hex);
});
expect(trackStudioEvent).toHaveBeenCalledTimes(1);
expect(trackStudioEvent).toHaveBeenLastCalledWith("design_input", {
@@ -3,107 +3,10 @@ import {
DesignPanelInputProvider,
useTrackDesignInput,
} from "../../contexts/DesignPanelInputContext";
import { adjustNumericToken, FIELD, LABEL, parseNumericToken } from "./propertyPanelHelpers";
import { FIELD, LABEL } from "./propertyPanelHelpers";
import { CommitField } from "./propertyPanelCommitField";
export function CommitField({
value,
disabled,
liveCommit,
align = "left",
onCommit,
}: {
value: string;
disabled?: boolean;
liveCommit?: boolean;
/** The legacy panel lays out label-then-value inline (left reads naturally);
* the flat inspector lays out labelvalue across a `justify-between` row,
* where a left-aligned value looks stranded at the edge of its own
* right-hand box instead of lining up with every other row's value. */
align?: "left" | "right";
onCommit: (nextValue: string) => void;
}) {
const [draft, setDraft] = useState(value);
const commitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const valueRef = useRef(value);
const draftRef = useRef(draft);
const inputRef = useRef<HTMLInputElement>(null);
valueRef.current = value;
draftRef.current = draft;
useEffect(() => {
setDraft(value);
}, [value]);
useEffect(() => {
const el = inputRef.current;
if (!el) return;
const handler = (e: WheelEvent) => {
if (disabled || document.activeElement !== el) return;
const delta = e.deltaY === 0 ? e.deltaX : e.deltaY;
if (delta === 0) return;
const nextDraft = adjustNumericToken(draftRef.current, delta < 0 ? 1 : -1, e);
if (!nextDraft) return;
e.preventDefault();
e.stopPropagation();
setDraft(nextDraft);
scheduleCommitRef.current(nextDraft);
};
el.addEventListener("wheel", handler, { passive: false });
return () => el.removeEventListener("wheel", handler);
}, [disabled]);
useEffect(
() => () => {
if (commitTimerRef.current) clearTimeout(commitTimerRef.current);
},
[],
);
const commitDraft = (nextDraft: string) => {
if (commitTimerRef.current) clearTimeout(commitTimerRef.current);
if (nextDraft !== valueRef.current) onCommit(nextDraft);
};
const scheduleCommit = (nextDraft: string) => {
if (commitTimerRef.current) clearTimeout(commitTimerRef.current);
commitTimerRef.current = setTimeout(() => {
if (nextDraft !== valueRef.current) onCommit(nextDraft);
}, 120);
};
const scheduleCommitRef = useRef(scheduleCommit);
scheduleCommitRef.current = scheduleCommit;
return (
<input
ref={inputRef}
type="text"
value={draft}
disabled={disabled}
onChange={(e) => {
setDraft(e.target.value);
if (liveCommit) scheduleCommit(e.target.value);
}}
onBlur={() => commitDraft(draft)}
onKeyDown={(e) => {
if (e.key === "Enter") {
(e.target as HTMLInputElement).blur();
return;
}
if (e.key !== "ArrowUp" && e.key !== "ArrowDown") return;
const nextDraft = adjustNumericToken(draft, e.key === "ArrowUp" ? 1 : -1, e);
if (!nextDraft) return;
e.preventDefault();
setDraft(nextDraft);
scheduleCommit(nextDraft);
}}
title={parseNumericToken(value) ? "Scroll or use Arrow keys to adjust" : undefined}
className={`min-w-0 w-full bg-transparent text-[11px] font-medium text-neutral-100 outline-none disabled:cursor-not-allowed disabled:text-neutral-600 ${
align === "right" ? "text-right" : "text-left"
}`}
/>
);
}
export { CommitField } from "./propertyPanelCommitField";
/* ------------------------------------------------------------------ */
/* MetricField */
@@ -32,6 +32,7 @@ export interface PropertyPanelProps {
onClearSelection: () => void;
onUngroup?: () => void;
onSetStyle: (prop: string, value: string) => void | Promise<void>;
onPreviewStyle?: (prop: string, value: string) => void;
onSetAttribute: (attr: string, value: string) => void | Promise<void>;
/** Commits several data-* attributes on the SAME element in ONE atomic
* persist call e.g. a pinned timing range's start+duration together, so
@@ -62,6 +63,7 @@ export interface PropertyPanelProps {
onSetManualRotation: (element: DomEditSelection, next: { angle: number }) => void;
onSetText: (value: string, fieldKey?: string) => void;
onSetTextFieldStyle: (fieldKey: string, property: string, value: string) => void;
onPreviewTextFieldStyle?: (fieldKey: string, property: string, value: string) => void;
onAddTextField: (afterFieldKey?: string) => string | Promise<string | null> | null;
onRemoveTextField: (fieldKey: string) => void;
onAskAgent: () => void;
@@ -0,0 +1,37 @@
// @vitest-environment happy-dom
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { describe, expect, it, vi } from "vitest";
import { useInspectorGestureTransaction } from "./useInspectorGestureTransaction";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
describe("useInspectorGestureTransaction", () => {
it("keeps a new gesture active when the prior async commit is acknowledged", () => {
const host = document.createElement("div");
const root = createRoot(host);
const onPreview = vi.fn();
const onCommit = vi.fn();
let gesture: ReturnType<typeof useInspectorGestureTransaction<number>> | null = null;
function Probe({ sourceValue }: { sourceValue: number }) {
gesture = useInspectorGestureTransaction({ sourceValue, onPreview, onCommit });
return null;
}
act(() => root.render(<Probe sourceValue={10} />));
act(() => {
gesture?.preview(20);
gesture?.settle();
gesture?.preview(30);
});
act(() => root.render(<Probe sourceValue={20} />));
expect(onPreview).toHaveBeenLastCalledWith(30);
act(() => gesture?.settle());
expect(onCommit.mock.calls.map(([value]) => value)).toEqual([20, 30]);
act(() => root.unmount());
});
});
@@ -0,0 +1,60 @@
import { useCallback, useEffect, useRef } from "react";
/** One owner for continuous inspector edits: preview freely, persist once. */
export function useInspectorGestureTransaction<T>({
sourceValue,
onPreview,
onCommit,
}: {
sourceValue: T;
onPreview: (value: T) => void;
onCommit: (value: T) => void;
}) {
const sourceRef = useRef(sourceValue);
const activeRef = useRef<{ before: T; latest: T } | null>(null);
const previewRef = useRef(onPreview);
const commitRef = useRef(onCommit);
if (!activeRef.current) sourceRef.current = sourceValue;
previewRef.current = onPreview;
commitRef.current = onCommit;
const begin = useCallback(() => {
if (!activeRef.current) {
activeRef.current = { before: sourceRef.current, latest: sourceRef.current };
}
}, []);
const preview = useCallback((value: T) => {
if (!activeRef.current) {
activeRef.current = { before: sourceRef.current, latest: sourceRef.current };
}
activeRef.current.latest = value;
previewRef.current(value);
}, []);
const settle = useCallback(() => {
const active = activeRef.current;
activeRef.current = null;
if (active && !Object.is(active.before, active.latest)) {
sourceRef.current = active.latest;
// Restore the captured baseline before the persistent commit captures
// rollback state. The commit reapplies `latest` synchronously, so this
// is not visible but a failed save can now correctly restore `before`.
previewRef.current(active.before);
commitRef.current(active.latest);
}
}, []);
const cancel = useCallback(() => {
const active = activeRef.current;
activeRef.current = null;
if (active && !Object.is(active.before, active.latest)) {
sourceRef.current = active.before;
previewRef.current(active.before);
}
}, []);
useEffect(() => cancel, [cancel]);
return { begin, preview, settle, cancel, activeRef };
}
@@ -96,6 +96,10 @@ export interface TimelinePaneProps {
blockName: string,
placement: Pick<TimelineElement, "start" | "track">,
) => Promise<void> | void;
onCompositionDrop?: (
sourcePath: string,
placement: Pick<TimelineElement, "start" | "track">,
) => Promise<void> | void;
onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
onSelectTimelineElement?: (element: TimelineElement | null) => void;
}
@@ -109,6 +113,7 @@ export function TimelinePane({
onDeleteElement,
onAssetDrop,
onBlockDrop,
onCompositionDrop,
onBlockedEditAttempt,
onSelectTimelineElement,
}: TimelinePaneProps) {
@@ -273,6 +278,7 @@ export function TimelinePane({
onDeleteElement={handleDeleteElement}
onAssetDrop={onAssetDrop}
onBlockDrop={onBlockDrop}
onCompositionDrop={onCompositionDrop}
onMoveElement={handleMoveElement}
onMoveElements={handleMoveElements}
onResizeElement={handleResizeElement}
@@ -0,0 +1,87 @@
// @vitest-environment happy-dom
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import { TIMELINE_COMPOSITION_MIME } from "../../utils/timelineCompositionDrop";
import { CompositionsTab } from "./CompositionsTab";
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
(
window as unknown as { happyDOM: { settings: { disableIframePageLoading: boolean } } }
).happyDOM.settings.disableIframePageLoading = true;
let root: Root | null = null;
afterEach(() => {
if (root) act(() => root?.unmount());
root = null;
document.body.innerHTML = "";
});
function mount(onSelect = vi.fn(), onAddToTimeline = vi.fn()) {
const host = document.createElement("div");
document.body.append(host);
root = createRoot(host);
act(() => {
root?.render(
<CompositionsTab
projectId="demo"
compositions={["compositions/headline.html"]}
activeComposition={null}
onSelect={onSelect}
onAddToTimeline={onAddToTimeline}
/>,
);
});
const card = host.querySelector<HTMLElement>('[draggable="true"]');
if (!card) throw new Error("composition card did not render");
return { host, card, onSelect, onAddToTimeline };
}
describe("composition card drag", () => {
it("keeps ordinary click navigation", () => {
const { card, onSelect } = mount();
act(() => card.click());
expect(onSelect).toHaveBeenCalledWith("compositions/headline.html");
});
it("emits only source identity and suppresses the click following a drag", () => {
const { card, onSelect } = mount();
const data = new Map<string, string>();
const event = new Event("dragstart", { bubbles: true });
Object.defineProperty(event, "dataTransfer", {
value: {
effectAllowed: "none",
setData: (type: string, value: string) => data.set(type, value),
},
});
act(() => {
card.dispatchEvent(event);
card.click();
});
expect(JSON.parse(data.get(TIMELINE_COMPOSITION_MIME) ?? "null")).toEqual({
sourcePath: "compositions/headline.html",
});
expect(card.className).toContain("select-none");
expect(onSelect).not.toHaveBeenCalled();
});
it("offers pointer and keyboard add-at-playhead actions without opening the card", () => {
const { host, onSelect, onAddToTimeline } = mount();
const add = host.querySelector<HTMLButtonElement>(
'[aria-label="Add headline to timeline at playhead"]',
);
if (!add) throw new Error("add action did not render");
act(() => {
add.click();
add.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
add.click();
});
expect(onAddToTimeline).toHaveBeenCalledTimes(2);
expect(onAddToTimeline).toHaveBeenLastCalledWith("compositions/headline.html");
expect(onSelect).not.toHaveBeenCalled();
});
});
@@ -1,5 +1,6 @@
import { memo, useCallback, useEffect, useRef, useState } from "react";
import { setPreviewMediaMuted } from "../../player/lib/timelineIframeHelpers";
import { TIMELINE_COMPOSITION_MIME } from "../../utils/timelineCompositionDrop";
interface CompositionsTabProps {
projectId: string;
@@ -7,6 +8,7 @@ interface CompositionsTabProps {
activeComposition: string | null;
onSelect: (comp: string) => void;
onRenderComposition?: (comp: string) => void;
onAddToTimeline?: (comp: string) => void;
isRendering?: boolean;
lintFindingsByFile?: Map<string, { count: number; messages: string[] }>;
}
@@ -115,6 +117,7 @@ function CompCard({
onRender,
isRendering,
lintInfo,
onAddToTimeline,
}: {
projectId: string;
comp: string;
@@ -123,12 +126,14 @@ function CompCard({
onRender?: () => void;
isRendering?: boolean;
lintInfo?: { count: number; messages: string[] };
onAddToTimeline?: () => void;
}) {
const [hovered, setHovered] = useState(false);
const [stageSize, setStageSize] = useState(DEFAULT_PREVIEW_STAGE);
const iframeRef = useRef<HTMLIFrameElement | null>(null);
const hoverTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const syncTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const draggedRef = useRef(false);
const requestIframePlaybackSync = useCallback((shouldPlay: boolean) => {
if (syncTimer.current) {
@@ -179,10 +184,32 @@ function CompCard({
return (
<div
onClick={onSelect}
role="button"
tabIndex={0}
draggable
onDragStart={(event) => {
draggedRef.current = true;
event.dataTransfer.effectAllowed = "copy";
event.dataTransfer.setData(TIMELINE_COMPOSITION_MIME, JSON.stringify({ sourcePath: comp }));
}}
onDragEnd={() => {
window.setTimeout(() => {
draggedRef.current = false;
}, 0);
}}
onClick={() => {
if (!draggedRef.current) onSelect();
}}
onKeyDown={(event) => {
if (event.target !== event.currentTarget) return;
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onSelect();
}
}}
onPointerEnter={handleEnter}
onPointerLeave={handleLeave}
className={`group/card w-full text-left px-2 py-1.5 flex items-center gap-2.5 transition-colors cursor-pointer ${
className={`group/card w-full select-none text-left px-2 py-1.5 flex items-center gap-2.5 transition-colors cursor-grab active:cursor-grabbing ${
isActive
? "bg-studio-accent/10 border-l-2 border-studio-accent"
: "border-l-2 border-transparent hover:bg-neutral-800/50"
@@ -232,6 +259,20 @@ function CompCard({
</div>
<span className="text-[9px] text-neutral-600 truncate block">{comp}</span>
</div>
{onAddToTimeline && (
<button
type="button"
title={`Add ${name} to timeline at playhead`}
aria-label={`Add ${name} to timeline at playhead`}
onClick={(event) => {
event.stopPropagation();
onAddToTimeline();
}}
className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded text-neutral-600 opacity-0 transition-[color,background-color,opacity] hover:bg-neutral-800 hover:text-studio-accent group-hover/card:opacity-100 group-focus-within/card:opacity-100 focus:opacity-100"
>
<span aria-hidden="true">+</span>
</button>
)}
{onRender && (
<button
type="button"
@@ -274,6 +315,7 @@ export const CompositionsTab = memo(function CompositionsTab({
activeComposition,
onSelect,
onRenderComposition,
onAddToTimeline,
isRendering,
lintFindingsByFile,
}: CompositionsTabProps) {
@@ -295,6 +337,7 @@ export const CompositionsTab = memo(function CompositionsTab({
isActive={activeComposition === comp}
onSelect={() => onSelect(comp)}
onRender={onRenderComposition ? () => onRenderComposition(comp) : undefined}
onAddToTimeline={onAddToTimeline ? () => onAddToTimeline(comp) : undefined}
isRendering={isRendering}
lintInfo={lintFindingsByFile?.get(comp)}
/>
@@ -61,6 +61,7 @@ interface LeftSidebarProps {
onPreviewBlock?: (preview: BlockPreviewInfo | null) => void;
takeoverContent?: ReactNode;
onAddAssetToTimeline?: (path: string) => void;
onAddCompositionToTimeline?: (path: string) => void;
}
export const LeftSidebar = memo(
@@ -94,6 +95,7 @@ export const LeftSidebar = memo(
onPreviewBlock,
takeoverContent,
onAddAssetToTimeline,
onAddCompositionToTimeline,
},
ref,
) {
@@ -220,6 +222,7 @@ export const LeftSidebar = memo(
compositions={compositions}
activeComposition={activeComposition}
onSelect={onSelectComposition}
onAddToTimeline={onAddCompositionToTimeline}
onRenderComposition={onRenderComposition}
isRendering={isRendering}
lintFindingsByFile={lintFindingsByFile}
@@ -69,6 +69,10 @@ export function useStudioPlaybackContext(): StudioPlaybackValue {
return ctx;
}
export function useStudioPlaybackContextOptional(): StudioPlaybackValue | null {
return useContext(StudioPlaybackContext);
}
/** @deprecated Use useStudioShellContext and/or useStudioPlaybackContext instead. */
// fallow-ignore-next-line unused-export
export function useStudioContext(): StudioContextValue {
@@ -166,6 +166,14 @@ export function patchIframeDomTiming(
// Cross-origin or mid-navigation — file save is enqueued; iframe patch is best-effort.
}
}
export function playbackStartAttributeForElement(
element: Pick<TimelineElement, "kind" | "playbackStartAttr">,
): "data-media-start" | "data-playback-start" {
return element.playbackStartAttr === "playback-start" || element.kind === "composition"
? "data-playback-start"
: "data-media-start";
}
// fallow-ignore-next-line complexity
function resolveResizePlaybackStart(
original: string,
@@ -174,8 +182,7 @@ function resolveResizePlaybackStart(
updates: Pick<TimelineElement, "start" | "playbackStart">,
): { attrName: string; value: number } | null {
if (updates.playbackStart != null) {
const attrName =
element.playbackStartAttr === "playback-start" ? "playback-start" : "media-start";
const attrName = playbackStartAttributeForElement(element).slice("data-".length);
return { attrName, value: updates.playbackStart };
}
const trimDelta = updates.start - element.start;
@@ -185,8 +192,7 @@ function resolveResizePlaybackStart(
readAttributeByTarget(original, target, "media-start");
const current = raw != null ? parseFloat(raw) : undefined;
if (current == null || !Number.isFinite(current)) return null;
const attrName =
element.playbackStartAttr === "playback-start" ? "playback-start" : "media-start";
const attrName = playbackStartAttributeForElement(element).slice("data-".length);
return {
attrName,
value: Math.max(0, current + trimDelta * Math.max(element.playbackRate ?? 1, 0.1)),
@@ -208,13 +208,19 @@ export function usePreviewPersistence({
// attributes onto the live DOM and re-runs the timeline at the SAME playhead,
// falling back to reloadPreview for anything structural (split/delete undo),
// multi-file, sub-comp, or a permanent soft-reload failure.
applyUndoRestoreToPreview(
const strategy = applyUndoRestoreToPreview(
previewIframeRef.current,
activeCompPathRef.current,
restore.files,
usePlayerStore.getState().currentTime,
reloadPreview,
);
if (strategy === "full") {
const player = usePlayerStore.getState();
player.setElements([]);
player.setSelectedElementId(null);
player.setTimelineReady(false);
}
},
[previewIframeRef, activeCompPathRef, reloadPreview],
);
@@ -67,43 +67,36 @@ function mountRazorSplit(opts: { gsap?: boolean; previewStamp?: boolean } = {}):
onChange: () => {},
});
// Faithful stand-in for the studio-server file-mutation endpoints: the server
// writes the split to disk itself, then returns the patched content.
// Faithful stand-in for the atomic server cut: one forward file write and one
// response carrying the canonical history snapshots.
const fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
const u = String(url);
if (u.includes("/gsap-mutations/")) {
if (opts.gsap) {
// Mirror the server: rewrites the GSAP script for the new id, writes to
// disk, returns the final content.
disk["index.html"] = SPLIT_GSAP;
const version = `"test-gsap-${SPLIT_GSAP.length}"`;
return new Response(JSON.stringify({ ok: true, after: SPLIT_GSAP, version }), {
status: 200,
headers: { "Content-Type": "application/json", ETag: version },
});
}
// The fixture has no GSAP script — mirror the server's 400 response.
return new Response(JSON.stringify({ error: "no GSAP script found in file" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
if (u.includes("/file-mutations/split-element/")) {
disk["index.html"] = SPLIT;
const version = `"test-split-${SPLIT.length}"`;
if (u.includes("/file-mutations/split-batch")) {
const before = disk["index.html"];
disk["index.html"] = finalContent;
const version = `"test-cut-${finalContent.length}"`;
return new Response(
JSON.stringify({
ok: true,
changed: true,
content: SPLIT,
newId: "clip1-split",
version,
outcome: "committed",
files: [
{
path: "index.html",
before,
after: finalContent,
version,
writeToken: "test-cut",
splitCount: 1,
skippedSelectors: [],
},
],
}),
{ status: 200, headers: { "Content-Type": "application/json", ETag: version } },
{ status: 200, headers: { "Content-Type": "application/json" } },
);
}
if (u.includes("/files/")) {
return new Response(JSON.stringify({ content: disk["index.html"] }), {
const content = disk["index.html"];
return new Response(JSON.stringify({ content, version: `"test-${content.length}"` }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
@@ -17,19 +17,30 @@ function jsonResponse(body: unknown, status = 200): Response {
describe("useRazorSplit mutation versions", () => {
afterEach(() => vi.restoreAllMocks());
it("observes each out-of-band mutation version before the OCC writer runs", async () => {
it("observes the batch version without a redundant client forward write", async () => {
const original = '<div id="clip" data-start="0" data-duration="4">Clip</div>';
const htmlSplit =
'<div id="clip" data-start="0" data-duration="2">Clip</div><div id="clip-split" data-start="2" data-duration="2">Clip</div>';
const final = `${htmlSplit}<script>window.__timelines = {}</script>`;
vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => {
const url = String(input);
if (url.includes("/files/")) return jsonResponse({ content: original });
if (url.includes("/file-mutations/split-element/")) {
return jsonResponse({ ok: true, changed: true, content: htmlSplit, version: '"v-html"' });
}
if (url.includes("/gsap-mutations/")) {
return jsonResponse({ ok: true, changed: true, after: final, version: '"v-gsap"' });
if (url.includes("/files/")) return jsonResponse({ content: original, version: '"v0"' });
if (url.includes("/file-mutations/split-batch")) {
return jsonResponse({
ok: true,
outcome: "committed",
files: [
{
path: "index.html",
before: original,
after: final,
version: '"v-cut"',
writeToken: "cut-1",
splitCount: 1,
skippedSelectors: [],
},
],
});
}
throw new Error(`Unexpected request: ${url}`);
});
@@ -78,8 +89,8 @@ describe("useRazorSplit mutation versions", () => {
);
});
expect(order).toEqual(['observe:index.html:"v-html"', 'observe:index.html:"v-gsap"', "write"]);
expect(writeProjectFile).toHaveBeenCalledWith("index.html", final, original);
expect(order).toEqual(['observe:index.html:"v-cut"']);
expect(writeProjectFile).not.toHaveBeenCalled();
expect(recordEdit).toHaveBeenCalledTimes(1);
act(() => root.unmount());
@@ -8,15 +8,19 @@ export interface SplitBody {
elementDuration: number;
}
interface BatchFileBody {
path: string;
targets: SplitBody[];
}
function decodePathFromUrl(url: string, marker: string): string {
const encoded = url.slice(url.indexOf(marker) + marker.length);
return decodeURIComponent(encoded);
}
/**
* Fetch mock shared by both harnesses: GSAP mutations 400 (no script in fixtures),
* split-element writes a `<!--split-->` marker so `changed` is true, and file reads
* echo the in-memory `disk`. `onSplit` (when set) records each split request's body.
* Fetch mock shared by both harnesses: the atomic split batch writes each file
* once and returns its canonical snapshots; file reads echo the in-memory disk.
*/
export function createSplitFetchMock(
disk: Record<string, string>,
@@ -24,31 +28,39 @@ export function createSplitFetchMock(
) {
return vi.fn(async (url: string, init?: RequestInit) => {
const u = String(url);
if (u.includes("/gsap-mutations/")) {
// No GSAP script in the fixtures — mirror the server's 400 response.
return new Response(JSON.stringify({ error: "no GSAP script found in file" }), {
status: 400,
headers: { "Content-Type": "application/json" },
if (u.includes("/file-mutations/split-batch")) {
const body = JSON.parse(String(init?.body)) as { files: BatchFileBody[] };
const files = body.files.map((file) => {
const before = disk[file.path];
for (const target of file.targets) onSplit?.(file.path, target);
const after = `${before}${"<!--split-->".repeat(file.targets.length)}`;
const version = `"test-${file.path}-${after.length}"`;
return {
path: file.path,
before,
after,
version,
writeToken: "test-cut",
splitCount: file.targets.length,
skippedSelectors: [],
};
});
}
if (u.includes("/file-mutations/split-element/")) {
const path = decodePathFromUrl(u, "/file-mutations/split-element/");
onSplit?.(path, JSON.parse(String(init?.body)) as SplitBody);
// Return content that differs from the original so `changed` is true.
const after = `${disk[path]}<!--split-->`;
const version = `"test-${path}-${after.length}"`;
disk[path] = after; // server writes the split to disk
return new Response(JSON.stringify({ ok: true, changed: true, content: after, version }), {
for (const file of files) disk[file.path] = file.after;
return new Response(JSON.stringify({ ok: true, outcome: "committed", files }), {
status: 200,
headers: { "Content-Type": "application/json", ETag: version },
headers: { "Content-Type": "application/json" },
});
}
if (u.includes("/files/")) {
const path = decodePathFromUrl(u, "/files/").replace(/\?.*$/, "");
return new Response(JSON.stringify({ content: disk[path] ?? "" }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
const content = disk[path] ?? "";
return new Response(
JSON.stringify({ content, version: `"test-${path}-${content.length}"` }),
{
status: 200,
headers: { "Content-Type": "application/json" },
},
);
}
void init;
throw new Error(`unexpected fetch: ${u}`);
+78 -350
View File
@@ -1,15 +1,10 @@
import { useCallback, useRef } from "react";
import type { TimelineElement } from "../player";
import { usePlayerStore } from "../player";
import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
import { getTimelineElementLabel, collectHtmlIds } from "../utils/studioHelpers";
import { getTimelineElementLabel } from "../utils/studioHelpers";
import { trackStudioRazorSplit } from "../telemetry/events";
import {
canSplitElementAt,
selectSplittableElements,
buildPatchTarget,
readFileContent,
} from "../utils/timelineElementSplit";
import { canSplitElementAt, selectSplittableElements } from "../utils/timelineElementSplit";
import { buildAtomicCutIntents, runAtomicCutTransaction } from "../utils/razorSplitTransaction";
import type { RecordEditInput } from "./timelineEditingHelpers";
interface UseRazorSplitOptions {
@@ -21,246 +16,10 @@ interface UseRazorSplitOptions {
recordEdit: (input: RecordEditInput) => Promise<void>;
domEditSaveTimestampRef: React.MutableRefObject<number>;
reloadPreview: () => void;
/**
* Resync the in-memory SDK session after the server-side split write (the
* split-element / split-gsap endpoints write the file directly, so the SDK's
* linkedom doc is now stale). This reload is read-only; the split endpoint owns
* the final on-disk bytes and history baseline. Every other server-side-write
* timeline path (move / resize / delete / drop / visibility) also resyncs.
*/
forceReloadSdkSession?: () => void;
isRecordingRef?: React.RefObject<boolean>;
}
function generateSplitId(existingIds: string[], baseId: string): string {
let newId = `${baseId}-split`;
let suffix = 2;
while (existingIds.includes(newId)) {
newId = `${baseId}-split-${suffix++}`;
}
return newId;
}
async function splitHtmlElement(
projectId: string,
targetPath: string,
patchTarget: NonNullable<ReturnType<typeof buildPatchTarget>>,
splitTime: number,
newId: string,
elementStart: number,
elementDuration: number,
): Promise<{ ok: boolean; changed?: boolean; content?: string; version: string }> {
const response = await fetch(
`/api/projects/${projectId}/file-mutations/split-element/${encodeURIComponent(targetPath)}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
target: patchTarget,
splitTime,
newId,
elementStart,
elementDuration,
}),
},
);
if (!response.ok) throw new Error("Split request failed");
const data = (await response.json()) as {
ok: boolean;
changed?: boolean;
content?: string;
version?: string;
};
const version = data.version ?? response.headers.get("etag");
if (!version) throw new Error("Split response did not include a content version");
return { ...data, version };
}
// fallow-ignore-next-line complexity
async function splitGsapAnimations(
projectId: string,
targetPath: string,
originalId: string,
newId: string,
splitTime: number,
elementStart: number,
elementDuration: number,
): Promise<{ content: string | null; version?: string; skippedSelectors?: string[] }> {
const response = await fetch(
`/api/projects/${projectId}/gsap-mutations/${encodeURIComponent(targetPath)}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
type: "split-animations",
originalId,
newId,
splitTime,
elementStart,
elementDuration,
}),
},
);
if (!response.ok) {
const errorBody = (await response.json().catch(() => null)) as { error?: string } | null;
if (errorBody?.error === "no GSAP script found in file") {
return { content: null };
}
throw new Error(errorBody?.error ?? `GSAP animation split failed (${response.status})`);
}
const data = (await response.json()) as {
ok?: boolean;
after?: string;
version?: string;
skippedSelectors?: string[];
};
return {
content: data.ok && data.after ? data.after : null,
version: data.version ?? response.headers.get("etag") ?? undefined,
skippedSelectors: data.skippedSelectors,
};
}
function getOriginalContent(originals: ReadonlyMap<string, string>, path: string): string {
const original = originals.get(path);
if (original === undefined) {
throw new Error(`Missing original contents for ${path}`);
}
return original;
}
async function restoreFilesToOriginal(
originals: ReadonlyMap<string, string>,
snapshots: ReadonlyMap<string, { before: string; after: string }>,
writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>,
): Promise<void> {
for (const [path, snapshot] of snapshots) {
await writeProjectFile(path, getOriginalContent(originals, path), snapshot.after);
}
}
async function readOriginalFiles(
pid: string,
elements: TimelineElement[],
activeCompPath: string | null,
): Promise<Map<string, string>> {
const originals = new Map<string, string>();
for (const element of elements) {
const path = element.sourceFile || activeCompPath || "index.html";
if (!originals.has(path)) {
originals.set(path, await readFileContent(pid, path));
}
}
return originals;
}
async function splitElementsAtTime(
pid: string,
elements: TimelineElement[],
splitTime: number,
activeCompPath: string | null,
originals: ReadonlyMap<string, string>,
snapshots: Map<string, { before: string; after: string }>,
writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>,
observeProjectFileVersion?: (path: string, version: string | null) => void,
): Promise<number> {
let count = 0;
for (const element of elements) {
const result = await executeSplit(
pid,
element,
splitTime,
activeCompPath,
writeProjectFile,
observeProjectFileVersion,
);
if (!result.changed) continue;
snapshots.set(result.targetPath, {
before: getOriginalContent(originals, result.targetPath),
after: result.patchedContent,
});
await writeProjectFile(result.targetPath, result.patchedContent, result.patchedContent);
count++;
}
return count;
}
// fallow-ignore-next-line complexity
async function executeSplit(
pid: string,
element: TimelineElement,
splitTime: number,
activeCompPath: string | null,
writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>,
observeProjectFileVersion?: (path: string, version: string | null) => void,
): Promise<{
targetPath: string;
originalContent: string;
patchedContent: string;
changed: boolean;
skippedSelectors?: string[];
}> {
const patchTarget = buildPatchTarget(element);
if (!patchTarget) throw new Error("Clip is missing a patchable target.");
const targetPath = element.sourceFile || activeCompPath || "index.html";
const originalContent = await readFileContent(pid, targetPath);
const newId = generateSplitId(collectHtmlIds(originalContent), element.domId || "clip");
// An expanded sub-comp child arrives in MASTER-timeline coordinates — both its
// `start` and the incoming `splitTime` are offset by the host's master start
// (expandedParentStart) — but its `sourceFile` is the sub-comp, whose clips are
// authored in LOCAL time. Rebase both onto local time before the server patches
// the file, exactly as TimelinePane.handleSplitElement does for non-razor edits.
// Root-level clips (no expandedParentStart) are already local, so pass through.
const basis = element.expandedParentStart;
const localSplitTime = basis === undefined ? splitTime : Math.max(0, splitTime - basis);
const localElementStart = basis === undefined ? element.start : element.start - basis;
const splitResult = await splitHtmlElement(
pid,
targetPath,
patchTarget,
localSplitTime,
newId,
localElementStart,
element.duration,
);
if (!splitResult.ok) throw new Error("Failed to split clip.");
if (!splitResult.changed) {
return { targetPath, originalContent, patchedContent: originalContent, changed: false };
}
observeProjectFileVersion?.(targetPath, splitResult.version);
let patchedContent =
typeof splitResult.content === "string" ? splitResult.content : originalContent;
let skippedSelectors: string[] | undefined;
if (element.domId) {
try {
const gsapResult = await splitGsapAnimations(
pid,
targetPath,
element.domId,
newId,
localSplitTime,
localElementStart,
element.duration,
);
if (gsapResult.content) patchedContent = gsapResult.content;
if (gsapResult.version) observeProjectFileVersion?.(targetPath, gsapResult.version);
if (gsapResult.skippedSelectors?.length) skippedSelectors = gsapResult.skippedSelectors;
} catch (gsapError) {
// GSAP mutation failed — the HTML split already wrote to disk.
// Restore the original content to avoid a corrupt half-split state.
await writeProjectFile(targetPath, originalContent, patchedContent);
throw gsapError;
}
}
return { targetPath, originalContent, patchedContent, changed: true, skippedSelectors };
}
export function useRazorSplit({
projectId,
activeCompPath,
@@ -276,140 +35,109 @@ export function useRazorSplit({
const projectIdRef = useRef(projectId);
projectIdRef.current = projectId;
const synchronize = useCallback(() => {
let failure: unknown;
try {
forceReloadSdkSession?.();
} catch (error) {
failure = error;
}
try {
reloadPreview();
} catch (error) {
failure ??= error;
}
if (failure) throw failure;
}, [forceReloadSdkSession, reloadPreview]);
const runCut = useCallback(
async (elements: readonly TimelineElement[], splitTime: number, mode: "single" | "all") => {
const pid = projectIdRef.current;
if (!pid || elements.length === 0) return;
const intents = buildAtomicCutIntents(elements, splitTime, activeCompPath);
const requestedCount = intents.reduce((count, file) => count + file.targets.length, 0);
const label =
mode === "single"
? "Split timeline clip"
: `Split ${requestedCount} clips at ${splitTime.toFixed(2)}s`;
// Server writes arrive through the watcher before React can refresh. Keep
// the existing short self-write window active for this owned transaction.
domEditSaveTimestampRef.current = Date.now();
const result = await runAtomicCutTransaction({
projectId: pid,
intents,
label,
writeProjectFile,
recordEdit,
observeProjectFileVersion,
synchronize,
});
trackStudioRazorSplit({ mode, count: result.splitCount });
if (result.syncFailed) {
showToast(
"Cut was saved, but Studio could not refresh it. Reload the preview to resynchronize.",
"error",
);
}
if (result.skippedSelectors.length > 0) {
showToast(
`Some animations use non-ID selectors (${result.skippedSelectors.join(", ")}) and were not retargeted`,
"info",
);
}
return result;
},
[
activeCompPath,
domEditSaveTimestampRef,
observeProjectFileVersion,
recordEdit,
showToast,
synchronize,
writeProjectFile,
],
);
const handleRazorSplit = useCallback(
// fallow-ignore-next-line complexity
async (element: TimelineElement, splitTime: number) => {
if (isRecordingRef?.current) {
showToast("Cannot edit timeline while recording", "error");
return;
}
const pid = projectIdRef.current;
if (!pid || !canSplitElementAt(element, splitTime)) return;
if (!canSplitElementAt(element, splitTime)) return;
try {
const { targetPath, originalContent, patchedContent, changed, skippedSelectors } =
await executeSplit(
pid,
element,
splitTime,
activeCompPath,
writeProjectFile,
observeProjectFileVersion,
);
if (!changed) {
showToast("Failed to split clip — playhead may be outside the clip", "error");
return;
}
domEditSaveTimestampRef.current = Date.now();
await saveProjectFilesWithHistory({
projectId: pid,
label: "Split timeline clip",
kind: "timeline",
files: { [targetPath]: patchedContent },
readFile: async () => originalContent,
writeFile: writeProjectFile,
recordEdit,
});
// Server writes bypass the SDK session, so reopen it before refreshing
// the preview. The split response already owns the final persisted bytes.
forceReloadSdkSession?.();
reloadPreview();
trackStudioRazorSplit({ mode: "single", count: 1 });
const result = await runCut([element], splitTime, "single");
if (!result) return;
if (result.syncFailed) return;
showToast(`Split ${getTimelineElementLabel(element)} at ${splitTime.toFixed(2)}s`, "info");
if (skippedSelectors?.length) {
showToast(
`Some animations use non-ID selectors (${skippedSelectors.join(", ")}) and were not retargeted`,
"info",
);
}
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to split timeline clip";
showToast(message, "error");
}
},
[
activeCompPath,
recordEdit,
showToast,
writeProjectFile,
observeProjectFileVersion,
domEditSaveTimestampRef,
reloadPreview,
forceReloadSdkSession,
isRecordingRef,
],
[isRecordingRef, runCut, showToast],
);
const handleRazorSplitAll = useCallback(
// fallow-ignore-next-line complexity
async (splitTime: number) => {
if (isRecordingRef?.current) {
showToast("Cannot edit timeline while recording", "error");
return;
}
const pid = projectIdRef.current;
if (!pid) return;
const { elements } = usePlayerStore.getState();
const splittable = selectSplittableElements(elements, splitTime);
const splittable = selectSplittableElements(usePlayerStore.getState().elements, splitTime);
if (splittable.length === 0) return;
let originals = new Map<string, string>();
const finalSnapshots = new Map<string, { before: string; after: string }>();
try {
originals = await readOriginalFiles(pid, splittable, activeCompPath);
const splitCount = await splitElementsAtTime(
pid,
splittable,
splitTime,
activeCompPath,
originals,
finalSnapshots,
writeProjectFile,
observeProjectFileVersion,
);
if (splitCount === 0) return;
domEditSaveTimestampRef.current = Date.now();
await recordEdit({
label: `Split ${splitCount} clips at ${splitTime.toFixed(2)}s`,
kind: "timeline",
files: Object.fromEntries(finalSnapshots),
});
// Resync the stale SDK doc after the batched server write (see the
// single-split path above for why this precedes the reload).
forceReloadSdkSession?.();
reloadPreview();
trackStudioRazorSplit({ mode: "all", count: splitCount });
showToast(`Split ${splitCount} clips at ${splitTime.toFixed(2)}s`, "info");
const result = await runCut(splittable, splitTime, "all");
if (!result) return;
if (result.syncFailed) return;
showToast(`Split ${result.splitCount} clips at ${splitTime.toFixed(2)}s`, "info");
} catch (error) {
// Best-effort rollback — a failing restore write must not swallow the
// original error's toast, which is what tells the user the split failed.
try {
await restoreFilesToOriginal(originals, finalSnapshots, writeProjectFile);
} catch {
/* leave disk as-is; the original failure is reported below */
}
const message = error instanceof Error ? error.message : "Failed to split clips";
showToast(message, "error");
}
},
[
activeCompPath,
recordEdit,
showToast,
writeProjectFile,
observeProjectFileVersion,
domEditSaveTimestampRef,
reloadPreview,
forceReloadSdkSession,
isRecordingRef,
],
[isRecordingRef, runCut, showToast],
);
return { handleRazorSplit, handleRazorSplitAll };
@@ -0,0 +1,15 @@
import { useCallback } from "react";
import { usePlayerStore } from "../player";
type AddAtPlacement = (path: string, placement: { start: number; track: number }) => unknown;
export function useTimelineAddAtPlayhead(addAsset: AddAtPlacement, addComposition: AddAtPlacement) {
const placement = () => ({ start: usePlayerStore.getState().currentTime, track: 0 });
return {
addAssetAtPlayhead: useCallback((path: string) => addAsset(path, placement()), [addAsset]),
addCompositionAtPlayhead: useCallback(
(path: string) => addComposition(path, placement()),
[addComposition],
),
};
}
@@ -19,19 +19,22 @@ import { saveProjectFilesWithHistory, type RecordEditInput } from "../utils/stud
import { collectHtmlIds, resolveDroppedAssetDuration } from "../utils/studioHelpers";
import { formatTimelineAttributeNumber } from "./timelineEditingHelpers";
import { readFileContent } from "./timelineTimingSync";
import { commitTimelineCompositionInsertion } from "../utils/timelineCompositionInsert";
import { usePlayerStore } from "../player";
interface UseTimelineAssetDropOpsOptions {
projectIdRef: MutableRefObject<string | null>;
activeCompPath: string | null;
timelineElements: TimelineElement[];
showToast: (message: string, tone?: "error" | "info") => void;
writeProjectFile: (path: string, content: string) => Promise<void>;
writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>;
recordEdit: (input: RecordEditInput) => Promise<void>;
domEditSaveTimestampRef: MutableRefObject<number>;
reloadPreview: () => void;
uploadProjectFiles: (files: Iterable<File>, dir?: string) => Promise<string[]>;
isRecordingRef?: RefObject<boolean>;
forceReloadSdkSession?: () => void;
observeProjectFileVersion?: (path: string, version: string | null) => void;
}
export function useTimelineAssetDropOps({
@@ -46,6 +49,7 @@ export function useTimelineAssetDropOps({
uploadProjectFiles,
isRecordingRef,
forceReloadSdkSession,
observeProjectFileVersion,
}: UseTimelineAssetDropOpsOptions) {
// fallow-ignore-next-line complexity
const handleTimelineAssetDrop = useCallback(
@@ -171,5 +175,51 @@ export function useTimelineAssetDropOps({
[handleTimelineAssetDrop, projectIdRef, uploadProjectFiles, isRecordingRef, showToast],
);
return { handleTimelineAssetDrop, handleTimelineFileDrop };
const handleTimelineCompositionDrop = useCallback(
async (sourcePath: string, placement: Pick<TimelineElement, "start" | "track">) => {
if (isRecordingRef?.current) {
showToast("Cannot edit timeline while recording", "error");
return;
}
const pid = projectIdRef.current;
if (!pid) throw new Error("No active project");
const targetPath = activeCompPath || "index.html";
try {
await commitTimelineCompositionInsertion({
projectId: pid,
targetPath,
sourcePath,
start: placement.start,
track: placement.track,
writeFile: writeProjectFile,
recordEdit,
observeVersion: observeProjectFileVersion,
selectHost: (key) => usePlayerStore.getState().setSelectedElementId(key),
resync: forceReloadSdkSession,
refresh: reloadPreview,
});
domEditSaveTimestampRef.current = Date.now();
showToast("Composition added to the timeline.", "info");
} catch (error) {
showToast(
error instanceof Error ? error.message : "Failed to add composition to timeline",
"error",
);
}
},
[
activeCompPath,
domEditSaveTimestampRef,
forceReloadSdkSession,
isRecordingRef,
observeProjectFileVersion,
projectIdRef,
recordEdit,
reloadPreview,
showToast,
writeProjectFile,
],
);
return { handleTimelineAssetDrop, handleTimelineFileDrop, handleTimelineCompositionDrop };
}
+19 -17
View File
@@ -12,6 +12,7 @@ import {
applyTimelineStackingReorder,
buildPatchTarget,
patchIframeDomTiming,
playbackStartAttributeForElement,
persistTimelineEdit,
formatTimelineAttributeNumber,
extendRootDurationIfNeeded,
@@ -263,10 +264,7 @@ export function useTimelineEditing({
["data-duration", formatTimelineAttributeNumber(updates.duration)],
];
if (updates.playbackStart != null) {
const liveAttr =
element.playbackStartAttr === "playback-start"
? "data-playback-start"
: "data-media-start";
const liveAttr = playbackStartAttributeForElement(element);
liveAttrs.push([liveAttr, formatTimelineAttributeNumber(updates.playbackStart)]);
}
patchIframeDomTiming(previewIframeRef.current, element, liveAttrs, activeCompPath);
@@ -475,19 +473,21 @@ export function useTimelineEditing({
],
);
const { handleTimelineAssetDrop, handleTimelineFileDrop } = useTimelineAssetDropOps({
projectIdRef,
activeCompPath,
timelineElements,
showToast,
writeProjectFile,
recordEdit,
domEditSaveTimestampRef,
reloadPreview,
uploadProjectFiles,
isRecordingRef,
forceReloadSdkSession,
});
const { handleTimelineAssetDrop, handleTimelineFileDrop, handleTimelineCompositionDrop } =
useTimelineAssetDropOps({
projectIdRef,
activeCompPath,
timelineElements,
showToast,
writeProjectFile,
recordEdit,
domEditSaveTimestampRef,
reloadPreview,
uploadProjectFiles,
isRecordingRef,
forceReloadSdkSession,
observeProjectFileVersion,
});
const handleBlockedTimelineEdit = useCallback(
(_element: TimelineElement) => {
@@ -509,6 +509,7 @@ export function useTimelineEditing({
domEditSaveTimestampRef,
reloadPreview,
isRecordingRef,
forceReloadSdkSession,
});
return {
@@ -522,6 +523,7 @@ export function useTimelineEditing({
handleRazorSplitAll,
handleTimelineAssetDrop,
handleTimelineFileDrop,
handleTimelineCompositionDrop,
handleBlockedTimelineEdit,
...groupEditing,
};
@@ -12,6 +12,7 @@ import {
extendRootDurationIfNeeded,
formatTimelineAttributeNumber,
patchIframeDomTiming,
playbackStartAttributeForElement,
persistTimelineBatchEdit,
type PersistTimelineBatchChange,
type RecordEditInput,
@@ -242,9 +243,10 @@ export function useTimelineGroupEditing({
// duration change), so nothing timing-related changed — the batch only
// rewrites data-track-index, which the renderer never reads (documented
// in core runtime/timeline.ts; track is a studio lane concept). The live
// DOM patch above + the gesture owner's optimistic store update fully
// cover the UI, so after the persist there is nothing to GSAP-shift and
// nothing for the preview to recompute: skip the fallback below entirely.
// DOM patch above + the gesture owner's optimistic store update cover the
// in-flight UI; after the complete lane + z transaction, that owner
// refreshes the preview so its runtime manifest converges to disk. There
// is still nothing to GSAP-shift here, so skip this fallback entirely.
// Running it anyway is what made the mirrored z-order lane move blink —
// a zero-delta batch yields no scriptText, and finishGroupTimingGsapFallback
// used to full-reload the iframe when there was no script to soft-swap
@@ -348,10 +350,7 @@ export function useTimelineGroupEditing({
["data-duration", formatTimelineAttributeNumber(change.duration)],
];
if (change.playbackStart != null) {
const liveAttr =
change.element.playbackStartAttr === "playback-start"
? "data-playback-start"
: "data-media-start";
const liveAttr = playbackStartAttributeForElement(change.element);
liveAttrs.push([liveAttr, formatTimelineAttributeNumber(change.playbackStart)]);
}
patchIframeDomTiming(previewIframeRef.current, change.element, liveAttrs, activeCompPath);
@@ -28,6 +28,7 @@ import { useResolvedTimelineEditCallbacks } from "./useResolvedTimelineEditCallb
import type { TimelineProps } from "./TimelineTypes";
import { useTrackGapMenu } from "./useTrackGapMenu";
import { useTimelineGapHighlights } from "./useTimelineGapHighlights";
import { useStudioPlaybackContextOptional } from "../../contexts/StudioContext";
// Re-export pure utilities so existing imports from "./Timeline" still resolve.
export {
@@ -52,6 +53,7 @@ export const Timeline = memo(function Timeline({
onFileDrop,
onAssetDrop,
onBlockDrop,
onCompositionDrop,
onDeleteElement: _onDeleteElement,
onMoveElement: onMoveElementOverride,
onMoveElements: onMoveElementsOverride,
@@ -84,6 +86,11 @@ export const Timeline = memo(function Timeline({
onSplitElement: onSplitElementOverride,
});
const theme = useMemo(() => ({ ...defaultTimelineTheme, ...themeOverrides }), [themeOverrides]);
const playbackContext = useStudioPlaybackContextOptional();
const setRefreshKey = playbackContext?.setRefreshKey;
const refreshAfterLaneMove = useCallback(() => {
setRefreshKey?.((key) => key + 1);
}, [setRefreshKey]);
useMusicBeatAnalysis();
const rawElements = usePlayerStore((s) => s.elements);
const expandedElements = useExpandedTimelineElements();
@@ -169,6 +176,7 @@ export const Timeline = memo(function Timeline({
pinnedOnFileDrop,
pinnedOnAssetDrop,
pinnedOnBlockDrop,
pinnedOnCompositionDrop,
} = useTimelineEditPinning({
ppsRef,
fitPpsRef,
@@ -179,6 +187,7 @@ export const Timeline = memo(function Timeline({
onFileDrop,
onAssetDrop,
onBlockDrop,
onCompositionDrop,
});
const { readClipZIndex, applyStackingPatches, zSyncEnabled } = useTimelineStackingSync({
@@ -223,6 +232,7 @@ export const Timeline = memo(function Timeline({
setRangeSelectionRef,
readZIndex: zSyncEnabled ? readClipZIndex : undefined,
onStackingPatches: zSyncEnabled ? applyStackingPatches : undefined,
refreshAfterLaneMove,
});
const { isDragOver, handleAssetDragOver, handleAssetDrop, clearDropPreview } =
@@ -234,6 +244,7 @@ export const Timeline = memo(function Timeline({
onFileDrop: pinnedOnFileDrop,
onAssetDrop: pinnedOnAssetDrop,
onBlockDrop: pinnedOnBlockDrop,
onCompositionDrop: pinnedOnCompositionDrop,
});
const displayTrackOrder = useMemo(() => {
@@ -399,7 +410,7 @@ export const Timeline = memo(function Timeline({
<div
ref={setContainerRef}
aria-label="Timeline"
className={`relative border-t select-none h-full overflow-hidden ${activeTool === "razor" ? "cursor-crosshair" : shiftHeld ? "cursor-crosshair" : "cursor-default"}`}
className={`relative border-t select-none h-full overflow-hidden ${isDragOver ? "ring-1 ring-inset ring-studio-accent/60" : ""} ${activeTool === "razor" ? "cursor-crosshair" : shiftHeld ? "cursor-crosshair" : "cursor-default"}`}
onMouseMove={(e) => {
if (activeTool === "razor" && scrollRef.current) {
const rect = scrollRef.current.getBoundingClientRect();
@@ -0,0 +1,77 @@
import type { TimelineElement } from "../store/playerStore";
import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
import { getTimelineEditCapabilities } from "./timelineEditing";
import type { DraggedClipState } from "./timelineClipDragTypes";
/** Whether Studio may write timing to this clip (false for locked/implicit rows). */
export function canMoveTimelineElement(element: TimelineElement): boolean {
return getTimelineEditCapabilities({
tag: element.tag,
kind: element.kind,
duration: element.duration,
domId: element.domId,
selector: element.selector,
compositionSrc: element.compositionSrc,
playbackStart: element.playbackStart,
playbackStartAttr: element.playbackStartAttr,
sourceDuration: element.sourceDuration,
timingSource: element.timingSource,
timelineLocked: element.timelineLocked,
}).canMove;
}
interface ExpandedHostAliasDeps {
elements: TimelineElement[];
selectedKeys?: ReadonlySet<string> | null;
}
/**
* Expanded children keep their own source-file identity for direct edits, but a
* selection can briefly contain both a composition host and one of its visible
* expanded children. That pair is one authored move target, not two. Resolve it
* to the host before time/lane/collision commit so ordinary clip placement stays
* the single owner of the gesture semantics.
*/
export function resolveExpandedHostAlias(
drag: DraggedClipState,
deps: ExpandedHostAliasDeps,
): { drag: DraggedClipState; selectedKeys: ReadonlySet<string> } | null {
const selectedKeys = deps.selectedKeys;
if (!selectedKeys) return null;
const collapsedKeys = new Set(selectedKeys);
const candidates = deps.elements.includes(drag.element)
? deps.elements
: [...deps.elements, drag.element];
for (const element of candidates) {
const hostKey = element.expandedHostKey;
const childKey = getTimelineElementIdentity(element);
if (hostKey && collapsedKeys.has(hostKey) && collapsedKeys.has(childKey)) {
collapsedKeys.delete(childKey);
}
}
const hostKey = drag.element.expandedHostKey;
const childKey = getTimelineElementIdentity(drag.element);
if (!hostKey || collapsedKeys.has(childKey)) {
if (collapsedKeys.size === selectedKeys.size) return null;
return { drag, selectedKeys: collapsedKeys };
}
const host = deps.elements.find((element) => getTimelineElementIdentity(element) === hostKey);
if (!host || !canMoveTimelineElement(host)) return null;
const delta = drag.previewStart - drag.element.start;
const mapTrack = (track: number | undefined): number | undefined =>
track === drag.element.track ? host.track : track;
return {
drag: {
...drag,
element: host,
previewStart: Math.max(0, Math.round((host.start + delta) * 1000) / 1000),
previewTrack: mapTrack(drag.previewTrack) ?? host.track,
desiredTrack: mapTrack(drag.desiredTrack),
},
selectedKeys: collapsedKeys,
};
}
@@ -0,0 +1,29 @@
import type { TimelineElement } from "../store/playerStore";
const keyOf = (element: TimelineElement) => element.key ?? element.id;
/** Authored track numbers only compare within one source file. */
export const sameSourceFile = (a: TimelineElement, b: TimelineElement): boolean =>
(a.sourceFile ?? null) === (b.sourceFile ?? null);
/** Translate a display lane into the source-file track to persist. */
export function authoredTrackForLane(
lane: number,
elements: TimelineElement[],
dragged: TimelineElement,
): number {
const dragKey = keyOf(dragged);
const peers = elements.filter((element) => {
return keyOf(element) !== dragKey && sameSourceFile(element, dragged);
});
const occupant = peers.find((element) => element.track === lane);
if (occupant) return occupant.authoredTrack ?? occupant.track;
let nearest: TimelineElement | null = null;
for (const peer of peers) {
if (!nearest || Math.abs(peer.track - lane) < Math.abs(nearest.track - lane)) nearest = peer;
}
if (!nearest) return lane;
// Synthetic expanded-child display rows can be fractional; authored tracks cannot.
return Math.round((nearest.authoredTrack ?? nearest.track) + (lane - nearest.track));
}
@@ -22,6 +22,10 @@ export interface TimelineDropCallbacks {
blockName: string,
placement: { start: number; track: number },
) => Promise<void> | void;
onCompositionDrop?: (
sourcePath: string,
placement: { start: number; track: number },
) => Promise<void> | void;
}
export interface TimelineEditCallbacks {
@@ -4,6 +4,7 @@ import type { DraggedClipState } from "./useTimelineClipDrag";
import {
commitDraggedClipMove,
commitZMirrorLaneMove,
persistMoveEdits,
type DragCommitDeps,
type TimelineMoveEdit,
} from "./timelineClipDragCommit";
@@ -314,6 +315,118 @@ describe("commitDraggedClipMove", () => {
expect(map.c).toBeUndefined(); // unselected clips untouched
});
it("collapses a selected expanded child onto its authored composition host", () => {
const host = { ...el("host", 0, 10, 8), kind: "composition" as const };
const child = {
...el("scene.html#title", 0.25, 12, 2),
sourceFile: "scene.html",
expandedParentStart: 10,
expandedHostKey: "host",
};
const { updateElement, onMoveElement, onMoveElements } = runClipMove(
drag(child, { previewStart: 15, previewTrack: child.track }),
{
elements: [host],
trackOrder: [0, child.track],
selectedKeys: new Set(["host", "scene.html#title"]),
},
);
expect(onMoveElements).not.toHaveBeenCalled();
expect(onMoveElement).toHaveBeenCalledWith(host, { start: 13, track: 0 });
expect(updateElement).toHaveBeenCalledWith("host", { start: 13, track: 0 });
expect(updateElement).not.toHaveBeenCalledWith("scene.html#title", expect.anything());
});
it("drops a selected expanded child alias when the authored host initiates the drag", () => {
const host = { ...el("host", 0, 10, 8), kind: "composition" as const };
const child = {
...el("scene.html#title", 0.25, 12, 2),
sourceFile: "scene.html",
expandedParentStart: 10,
expandedHostKey: "host",
};
const { onMoveElement, onMoveElements } = runClipMove(
drag(host, { previewStart: 13, previewTrack: host.track }),
{
elements: [host, child],
trackOrder: [0, child.track],
selectedKeys: new Set(["host", "scene.html#title"]),
},
);
expect(onMoveElements).not.toHaveBeenCalled();
expect(onMoveElement).toHaveBeenCalledOnce();
expect(onMoveElement).toHaveBeenCalledWith(host, { start: 13, track: 0 });
});
it("keeps an expanded child as the edit target when its host is not selected", () => {
const host = { ...el("host", 0, 10, 8), kind: "composition" as const };
const child = {
...el("scene.html#title", 0.25, 12, 2),
sourceFile: "scene.html",
expandedParentStart: 10,
expandedHostKey: "host",
};
const { onMoveElement, onMoveElements } = runClipMove(
drag(child, { previewStart: 15, previewTrack: child.track }),
{
elements: [host],
trackOrder: [0, child.track],
selectedKeys: new Set(["scene.html#title"]),
},
);
expect(onMoveElements).not.toHaveBeenCalled();
expect(onMoveElement).toHaveBeenCalledWith(child, { start: 15, track: child.track });
});
it("moves a host alias and an ordinary selected clip once each in one batch", () => {
const host = { ...el("host", 0, 10, 8), kind: "composition" as const };
const ordinary = el("ordinary", 1, 20, 3);
const child = {
...el("scene.html#title", 0.25, 12, 2),
sourceFile: "scene.html",
expandedParentStart: 10,
expandedHostKey: "host",
};
const { onMoveElement, onMoveElements } = runClipMove(
drag(child, { previewStart: 14, previewTrack: child.track }),
{
elements: [host, ordinary],
trackOrder: [0, child.track, 1],
selectedKeys: new Set(["host", "scene.html#title", "ordinary"]),
},
);
const map = expectAtomicMoveMap({ onMoveElement, onMoveElements });
expect(map).toEqual({
host: { start: 12, track: 0 },
ordinary: { start: 22, track: 1 },
});
});
it("applies an expanded-child vertical drag to the selected host lane", () => {
const host = { ...el("host", 0, 10, 8), kind: "composition" as const };
const child = {
...el("scene.html#title", 0.25, 12, 2),
sourceFile: "scene.html",
expandedParentStart: 10,
expandedHostKey: "host",
};
const { onMoveElement, onMoveElements } = runClipMove(
drag(child, { previewStart: 12, previewTrack: 1, desiredTrack: 1 }),
{
elements: [host],
trackOrder: [0, child.track, 1],
selectedKeys: new Set(["host", "scene.html#title"]),
},
);
const map = expectAtomicMoveMap({ onMoveElement, onMoveElements });
expect(map).toEqual({ host: { start: 10, track: 1 } });
});
it("multi-selection move clamps shifted clips at 0 and applies the store update optimistically", () => {
const elements = [el("a", 0, 6, 3), el("b", 1, 2, 3)];
// Drag 'a' 5s: b would land at 3 → clamps to 0.
@@ -959,20 +1072,40 @@ describe("commitDraggedClipMove", () => {
expectZLiftedToSix(onStackingPatches);
});
it("refreshes the preview only after the complete lane and z transaction", async () => {
const order: string[] = [];
commitInsertAbove(overlapping(), {
onMoveElements: vi.fn(async () => {
order.push("lane");
}),
onStackingPatches: vi.fn(async () => {
order.push("z");
}),
refreshAfterLaneMove: () => order.push("refresh"),
});
await flushMicrotasks();
expect(order).toEqual(["lane", "z", "refresh"]);
});
it("rolls back the move and skips the z-sync when the persist fails", async () => {
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const elements = overlapping();
const onMoveElements = vi.fn(() => Promise.reject(new Error("write failed")));
const onStackingPatches = vi.fn();
const refreshAfterLaneMove = vi.fn();
const updateElement = vi.fn();
commitInsertAbove(elements, {
updateElement,
onMoveElements,
onStackingPatches,
refreshAfterLaneMove,
});
await flushMicrotasks();
// Failed move → z patch never issued (no orphaned z change left behind)...
expect(onStackingPatches).not.toHaveBeenCalled();
expect(refreshAfterLaneMove).not.toHaveBeenCalled();
// ...and the optimistic start/track edit for the dragged clip is rolled back.
expect(updateElement).toHaveBeenCalledWith("a", { start: 0, track: 1 });
errSpy.mockRestore();
@@ -1300,3 +1433,46 @@ describe("commitZMirrorLaneMove", () => {
expect(onMoveElements).not.toHaveBeenCalled();
});
});
describe("persistMoveEdits convergence", () => {
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;
const pendingSave = new Promise<void>((resolve) => {
releaseSave = resolve;
});
let liveTrack = clip.track;
let liveAuthoredTrack = clip.authoredTrack;
const updateElement = vi.fn((_key: string, updates: Partial<TimelineElement>) => {
if (updates.track != null) liveTrack = updates.track;
if (updates.authoredTrack != null) liveAuthoredTrack = updates.authoredTrack;
});
const persisted = persistMoveEdits(
[
{
element: clip,
updates: { start: clip.start, track: 0 },
persistTrack: 0,
},
],
{
elements: [clip],
trackOrder: [0, 1, 2],
updateElement,
onMoveElements: () => pendingSave,
},
);
expect([liveTrack, liveAuthoredTrack]).toEqual([0, 0]);
// Reproduce the real failure: the preview emits its cached pre-drag lane
// while the file write is still pending.
liveTrack = 2;
liveAuthoredTrack = 2;
releaseSave?.();
await expect(persisted).resolves.toBe(true);
expect([liveTrack, liveAuthoredTrack]).toEqual([0, 0]);
expect(updateElement).toHaveBeenCalledTimes(2);
});
});
@@ -5,13 +5,18 @@ import type { DraggedClipState } from "./useTimelineClipDrag";
import type { ZMirrorLaneMove } from "./timelineZMirror";
import { classifyZone, normalizeToZones } from "./timelineZones";
import { computeStackingPatches, type StackingPatch } from "./timelineStackingSync";
import { getTimelineEditCapabilities } from "./timelineEditing";
import {
canMoveTimelineElement as canMoveElement,
resolveExpandedHostAlias,
} from "./timelineAuthoredMoveTarget";
import type { TimelineMoveOperation } from "../../hooks/timelineMoveAdapter";
import {
beginTimelineOptimisticGesture,
isLatestTimelineOptimisticGesture,
} from "./timelineOptimisticRevision";
import { runLaneZGesture } from "../../components/nle/zLaneGesture";
import { refreshAfterDurableLaneMove } from "./timelineLaneMoveRefresh";
import { authoredTrackForLane, sameSourceFile } from "./timelineAuthoredTrack";
type StartTrack = Pick<TimelineElement, "start" | "track">;
export interface TimelineMoveEdit {
@@ -68,30 +73,15 @@ export interface DragCommitDeps {
* research/STAGE3-NEEDED-WIRING.md.
*/
onStackingPatches?: (patches: StackingPatch[], coalesceKey?: string) => Promise<unknown> | void;
/** Converge the preview manifest after the complete lane + z transaction. */
refreshAfterLaneMove?: () => void;
}
const keyOf = (e: TimelineElement) => e.key ?? e.id;
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;
/** Whether Studio may write timing to this clip (false for locked/implicit rows). */
function canMoveElement(element: TimelineElement): boolean {
return getTimelineEditCapabilities({
tag: element.tag,
duration: element.duration,
domId: element.domId,
selector: element.selector,
compositionSrc: element.compositionSrc,
playbackStart: element.playbackStart,
playbackStartAttr: element.playbackStartAttr,
sourceDuration: element.sourceDuration,
timingSource: element.timingSource,
timelineLocked: element.timelineLocked,
}).canMove;
}
/**
* Optimistically apply + persist a batch of moves with rollback on failure.
*
@@ -139,14 +129,15 @@ export function persistMoveEdits(
// 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.
for (const e of edits) {
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 },
);
}
};
for (const e of edits) applyEdit(e);
// The store above gets DISPLAY lanes; the file below gets the authored-space
// track when one was resolved (see TimelineMoveEdit.persistTrack).
const persistEdits = edits.map((e) =>
@@ -158,7 +149,17 @@ export function persistMoveEdits(
? onMoveElements(persistEdits, coalesceKey, operation, coalesceMs)
: Promise.all(persistEdits.map((e) => Promise.resolve(onMoveElement?.(e.element, e.updates))));
return Promise.resolve(persisted).then(
() => true,
() => {
// Runtime timeline messages can arrive while the save is in flight and
// 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);
}
return true;
},
(error) => {
for (const p of prev) {
if (isLatestTimelineOptimisticGesture(updateElement, revision, p.key)) {
@@ -177,59 +178,6 @@ export function persistMoveEdits(
* then compacts it to a distinct integer lane between its neighbours, and the
* clips at/below the insert shift down by one the sanctioned index-renumber.
*/
/** Same-source-file predicate: authored track numbers only compare within ONE
* file's coordinate space (an expanded sub-comp child's authoredTrack is in ITS
* file, not the host timeline's). `undefined` means the active composition. */
export const sameSourceFile = (a: TimelineElement, b: TimelineElement): boolean =>
(a.sourceFile ?? null) === (b.sourceFile ?? null);
/**
* Translate a DISPLAY lane into the AUTHORED (source-file) track to persist for
* `dragged`. Occupants are consulted ONLY from the dragged clip's own source
* file an occupant from a different file (e.g. an expanded sub-comp child, or
* a host clip next to expanded rows) carries authored values in a different
* coordinate space, and borrowing them would write a foreign file's numbering.
*
* Lane semantics after normalizeToZones: each distinct authored track owns one
* base lane, and time-overlapping same-track clips spill onto adjacent display
* sub-lanes (packTrackLanes). A spill sub-lane IS a legal drop target (Timeline's
* trackOrder lists it): its occupants share the base lane's authored track by
* construction, so the same-file occupant lookup returns that authored track and
* the drop persists as a same-track join. The clip may then DISPLAY on a
* different sub-lane than it was dropped on the spill re-packs
* deterministically by stable id, first-fit but the persisted track is
* correct.
*
* Fallbacks when the lane has no same-file occupant (e.g. an expanded child
* dropped on a lane holding only other files' clips the display-lane integer
* must NOT be persisted into a sparse file):
* 1. Offset from the NEAREST same-file lane: authored(nearest) + lane distance,
* preserving "one lane up = one authored track up" in the clip's own file.
* 2. No same-file peers at all the lane value itself (single-clip files:
* display and authored spaces coincide for want of any other anchor).
* Edge-created lanes (min-1 / max+1 inserts) route through the insert path,
* never here.
*/
export function authoredTrackForLane(
lane: number,
elements: TimelineElement[],
dragged: TimelineElement,
): number {
const dragKey = keyOf(dragged);
const peers = elements.filter((e) => keyOf(e) !== dragKey && sameSourceFile(e, dragged));
const occupant = peers.find((e) => e.track === lane);
if (occupant) return occupant.authoredTrack ?? occupant.track;
let nearest: TimelineElement | null = null;
for (const p of peers) {
if (!nearest || Math.abs(p.track - lane) < Math.abs(nearest.track - lane)) nearest = p;
}
if (!nearest) return lane;
// Rounded: expanded children live on FRACTIONAL synthetic display rows (see
// buildChildElements), so a lane distance measured against one can carry a
// fraction — an authored data-track-index must stay an integer.
return Math.round((nearest.authoredTrack ?? nearest.track) + (lane - nearest.track));
}
function insertTrackValue(trackOrder: number[], insertRow: number): number {
if (trackOrder.length === 0) return 0;
if (insertRow <= 0) return trackOrder[0] - 0.5;
@@ -284,6 +232,12 @@ function resolveMultiSelection(
*/
// fallow-ignore-next-line complexity
export function commitDraggedClipMove(drag: DraggedClipState, deps: DragCommitDeps): void {
const hostAlias = resolveExpandedHostAlias(drag, deps);
if (hostAlias) {
commitDraggedClipMove(hostAlias.drag, { ...deps, selectedKeys: hostAlias.selectedKeys });
return;
}
const { elements, updateElement, onMoveElement } = deps;
const dragKey = keyOf(drag.element);
const isInsert = drag.insertRow != null;
@@ -362,22 +316,28 @@ export function commitDraggedClipMove(drag: DraggedClipState, deps: DragCommitDe
});
const multiKeys = multi ? multi.keys : null;
if (!isVertical || !deps.readZIndex || !deps.onStackingPatches) {
void persistMoveEdits(edits, deps, coalesceKey, "lane-reorder");
void refreshAfterDurableLaneMove(
persistMoveEdits(edits, deps, coalesceKey, "lane-reorder"),
deps,
);
return;
}
void runLaneZGesture({
commitLane: () => persistMoveEdits(edits, deps, coalesceKey, "lane-reorder"),
commitZ: () =>
syncStackingForEdit(
candidate,
dragKey,
drag.element.track,
drag.previewTrack,
multiKeys,
deps,
coalesceKey,
),
}).catch(() => undefined);
void refreshAfterDurableLaneMove(
runLaneZGesture({
commitLane: () => persistMoveEdits(edits, deps, coalesceKey, "lane-reorder"),
commitZ: () =>
syncStackingForEdit(
candidate,
dragKey,
drag.element.track,
drag.previewTrack,
multiKeys,
deps,
coalesceKey,
),
}),
deps,
).catch(() => undefined);
}
/** Build the one sanctioned multi-clip write: atomically insert and compact a
@@ -486,23 +446,29 @@ function commitTrackInsert(
const coalesceKey = `clip-lane-move:${laneChangeGestureSeq++}`;
if (!deps.readZIndex || !deps.onStackingPatches) {
void persistMoveEdits(edits, deps, coalesceKey, "track-insert");
void refreshAfterDurableLaneMove(
persistMoveEdits(edits, deps, coalesceKey, "track-insert"),
deps,
);
return;
}
void runLaneZGesture({
commitLane: () => persistMoveEdits(edits, deps, coalesceKey, "track-insert"),
commitZ: () =>
// Sync from the fractional drop intent, not the normalized persisted lanes.
syncStackingForEdit(
candidate,
dragKey,
drag.element.track,
drag.insertRow!,
multi ? multi.keys : null,
deps,
coalesceKey,
),
}).catch(() => undefined);
void refreshAfterDurableLaneMove(
runLaneZGesture({
commitLane: () => persistMoveEdits(edits, deps, coalesceKey, "track-insert"),
commitZ: () =>
// Sync from the fractional drop intent, not the normalized persisted lanes.
syncStackingForEdit(
candidate,
dragKey,
drag.element.track,
drag.insertRow!,
multi ? multi.keys : null,
deps,
coalesceKey,
),
}),
deps,
).catch(() => undefined);
}
/**
@@ -544,11 +510,17 @@ export function commitZMirrorLaneMove(
updates: { start: element.start, track: move.displayTrack },
persistTrack: move.persistTrack,
};
return persistMoveEdits([edit], deps, coalesceKey, "lane-reorder", coalesceMs);
return refreshAfterDurableLaneMove(
persistMoveEdits([edit], deps, coalesceKey, "lane-reorder", coalesceMs),
deps,
);
}
const built = buildTrackInsertEdits(element, element.start, move.insertRow, null, deps);
if (!built || built.edits.length === 0) return Promise.resolve(false);
return persistMoveEdits(built.edits, deps, coalesceKey, "track-insert", coalesceMs);
return refreshAfterDurableLaneMove(
persistMoveEdits(built.edits, deps, coalesceKey, "track-insert", coalesceMs),
deps,
);
}
/**
@@ -1,6 +1,10 @@
import { describe, expect, it } from "vitest";
import type { TimelineElement } from "../store/playerStore";
import { computeDragPreview, type DragPreviewContext } from "./timelineClipDragPreview";
import {
computeDragPreview,
computeResizePreview,
type DragPreviewContext,
} from "./timelineClipDragPreview";
import type { DraggedClipState } from "./timelineClipDragTypes";
import { RULER_H, TRACKS_TOP_PAD, TRACK_H } from "./timelineLayout";
@@ -142,3 +146,31 @@ describe("computeDragPreview — plain horizontal drag never arms a phantom inse
expect(next.insertRow).toBe(0); // a new TOP track will be created on drop
});
});
describe("computeResizePreview — composition source continuity", () => {
it("seeds a legacy composition offset and advances it at playback rate", () => {
const element = {
...clip("comp", 0, 2, 4, 0, "div"),
kind: "composition" as const,
playbackRate: 2,
};
const result = computeResizePreview(
{
element,
edge: "start",
originClientX: 0,
previewStart: 2,
previewDuration: 4,
started: true,
},
100,
{ scroll: fakeScroll(), pps: 100, buildSnapTargets: () => [] },
);
expect(result).toMatchObject({
previewStart: 3,
previewDuration: 3,
previewPlaybackStart: 2,
});
});
});
@@ -221,7 +221,8 @@ export function computeResizePreview(
)
: Number.POSITIVE_INFINITY;
const normalizedTag = resize.element.tag.toLowerCase();
const canSeedPlaybackStart = normalizedTag === "audio" || normalizedTag === "video";
const canSeedPlaybackStart =
resize.element.kind === "composition" || normalizedTag === "audio" || normalizedTag === "video";
const playbackRate = Math.max(resize.element.playbackRate ?? 1, 0.1);
// Trim limit = available source media only — NOT the composition length.
// Duration is content-driven (the comp grows/shrinks to fit on commit), so
@@ -308,6 +308,24 @@ describe("resolveZoneDropPlacement (the whole drop decision, no same-track overl
).toEqual({ track: 2, insertRow: null });
});
it("crosses an occupied aim instead of snapping the dragged clip back to its origin", () => {
expect(
resolveZoneDropPlacement({
...base,
elements: [el("a", 0, 0, 5), el("b", 1, 0, 5), el("x", 2, 0, 5)],
desiredTrack: 1,
}),
).toEqual({ track: 1, insertRow: 1 });
expect(
resolveZoneDropPlacement({
...base,
elements: [el("x", 0, 0, 5), el("b", 1, 0, 5), el("a", 2, 0, 5)],
desiredTrack: 1,
}),
).toEqual({ track: 1, insertRow: 2 });
});
it("auto-creates a new track when EVERY lane in the zone is occupied at that time", () => {
expect(
resolveZoneDropPlacement({
@@ -115,7 +115,10 @@ export function resolveZoneDropPlacement(input: {
trackOrder: zoneTracks,
excludeKey: dragKey,
});
if (placement.needsInsert) {
const originTrack = elements.find((element) => (element.key ?? element.id) === dragKey)?.track;
const snappedBackToOrigin =
originTrack != null && desired !== originTrack && placement.track === originTrack;
if (placement.needsInsert || snappedBackToOrigin) {
const desiredRow = order.indexOf(desired);
if (desiredRow < 0) {
return {
@@ -123,14 +126,14 @@ export function resolveZoneDropPlacement(input: {
insertRow: outOfRangeZoneInsertRow(order, zoneTracks, audioRow, desired),
};
}
// Prefer the gap NEAREST the pointer: insert above the aimed row when the
// pointer sits in its upper half AND that boundary is in the clip's own zone
// (else the visual/audio split would be crossed) — otherwise fall to below.
// `desired` is clamped into the zone, so both boundaries stay in-zone.
const insertRow =
preferInsertAbove && isInsertAllowedForZone(desiredRow, audioRow, isAudio)
? desiredRow
: desiredRow + 1;
// When collision fallback found only the origin lane, insert on the far side
// of the aimed lane so normalization cannot turn the gesture into a no-op.
// Otherwise prefer the gap nearest the pointer, preserving normal insertion.
const originRow = originTrack == null ? -1 : order.indexOf(originTrack);
const insertAbove = snappedBackToOrigin
? originRow > desiredRow
: preferInsertAbove && isInsertAllowedForZone(desiredRow, audioRow, isAudio);
const insertRow = insertAbove ? desiredRow : desiredRow + 1;
return { track: desired, insertRow };
}
return { track: placement.track, insertRow: null };
@@ -1,5 +1,9 @@
import { useCallback, useState, type RefObject } from "react";
import { TIMELINE_ASSET_MIME, TIMELINE_BLOCK_MIME } from "../../utils/timelineAssetDrop";
import {
parseTimelineCompositionPayload,
TIMELINE_COMPOSITION_MIME,
} from "../../utils/timelineCompositionDrop";
import { usePlayerStore } from "../store/playerStore";
import { TRACK_H, resolveTimelineAssetDrop } from "./timelineLayout";
import type { TimelineDropCallbacks } from "./timelineCallbacks";
@@ -32,6 +36,11 @@ function applyJsonDropPayload(
}
}
function resolveDropStart(usePointerStart: boolean, pointerStart: number): number {
if (usePointerStart) return pointerStart;
return Math.max(0, usePlayerStore.getState().currentTime);
}
/**
* Dropping an asset/file/block onto the timeline places it at the PLAYHEAD
* start is the current playhead time, only the track comes from the drop y.
@@ -48,6 +57,7 @@ export function useTimelineAssetDrop({
onFileDrop,
onAssetDrop,
onBlockDrop,
onCompositionDrop,
}: UseTimelineAssetDropOptions) {
const [isDragOver, setIsDragOver] = useState(false);
@@ -56,7 +66,8 @@ export function useTimelineAssetDrop({
const hasFiles = types.includes("Files");
const hasAsset = types.includes(TIMELINE_ASSET_MIME);
const hasBlock = types.includes(TIMELINE_BLOCK_MIME);
if (!hasFiles && !hasAsset && !hasBlock) return;
const hasComposition = types.includes(TIMELINE_COMPOSITION_MIME);
if (!hasFiles && !hasAsset && !hasBlock && !hasComposition) return;
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
setIsDragOver(true);
@@ -65,11 +76,10 @@ export function useTimelineAssetDrop({
const clearDropPreview = useCallback(() => setIsDragOver(false), []);
const resolveDropPlacement = useCallback(
(clientX: number, clientY: number): TimelinePlacement => {
(clientX: number, clientY: number, usePointerStart = false): TimelinePlacement => {
const scroll = scrollRef.current;
const rect = scroll?.getBoundingClientRect();
// Track comes from the vertical drop position; start is the playhead.
const { track } = resolveTimelineAssetDrop(
const pointer = resolveTimelineAssetDrop(
{
rectLeft: rect?.left ?? 0,
rectTop: rect?.top ?? 0,
@@ -77,14 +87,17 @@ export function useTimelineAssetDrop({
scrollTop: scroll?.scrollTop ?? 0,
pixelsPerSecond: ppsRef.current,
duration: durationRef.current,
clampStartToDuration: !usePointerStart,
trackHeight: TRACK_H,
trackOrder: trackOrderRef.current,
},
clientX,
clientY,
);
const start = Math.max(0, usePlayerStore.getState().currentTime);
return { start, track };
return {
start: resolveDropStart(usePointerStart, pointer.start),
track: pointer.track,
};
},
[scrollRef, ppsRef, durationRef, trackOrderRef],
);
@@ -93,6 +106,14 @@ export function useTimelineAssetDrop({
(e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(false);
const compositionPayload = parseTimelineCompositionPayload(
e.dataTransfer.getData(TIMELINE_COMPOSITION_MIME),
);
if (compositionPayload && onCompositionDrop) {
const placement = resolveDropPlacement(e.clientX, e.clientY, true);
void onCompositionDrop(compositionPayload.sourcePath, placement);
return;
}
const placement = resolveDropPlacement(e.clientX, e.clientY);
if (onFileDrop && e.dataTransfer.files.length > 0) {
@@ -109,7 +130,7 @@ export function useTimelineAssetDrop({
applyJsonDropPayload(blockPayload, (p) => p.name, onBlockDrop, placement);
}
},
[resolveDropPlacement, onFileDrop, onAssetDrop, onBlockDrop],
[resolveDropPlacement, onFileDrop, onAssetDrop, onBlockDrop, onCompositionDrop],
);
return { isDragOver, handleAssetDragOver, handleAssetDrop, clearDropPreview };
@@ -6,11 +6,13 @@ export interface TimelineEditCapabilities {
function isDeterministicTimelineWindow(input: {
tag: string;
kind?: "video" | "audio" | "image" | "element" | "composition";
compositionSrc?: string;
playbackStartAttr?: "media-start" | "playback-start";
sourceDuration?: number;
}): boolean {
if (input.compositionSrc || input.playbackStartAttr != null) return true;
if (input.kind === "composition" || input.compositionSrc || input.playbackStartAttr != null)
return true;
if (
input.sourceDuration != null &&
Number.isFinite(input.sourceDuration) &&
@@ -27,6 +29,7 @@ export function hasPatchableTimelineTarget(input: { domId?: string; selector?: s
export function getTimelineEditCapabilities(input: {
tag: string;
kind?: "video" | "audio" | "image" | "element" | "composition";
duration: number;
domId?: string;
selector?: string;
@@ -53,6 +53,16 @@ describe("buildTimelineGroupResizeMembers (legacy 36413da7f semantics)", () => {
]);
});
it("seeds legacy composition offsets and advances them at playback rate", () => {
const a = el("a", { kind: "composition", tag: "div", start: 2, playbackRate: 2 });
const b = el("b", { kind: "composition", tag: "div", start: 5, playbackRate: 0.5 });
const members = buildTimelineGroupResizeMembers([a, b], keys("a", "b"), "a", "start")!;
expect(members.map((member) => member.playbackStart)).toEqual([0, 0]);
const changes = resolveTimelineGroupResizeChanges(members, "start", 1);
expect(changes.map((change) => change.playbackStart)).toEqual([2, 0.5]);
});
it("does not seed playbackStart on the END edge", () => {
const grabbed = el("a", { tag: "audio", start: 0, duration: 2 });
const b = el("b", { tag: "audio", start: 3, duration: 2 });
@@ -181,14 +181,15 @@ function elementKey(element: TimelineElement): string {
return element.key ?? element.id;
}
function isMediaTimelineElement(element: TimelineElement): boolean {
function hasSourcePlaybackOffset(element: TimelineElement): boolean {
const tag = element.tag.toLowerCase();
return tag === "audio" || tag === "video";
return element.kind === "composition" || tag === "audio" || tag === "video";
}
function canTrimEdge(element: TimelineElement, edge: TimelineGroupResizeEdge): boolean {
const caps = getTimelineEditCapabilities({
tag: element.tag,
kind: element.kind,
duration: element.duration,
domId: element.domId,
selector: element.selector,
@@ -228,7 +229,7 @@ export function buildTimelineGroupResizeMembers(
start: element.start,
duration: element.duration,
playbackStart:
edge === "start" && isMediaTimelineElement(element)
edge === "start" && hasSourcePlaybackOffset(element)
? (element.playbackStart ?? 0)
: element.playbackStart,
playbackRate: element.playbackRate,
@@ -0,0 +1,14 @@
export interface LaneMoveRefreshDeps {
refreshAfterLaneMove?: () => void;
}
/** Refresh only after the complete lane transaction persisted successfully. */
export function refreshAfterDurableLaneMove(
pending: Promise<boolean>,
deps: LaneMoveRefreshDeps,
): Promise<boolean> {
return pending.then((persisted) => {
if (persisted) deps.refreshAfterLaneMove?.();
return persisted;
});
}
@@ -383,6 +383,7 @@ export function resolveTimelineAssetDrop(
scrollTop: number;
pixelsPerSecond: number;
duration: number;
clampStartToDuration?: boolean;
trackHeight: number;
trackOrder: number[];
},
@@ -391,9 +392,10 @@ export function resolveTimelineAssetDrop(
): { start: number; track: number } {
const x = clientX - input.rectLeft + input.scrollLeft - GUTTER - TRACKS_LEFT_PAD;
const contentY = clientY - input.rectTop + input.scrollTop;
const pointerStart = Math.round((x / Math.max(input.pixelsPerSecond, 1)) * 100) / 100;
const start = Math.max(
0,
Math.min(input.duration, Math.round((x / Math.max(input.pixelsPerSecond, 1)) * 100) / 100),
input.clampStartToDuration === false ? pointerStart : Math.min(input.duration, pointerStart),
);
// Row from the shared row→y inverse so the top pad is honoured; a drop in the
// pad above the first lane floors to row 0, a drop in the bottom pad rounds
@@ -1,7 +1,7 @@
import type { TimelineElement } from "../store/playerStore";
import { classifyZone } from "./timelineZones";
import { isLaneFree, timeRangesOverlap } from "./timelineCollision";
import { authoredTrackForLane, sameSourceFile } from "./timelineClipDragCommit";
import { authoredTrackForLane, sameSourceFile } from "./timelineAuthoredTrack";
import { samePaintScope } from "./timelineStackingSync";
/**
@@ -77,6 +77,7 @@ interface UseTimelineClipDragInput {
*/
readZIndex?: (element: TimelineElement) => number;
onStackingPatches?: (patches: StackingPatch[]) => Promise<unknown> | void;
refreshAfterLaneMove?: () => void;
}
export function useTimelineClipDrag({
@@ -93,6 +94,7 @@ export function useTimelineClipDrag({
setRangeSelectionRef,
readZIndex,
onStackingPatches,
refreshAfterLaneMove,
}: UseTimelineClipDragInput) {
const updateElement = usePlayerStore((s) => s.updateElement);
const rawBeatTimes = usePlayerStore((s) => s.beatAnalysis?.beatTimes ?? EMPTY_BEAT_TIMES);
@@ -213,6 +215,8 @@ export function useTimelineClipDrag({
readZIndexRef.current = readZIndex;
const onStackingPatchesRef = useRef(onStackingPatches);
onStackingPatchesRef.current = onStackingPatches;
const refreshAfterLaneMoveRef = useRef(refreshAfterLaneMove);
refreshAfterLaneMoveRef.current = refreshAfterLaneMove;
const clipDragScrollRaf = useRef(0);
const clipDragPointerRef = useRef<{
@@ -499,6 +503,7 @@ export function useTimelineClipDrag({
// deps (Timeline.tsx). Absent → commitDraggedClipMove skips the z-sync.
readZIndex: readZIndexRef.current,
onStackingPatches: onStackingPatchesRef.current,
refreshAfterLaneMove: refreshAfterLaneMoveRef.current,
});
};
@@ -12,6 +12,7 @@ interface UseTimelineEditPinningInput {
onFileDrop: TimelineDropCallbacks["onFileDrop"];
onAssetDrop: TimelineDropCallbacks["onAssetDrop"];
onBlockDrop: TimelineDropCallbacks["onBlockDrop"];
onCompositionDrop: TimelineDropCallbacks["onCompositionDrop"];
}
// Wrap every mutating timeline edit so the zoom pins to the current on-screen
@@ -29,6 +30,7 @@ export function useTimelineEditPinning({
onFileDrop,
onAssetDrop,
onBlockDrop,
onCompositionDrop,
}: UseTimelineEditPinningInput) {
const pinTimelineZoom = usePlayerStore((s) => s.pinTimelineZoom);
// Pin the timeline zoom to the current on-screen scale on the FIRST edit, so a
@@ -106,6 +108,15 @@ export function useTimelineEditPinning({
}),
[onBlockDrop, pinZoomBeforeEdit],
);
const pinnedOnCompositionDrop = useMemo(
() =>
onCompositionDrop &&
((...args: Parameters<typeof onCompositionDrop>) => {
pinZoomBeforeEdit();
return onCompositionDrop(...args);
}),
[onCompositionDrop, pinZoomBeforeEdit],
);
return {
pinZoomBeforeEdit,
@@ -117,5 +128,6 @@ export function useTimelineEditPinning({
pinnedOnFileDrop,
pinnedOnAssetDrop,
pinnedOnBlockDrop,
pinnedOnCompositionDrop,
};
}
@@ -48,9 +48,51 @@ describe("buildExpandedElements", () => {
const out = buildExpandedElements(elements, manifest, parentMap, "s3", "s3");
const child = out.find((e) => e.domId === "stat-1")!;
expect(child.expandedParentStart).toBe(16);
expect(child.expandedHostKey).toBe("s3");
expect(child.sourceFile).toBe("stats.html");
});
it("keeps repeated same-source composition hosts as distinct move identities", () => {
const elements = [
el({
id: "host-a",
key: "index.html#host-a",
start: 0,
duration: 5,
compositionSrc: "scene.html",
}),
el({
id: "host-b",
key: "index.html#host-b",
start: 8,
duration: 5,
compositionSrc: "scene.html",
}),
];
const manifest = [
clip({ id: "host-a", start: 0, duration: 5, compositionSrc: "scene.html" }),
clip({ id: "child-a", start: 1, duration: 2 }),
clip({ id: "host-b", start: 8, duration: 5, compositionSrc: "scene.html" }),
clip({ id: "child-b", start: 9, duration: 2 }),
];
const parentMap = new Map([
["child-a", "host-a"],
["child-b", "host-b"],
]);
const childA = buildExpandedElements(elements, manifest, parentMap, "host-a", "host-a").find(
(element) => element.domId === "child-a",
);
const childB = buildExpandedElements(elements, manifest, parentMap, "host-b", "host-b").find(
(element) => element.domId === "child-b",
);
expect(childA?.sourceFile).toBe("scene.html");
expect(childB?.sourceFile).toBe("scene.html");
expect(childA?.expandedHostKey).toBe("index.html#host-a");
expect(childB?.expandedHostKey).toBe("index.html#host-b");
});
// fallow-ignore-next-line code-duplication
it("rebases a 2-level child onto its NESTED host, not the top-level scene", () => {
// top host A@10 (a.html) embeds host B@12 (b.html); child C lives in b.html.
@@ -133,6 +133,7 @@ function buildChildElements(
siblings: ClipManifestClip[],
display: DisplayBounds,
editBasis: { start: number; sourceFile: string | undefined },
expandedHostKey: string,
): TimelineElement[] {
const result: TimelineElement[] = [];
for (const child of siblings) {
@@ -182,6 +183,7 @@ function buildChildElements(
authoredTrack: base.authoredTrack,
stackingContextId: base.stackingContextId,
expandedParentStart: editBasis.start,
expandedHostKey,
domId,
selector,
sourceFile: editBasis.sourceFile,
@@ -260,6 +262,7 @@ export function buildExpandedElements(
track: topLevelElement.track,
},
editBasis,
parentKey,
);
if (expanded.length === 0) return filterToTopLevel(elements, parentMap);
@@ -46,6 +46,8 @@ export interface ClipManifestClip {
compositionAncestors?: string[];
parentCompositionId: string | null;
compositionSrc: string | null;
playbackStart?: number;
playbackRate?: number;
assetUrl: string | null;
}
@@ -110,7 +110,65 @@ describe("parseTimelineFromDOM — hfId from data-hf-id", () => {
});
});
describe("parseTimelineFromDOM — canonical playback rate", () => {
it.each([
["10", 5],
["0.01", 0.1],
])("clamps authored rate %s to %s for trim and split math", (authored, expected) => {
const doc = makeDoc(`
<div data-composition-id="root">
<div id="nested" class="clip" data-composition-src="scene.html"
data-start="0" data-duration="5" data-playback-rate="${authored}"></div>
</div>
`);
const nested = parseTimelineFromDOM(doc, 10).find((entry) => entry.domId === "nested");
expect(nested?.playbackRate).toBe(expected);
});
});
describe("createTimelineElementFromManifestClip — source-scoped selector identity", () => {
it("preserves composition kind and source timing on first translation", () => {
const doc = makeDoc(`
<div data-composition-id="root" data-composition-file="index.html">
<div id="host" data-composition-id="scene" data-composition-src="scene.html"
data-playback-start="1.5" data-playback-rate="2"></div>
</div>
`);
const host = doc.getElementById("host");
const element = createTimelineElementFromManifestClip({
clip: {
id: "host",
label: "Scene",
kind: "composition",
tagName: "div",
start: 2,
duration: 4,
track: 0,
compositionId: "scene",
parentCompositionId: "root",
compositionSrc: "scene.html",
playbackStart: 1.5,
playbackRate: 2,
assetUrl: null,
},
fallbackIndex: 0,
doc,
hostEl: host,
});
expect(element).toMatchObject({
kind: "composition",
compositionSrc: "scene.html",
playbackStart: 1.5,
playbackStartAttr: "playback-start",
playbackRate: 2,
domId: "host",
});
});
it("ignores an index.html duplicate when indexing a scene.html selector", () => {
const doc = makeDoc(`
<div data-composition-id="root" data-composition-file="index.html">
+18 -1
View File
@@ -111,6 +111,7 @@ export function createTimelineElementFromManifestClip(params: {
id: identity.id,
label,
key: identity.key,
kind: clip.kind,
tag: resolveClipTag(clip),
start: clip.start,
duration: clip.duration,
@@ -129,6 +130,8 @@ export function createTimelineElementFromManifestClip(params: {
selector,
selectorIndex,
sourceFile,
playbackStart: clip.playbackStart,
playbackRate: clip.playbackRate,
};
if (hostEl) {
@@ -140,6 +143,8 @@ export function createTimelineElementFromManifestClip(params: {
}
if (clip.assetUrl) entry.src = clip.assetUrl;
if (clip.kind === "composition" && clip.compositionId) {
entry.playbackStart ??= 0;
entry.playbackRate ??= 1;
let resolvedSrc = clip.compositionSrc;
if (!resolvedSrc) {
hostEl =
@@ -293,6 +298,14 @@ export function parseTimelineFromDOM(doc: Document, rootDuration: number): Timel
id: identity.id,
label,
key: identity.key,
kind:
compId && compId !== rootComp?.getAttribute("data-composition-id")
? "composition"
: tagLower === "video" || tagLower === "audio"
? tagLower
: tagLower === "img"
? "image"
: "element",
tag: tagLower,
start,
duration: dur,
@@ -308,13 +321,13 @@ export function parseTimelineFromDOM(doc: Document, rootDuration: number): Timel
};
const mediaEl = resolveMediaElement(el);
applyMediaMetadataFromElement(entry, el);
if (mediaEl) {
if (mediaEl.tagName === "IMG") {
entry.tag = "img";
}
const vol = el.getAttribute("data-volume") ?? mediaEl.getAttribute("data-volume");
if (vol) entry.volume = parseFloat(vol);
applyMediaMetadataFromElement(entry, el);
// Override AFTER the helper (which sets the raw relative attribute) so the
// resolved absolute URL wins — the Studio can then fetch the asset
// regardless of whether the attribute value was relative or absolute.
@@ -345,6 +358,10 @@ export function parseTimelineFromDOM(doc: Document, rootDuration: number): Timel
entry.tag = "video";
}
}
if (entry.kind === "composition") {
entry.playbackStart ??= 0;
entry.playbackRate ??= 1;
}
els.push(entry);
});
@@ -76,6 +76,10 @@ function readDurationAttribute(el: Element | null | undefined): number {
return isFinitePositive(duration) ? duration : 0;
}
function normalizePlaybackRate(raw: number): number {
return Number.isFinite(raw) && raw > 0 ? Math.max(0.1, Math.min(5, raw)) : 1;
}
export function isTimelineIgnoredElement(el: Element): boolean {
return Boolean(
el.closest(
@@ -149,19 +153,25 @@ export function resolveMediaElement(el: Element): HTMLMediaElement | HTMLImageEl
: null;
}
export function applyMediaMetadataFromElement(entry: TimelineElement, el: Element): void {
const mediaStartAttr = el.getAttribute("data-playback-start")
? "playback-start"
: el.getAttribute("data-media-start")
? "media-start"
: undefined;
const mediaStartValue =
el.getAttribute("data-playback-start") ?? el.getAttribute("data-media-start");
function applyPlaybackMetadataFromElement(entry: TimelineElement, el: Element): void {
const playbackStartValue = el.getAttribute("data-playback-start");
const legacyMediaStartValue = el.getAttribute("data-media-start");
const mediaStartValue = playbackStartValue ?? legacyMediaStartValue;
if (mediaStartValue != null) {
const playbackStart = parseFloat(mediaStartValue);
if (Number.isFinite(playbackStart)) entry.playbackStart = playbackStart;
}
if (mediaStartAttr) entry.playbackStartAttr = mediaStartAttr;
if (playbackStartValue != null) entry.playbackStartAttr = "playback-start";
else if (legacyMediaStartValue != null) entry.playbackStartAttr = "media-start";
const authoredPlaybackRate = Number.parseFloat(el.getAttribute("data-playback-rate") ?? "");
if (Number.isFinite(authoredPlaybackRate) && authoredPlaybackRate > 0) {
entry.playbackRate = normalizePlaybackRate(authoredPlaybackRate);
}
}
export function applyMediaMetadataFromElement(entry: TimelineElement, el: Element): void {
applyPlaybackMetadataFromElement(entry, el);
const mediaEl = resolveMediaElement(el);
if (!mediaEl) return;
@@ -182,8 +192,8 @@ export function applyMediaMetadataFromElement(entry: TimelineElement, el: Elemen
}
const playbackRate = mediaEl.defaultPlaybackRate;
if (Number.isFinite(playbackRate) && playbackRate > 0) {
entry.playbackRate = playbackRate;
if (entry.playbackRate == null && Number.isFinite(playbackRate) && playbackRate > 0) {
entry.playbackRate = normalizePlaybackRate(playbackRate);
}
}
@@ -25,6 +25,7 @@ export interface TimelineElement {
id: string;
label?: string;
key?: string;
kind?: ClipManifestClip["kind"];
tag: string;
start: number;
duration: number;
@@ -82,8 +83,8 @@ export interface TimelineElement {
* the child's local (sourceFile-relative) time. Works at any nesting depth.
*/
expandedParentStart?: number;
expandedHostKey?: string;
}
export type ZoomMode = "fit" | "manual";
type TimelineTool = "select" | "razor";
@@ -0,0 +1,175 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { TimelineElement } from "../player";
import { buildAtomicCutIntents, runAtomicCutTransaction } from "./razorSplitTransaction";
const element = (over: Partial<TimelineElement> = {}): TimelineElement => ({
id: "clip",
domId: "clip",
tag: "div",
start: 0,
duration: 4,
track: 0,
timingSource: "authored",
sourceFile: "index.html",
...over,
});
afterEach(() => vi.unstubAllGlobals());
describe("buildAtomicCutIntents", () => {
it("deduplicates runtime aliases but keeps repeated authored hosts distinct", () => {
const intents = buildAtomicCutIntents(
[
element({ id: "runtime-a", domId: "host-a", hfId: "stable-a" }),
element({ id: "alias-a", domId: "host-a", hfId: "stable-a" }),
element({ id: "runtime-b", domId: "host-b", hfId: "stable-b" }),
],
2,
"index.html",
);
expect(intents).toHaveLength(1);
expect(intents[0].targets).toHaveLength(2);
expect(intents[0].targets.map((target) => target.originalId)).toEqual(["host-a", "host-b"]);
});
it("rebases each nested target into its own source-file coordinates", () => {
const intents = buildAtomicCutIntents(
[element({ start: 8, duration: 4, expandedParentStart: 6, sourceFile: "scene.html" })],
10,
"index.html",
);
expect(intents[0].targets[0]).toMatchObject({ splitTime: 4, elementStart: 2 });
});
});
function installCutServer(options: { status?: number } = {}) {
const requests: Array<{ url: string; body?: unknown }> = [];
vi.stubGlobal(
"fetch",
vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
const url = String(input);
requests.push({ url, body: init?.body ? JSON.parse(String(init.body)) : undefined });
if (url.includes("/files/")) {
return new Response(JSON.stringify({ content: "before", version: '"v0"' }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
if (options.status) {
return new Response(JSON.stringify({ error: "stale base" }), {
status: options.status,
headers: { "Content-Type": "application/json" },
});
}
return new Response(
JSON.stringify({
ok: true,
outcome: "committed",
files: [
{
path: "index.html",
before: "before",
after: "after",
version: '"v1"',
writeToken: "cut-1",
splitCount: 1,
skippedSelectors: [],
},
],
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
}),
);
return requests;
}
describe("runAtomicCutTransaction", () => {
it("records canonical snapshots once and performs no client forward write", async () => {
const requests = installCutServer();
const writeProjectFile = vi.fn();
const recordEdit = vi.fn().mockResolvedValue(undefined);
const observe = vi.fn();
const synchronize = vi.fn();
const result = await runAtomicCutTransaction({
projectId: "launch/demo",
intents: buildAtomicCutIntents([element()], 2, "index.html"),
label: "Split timeline clip",
writeProjectFile,
recordEdit,
observeProjectFileVersion: observe,
synchronize,
});
expect(requests.filter((request) => request.url.includes("split-batch"))).toHaveLength(1);
expect(requests.map((request) => request.url)).toEqual([
"/api/projects/launch%2Fdemo/files/index.html",
"/api/projects/launch%2Fdemo/file-mutations/split-batch",
]);
expect(writeProjectFile).not.toHaveBeenCalled();
expect(recordEdit).toHaveBeenCalledWith({
label: "Split timeline clip",
kind: "timeline",
files: { "index.html": { before: "before", after: "after" } },
});
expect(observe).toHaveBeenCalledWith("index.html", '"v1"');
expect(synchronize).toHaveBeenCalledTimes(1);
expect(result).toMatchObject({ splitCount: 1, syncFailed: false });
});
it("CAS-restores durable bytes when history registration fails", async () => {
installCutServer();
const writeProjectFile = vi.fn().mockResolvedValue(undefined);
await expect(
runAtomicCutTransaction({
projectId: "p1",
intents: buildAtomicCutIntents([element()], 2, "index.html"),
label: "Split timeline clip",
writeProjectFile,
recordEdit: vi.fn().mockRejectedValue(new Error("history unavailable")),
synchronize: vi.fn(),
}),
).rejects.toThrow("history unavailable");
expect(writeProjectFile).toHaveBeenCalledTimes(1);
expect(writeProjectFile).toHaveBeenCalledWith("index.html", "before", "after");
});
it("reports an initial version conflict with no history or client write", async () => {
installCutServer({ status: 409 });
const writeProjectFile = vi.fn();
const recordEdit = vi.fn();
await expect(
runAtomicCutTransaction({
projectId: "p1",
intents: buildAtomicCutIntents([element()], 2, "index.html"),
label: "Split timeline clip",
writeProjectFile,
recordEdit,
synchronize: vi.fn(),
}),
).rejects.toThrow("Cut conflict");
expect(writeProjectFile).not.toHaveBeenCalled();
expect(recordEdit).not.toHaveBeenCalled();
});
it("keeps a durable recorded cut when synchronization fails", async () => {
installCutServer();
const result = await runAtomicCutTransaction({
projectId: "p1",
intents: buildAtomicCutIntents([element()], 2, "index.html"),
label: "Split timeline clip",
writeProjectFile: vi.fn(),
recordEdit: vi.fn().mockResolvedValue(undefined),
synchronize: () => {
throw new Error("preview unavailable");
},
});
expect(result.syncFailed).toBe(true);
});
});
@@ -0,0 +1,200 @@
import type { TimelineElement } from "../player";
import type { RecordEditInput } from "../hooks/timelineEditingHelpers";
import { buildPatchTarget } from "./timelineElementSplit";
import { serializeStudioFileMutations } from "./studioFileMutationCoordinator";
import { buildProjectApiPath } from "./projectRouting";
type ProjectFileWriter = (path: string, content: string, expectedContent?: string) => Promise<void>;
interface CutTarget {
target: NonNullable<ReturnType<typeof buildPatchTarget>>;
originalId?: string;
splitTime: number;
elementStart: number;
elementDuration: number;
playbackStart?: number;
playbackRate?: number;
isComposition?: boolean;
}
interface CutFileIntent {
path: string;
targets: CutTarget[];
}
interface CutFileResult {
path: string;
before: string;
after: string;
version: string;
writeToken: string;
splitCount: number;
skippedSelectors: string[];
}
interface CutBatchResponse {
ok: true;
outcome: "committed";
files: CutFileResult[];
}
export interface AtomicCutResult {
splitCount: number;
skippedSelectors: string[];
syncFailed: boolean;
}
function targetIdentity(
path: string,
target: NonNullable<ReturnType<typeof buildPatchTarget>>,
): string {
if (target.hfId) return `${path}|hf:${target.hfId}`;
if (target.id) return `${path}|id:${target.id}`;
return `${path}|selector:${target.selector ?? ""}:${target.selectorIndex ?? 0}`;
}
function buildCutTarget(
element: TimelineElement,
target: CutTarget["target"],
splitTime: number,
): CutTarget {
const basis = element.expandedParentStart;
return {
target,
...(element.domId ? { originalId: element.domId } : {}),
splitTime: basis === undefined ? splitTime : Math.max(0, splitTime - basis),
elementStart: basis === undefined ? element.start : element.start - basis,
elementDuration: element.duration,
...(element.playbackStart != null ? { playbackStart: element.playbackStart } : {}),
...(element.playbackRate != null ? { playbackRate: element.playbackRate } : {}),
...(element.kind === "composition" ? { isComposition: true } : {}),
};
}
/** Group one immutable cut time by file and collapse runtime aliases once. */
export function buildAtomicCutIntents(
elements: readonly TimelineElement[],
splitTime: number,
activeCompPath: string | null,
): CutFileIntent[] {
const byPath = new Map<string, CutFileIntent>();
const seen = new Set<string>();
for (const element of elements) {
const target = buildPatchTarget(element);
if (!target) throw new Error("Clip is missing a patchable target.");
const path = element.sourceFile || activeCompPath || "index.html";
const identity = targetIdentity(path, target);
if (seen.has(identity)) continue;
seen.add(identity);
const intent = byPath.get(path) ?? { path, targets: [] };
intent.targets.push(buildCutTarget(element, target, splitTime));
byPath.set(path, intent);
}
return [...byPath.values()].sort((a, b) => a.path.localeCompare(b.path));
}
async function readFileVersion(projectId: string, path: string): Promise<string> {
const response = await fetch(
buildProjectApiPath(projectId, `/files/${encodeURIComponent(path)}`),
);
if (!response.ok) throw new Error(`Failed to read ${path} before cut (${response.status})`);
const body = (await response.json()) as { version?: string };
const version = body.version ?? response.headers.get("etag") ?? undefined;
if (!version) throw new Error(`Missing content version for ${path}`);
return version;
}
async function requestAtomicCut(
projectId: string,
intents: CutFileIntent[],
): Promise<CutBatchResponse> {
const files = [];
for (const intent of intents) {
files.push({
...intent,
expectedVersion: await readFileVersion(projectId, intent.path),
});
}
const transactionToken = `cut:${crypto.randomUUID()}`;
const response = await fetch(buildProjectApiPath(projectId, "/file-mutations/split-batch"), {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Hyperframes-Write-Token": transactionToken,
},
body: JSON.stringify({ files, transactionToken }),
});
const body = (await response.json().catch(() => null)) as
| (Partial<CutBatchResponse> & { error?: string; outcome?: string })
| null;
if (!response.ok || body?.ok !== true || !Array.isArray(body.files)) {
const prefix = response.status === 409 ? "Cut conflict" : "Cut failed";
throw new Error(`${prefix}: ${body?.error ?? `server returned ${response.status}`}`);
}
return body as CutBatchResponse;
}
async function rollbackUnrecordedCut(
files: readonly CutFileResult[],
writeProjectFile: ProjectFileWriter,
): Promise<void> {
const failures: unknown[] = [];
for (const file of [...files].reverse()) {
try {
await writeProjectFile(file.path, file.before, file.after);
} catch (error) {
failures.push(error);
}
}
if (failures.length > 0) {
throw new AggregateError(
failures,
"Cut history failed and externally changed files could not be safely restored",
);
}
}
interface RunAtomicCutInput {
projectId: string;
intents: CutFileIntent[];
label: string;
writeProjectFile: ProjectFileWriter;
recordEdit: (input: RecordEditInput) => Promise<void>;
observeProjectFileVersion?: (path: string, version: string | null) => void;
synchronize: () => void;
}
/** One coordinator owns request, history registration, safe rollback, and resync. */
export function runAtomicCutTransaction(input: RunAtomicCutInput): Promise<AtomicCutResult> {
const paths = input.intents.map((intent) => intent.path);
return serializeStudioFileMutations(input.writeProjectFile, paths, async () => {
const result = await requestAtomicCut(input.projectId, input.intents);
const snapshots = Object.fromEntries(
result.files.map((file) => [file.path, { before: file.before, after: file.after }]),
);
try {
await input.recordEdit({ label: input.label, kind: "timeline", files: snapshots });
} catch (error) {
try {
await rollbackUnrecordedCut(result.files, input.writeProjectFile);
} catch (rollbackError) {
throw new AggregateError([error, rollbackError], "Cut aborted with rollback conflicts");
}
throw error;
}
for (const file of result.files) input.observeProjectFileVersion?.(file.path, file.version);
let syncFailed = false;
try {
input.synchronize();
} catch {
syncFailed = true;
}
return {
splitCount: result.files.reduce((count, file) => count + file.splitCount, 0),
skippedSelectors: [...new Set(result.files.flatMap((file) => file.skippedSelectors))],
syncFailed,
};
});
}
@@ -114,6 +114,33 @@ describe("pauseStudioPreviewPlayback", () => {
});
describe("getPreviewTargetFromPointer", () => {
it("chooses the deepest headline through a transparent overflow mask", () => {
const { iframe, doc } = createPreviewIframe();
doc.body.innerHTML = `
<template id="source-template"></template>
<main data-composition-id="scene">
<section class="hl-block">
<div class="hl-mask" style="overflow: hidden; background: transparent">
<h1 class="hl-text">Launch title</h1>
</div>
</section>
</main>
`;
const scene = doc.querySelector<HTMLElement>("main")!;
const block = doc.querySelector<HTMLElement>(".hl-block")!;
const mask = doc.querySelector<HTMLElement>(".hl-mask")!;
const headline = doc.querySelector<HTMLElement>(".hl-text")!;
stubRect(iframe, domRect(0, 0, 400, 300));
stubRect(scene, domRect(0, 0, 400, 300));
stubRect(block, domRect(30, 30, 300, 100));
stubRect(mask, domRect(40, 40, 260, 64));
stubRect(headline, domRect(44, 44, 220, 48));
doc.elementsFromPoint = () => [headline, mask, block, scene];
expect(getPreviewTargetFromPointer(iframe, 80, 64, "index.html")).toBe(headline);
iframe.remove();
});
it("skips candidates hidden from author hit-testing by inherited pointer-events:none", () => {
const { iframe, doc } = createPreviewIframe();
@@ -0,0 +1,12 @@
import { describe, expect, it } from "vitest";
import { parseTimelineCompositionPayload } from "./timelineCompositionDrop";
describe("timeline composition drop", () => {
it("parses valid composition payloads and rejects malformed ones", () => {
expect(parseTimelineCompositionPayload('{"sourcePath":"scene.html"}')).toEqual({
sourcePath: "scene.html",
});
expect(parseTimelineCompositionPayload('{"path":"scene.html"}')).toBeNull();
expect(parseTimelineCompositionPayload("nope")).toBeNull();
});
});
@@ -0,0 +1,16 @@
export const TIMELINE_COMPOSITION_MIME = "application/x-hyperframes-composition";
export interface TimelineCompositionPayload {
sourcePath: string;
}
export function parseTimelineCompositionPayload(raw: string): TimelineCompositionPayload | null {
try {
const value: unknown = JSON.parse(raw);
if (typeof value !== "object" || value === null || !("sourcePath" in value)) return null;
const sourcePath = value.sourcePath;
return typeof sourcePath === "string" && sourcePath.trim() ? { sourcePath } : null;
} catch {
return null;
}
}
@@ -0,0 +1,142 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { commitTimelineCompositionInsertion } from "./timelineCompositionInsert";
afterEach(() => vi.unstubAllGlobals());
function response(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}
describe("commitTimelineCompositionInsertion", () => {
it("records one history entry, then selects and refreshes once", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(response({ content: "before", version: "v1" }))
.mockResolvedValueOnce(
response({
path: "index.html",
hostId: "headline",
before: "before",
after: "after",
version: "v2",
}),
);
vi.stubGlobal("fetch", fetchMock);
const writeFile = vi.fn();
const recordEdit = vi.fn();
const observeVersion = vi.fn();
const selectHost = vi.fn();
const resync = vi.fn();
const refresh = vi.fn();
await commitTimelineCompositionInsertion({
projectId: "launch/demo",
targetPath: "index.html",
sourcePath: "headline.html",
start: 4,
track: 2,
writeFile,
recordEdit,
observeVersion,
selectHost,
resync,
refresh,
});
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([
"/api/projects/launch%2Fdemo/files/index.html",
"/api/projects/launch%2Fdemo/file-mutations/insert-composition/index.html",
]);
expect(recordEdit).toHaveBeenCalledOnce();
expect(writeFile).not.toHaveBeenCalled();
expect(observeVersion).toHaveBeenCalledWith("index.html", "v2");
expect(selectHost).toHaveBeenCalledWith("index.html#headline");
expect(resync).toHaveBeenCalledOnce();
expect(refresh).toHaveBeenCalledOnce();
});
it("CAS-restores the server write when history registration fails", async () => {
vi.stubGlobal(
"fetch",
vi
.fn()
.mockResolvedValueOnce(response({ content: "before", version: "v1" }))
.mockResolvedValueOnce(
response({
path: "index.html",
hostId: "headline",
before: "before",
after: "after",
version: "v2",
}),
),
);
const writeFile = vi.fn();
const refresh = vi.fn();
await expect(
commitTimelineCompositionInsertion({
projectId: "demo",
targetPath: "index.html",
sourcePath: "headline.html",
start: 4,
track: 2,
writeFile,
recordEdit: vi.fn().mockRejectedValue(new Error("history failed")),
selectHost: vi.fn(),
refresh,
}),
).rejects.toThrow("history failed");
expect(writeFile).toHaveBeenCalledWith("index.html", "before", "after");
expect(refresh).not.toHaveBeenCalled();
});
it("keeps a durable insertion successful and refreshes when resync fails", async () => {
vi.stubGlobal(
"fetch",
vi
.fn()
.mockResolvedValueOnce(response({ content: "before", version: "v1" }))
.mockResolvedValueOnce(
response({
path: "index.html",
hostId: "headline",
before: "before",
after: "after",
version: "v2",
}),
),
);
const refresh = vi.fn();
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
await expect(
commitTimelineCompositionInsertion({
projectId: "demo",
targetPath: "index.html",
sourcePath: "headline.html",
start: 4,
track: 2,
writeFile: vi.fn(),
recordEdit: vi.fn(),
selectHost: vi.fn(),
resync: () => {
throw new Error("resync failed");
},
refresh,
}),
).resolves.toBeUndefined();
expect(refresh).toHaveBeenCalledOnce();
expect(consoleError).toHaveBeenCalledWith(
"[Studio] Composition insertion committed but preview resync failed",
expect.any(Error),
);
consoleError.mockRestore();
});
});
@@ -0,0 +1,90 @@
import { createStudioSaveHttpError } from "./studioSaveDiagnostics";
import { serializeStudioFileMutation } from "./studioFileMutationCoordinator";
import type { RecordEditInput } from "./studioFileHistory";
import { buildProjectApiPath } from "./projectRouting";
interface TimelineCompositionInsertionResult {
path: string;
hostId: string;
before: string;
after: string;
version: string;
}
async function insertTimelineComposition(input: {
projectId: string;
targetPath: string;
sourcePath: string;
start: number;
track: number;
}): Promise<TimelineCompositionInsertionResult> {
const current = await fetch(
buildProjectApiPath(input.projectId, `/files/${encodeURIComponent(input.targetPath)}`),
);
if (!current.ok) {
throw await createStudioSaveHttpError(current, `Failed to read ${input.targetPath}`);
}
const snapshot = (await current.json()) as { version?: string };
if (typeof snapshot.version !== "string") throw new Error("Missing composition file version");
const response = await fetch(
buildProjectApiPath(
input.projectId,
`/file-mutations/insert-composition/${encodeURIComponent(input.targetPath)}`,
),
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
sourcePath: input.sourcePath,
start: input.start,
track: input.track,
expectedVersion: snapshot.version,
}),
},
);
if (!response.ok) {
throw await createStudioSaveHttpError(response, "Failed to add composition to timeline");
}
return (await response.json()) as TimelineCompositionInsertionResult;
}
export async function commitTimelineCompositionInsertion(input: {
projectId: string;
targetPath: string;
sourcePath: string;
start: number;
track: number;
writeFile: (path: string, content: string, expectedContent?: string) => Promise<void>;
recordEdit: (entry: RecordEditInput) => Promise<void>;
observeVersion?: (path: string, version: string | null) => void;
selectHost: (key: string) => void;
resync?: () => void;
refresh: () => void;
}): Promise<void> {
await serializeStudioFileMutation(input.writeFile, input.targetPath, async () => {
const result = await insertTimelineComposition(input);
input.observeVersion?.(input.targetPath, result.version);
try {
await input.recordEdit({
label: "Add composition to timeline",
kind: "timeline",
files: { [input.targetPath]: { before: result.before, after: result.after } },
});
} catch (error) {
await input.writeFile(input.targetPath, result.before, result.after);
throw error;
}
input.selectHost(`${input.targetPath}#${result.hostId}`);
try {
input.resync?.();
} catch (error) {
console.error("[Studio] Composition insertion committed but preview resync failed", error);
}
try {
input.refresh();
} catch (error) {
console.error("[Studio] Composition insertion committed but refresh failed", error);
}
});
}
@@ -14,6 +14,7 @@ function element(overrides: Partial<TimelineElement> = {}): TimelineElement {
start: 1,
duration: 4,
track: 0,
domId: "el-1",
...overrides,
};
}
@@ -77,10 +78,21 @@ describe("canSplitElementAt", () => {
).toBe(false);
});
it("rejects locked, implicit and sub-composition elements", () => {
it("rejects locked and implicit elements while allowing identified compositions", () => {
expect(canSplitElementAt(element({ timelineLocked: true }), 3)).toBe(false);
expect(canSplitElementAt(element({ timingSource: "implicit" }), 3)).toBe(false);
expect(canSplitElementAt(element({ compositionSrc: "child.html" }), 3)).toBe(false);
expect(
canSplitElementAt(
element({ kind: "composition", compositionSrc: "child.html", playbackRate: 2 }),
3,
),
).toBe(true);
});
it("rejects missing identity and invalid playback rates", () => {
expect(canSplitElementAt(element({ domId: undefined }), 3)).toBe(false);
expect(canSplitElementAt(element({ playbackRate: 0 }), 3)).toBe(false);
expect(canSplitElementAt(element({ playbackRate: Number.NaN }), 3)).toBe(false);
});
});
@@ -22,10 +22,14 @@ export function isSplitTimeWithinBounds(
}
export function canSplitElement(el: TimelineElement): boolean {
const hasStableIdentity = Boolean(el.hfId || el.domId || el.selector);
const hasValidRate =
el.playbackRate == null || (Number.isFinite(el.playbackRate) && el.playbackRate > 0);
return (
!el.timelineLocked &&
el.timingSource !== "implicit" &&
!el.compositionSrc &&
hasStableIdentity &&
hasValidRate &&
!!el.duration &&
Number.isFinite(el.duration)
);
@@ -0,0 +1,13 @@
# Composition reliability acceptance fixture
Compact, media-free Studio project for validating composition editing as one stack:
- two root hosts reuse `title-card.html` at different times;
- `nested-shell.html` hosts the same title card one level deeper;
- the title card uses a transparent overflow mask around an editable headline;
- adjacent clips on one track provide a clean collision/new-track drop target;
- a cross-track overlap exercises normal visual layering.
Copy this directory to scratch before browser acceptance. Exercise open, composition insert,
single/multi move, collision placement, overlap layering, cut, headline color/font-size, and one-step
undo. The checked-in fixture must remain unchanged.
@@ -0,0 +1,24 @@
<template id="nested-shell-template">
<section
id="nested-shell-root"
data-composition-id="nested-shell"
data-width="480"
data-height="220"
data-start="0"
data-duration="6"
data-no-timeline
style="position: relative; width: 480px; height: 220px; overflow: hidden; background: #17221f"
>
<div
id="nested-title-host"
class="clip"
data-hf-id="nested-title-host"
data-composition-id="nested-title-card"
data-composition-src="title-card.html"
data-start="1"
data-duration="4"
data-track-index="0"
style="position: absolute; inset: 0; width: 480px; height: 220px"
></div>
</section>
</template>
@@ -0,0 +1,41 @@
<template id="title-card-template">
<section
id="title-card-root"
data-composition-id="title-card"
data-width="480"
data-height="220"
data-start="0"
data-duration="4"
data-no-timeline
>
<style>
#title-card-root {
box-sizing: border-box;
width: 480px;
height: 220px;
padding: 32px;
overflow: hidden;
background: #202736;
font-family: Arial, sans-serif;
}
.hl-block {
width: 100%;
}
.hl-mask {
overflow: hidden;
background: transparent;
}
.hl-text {
margin: 0;
color: #f4f7ff;
font-size: 52px;
line-height: 1.05;
}
</style>
<div class="hl-block" data-hf-id="title-block">
<div class="hl-mask" data-hf-id="title-mask">
<h1 class="hl-text" data-hf-id="title-text">Reliable compositions</h1>
</div>
</div>
</section>
</template>
@@ -0,0 +1,9 @@
{
"$schema": "https://hyperframes.heygen.com/schema/hyperframes.json",
"registry": "https://raw.githubusercontent.com/heygen-com/hyperframes/main/registry",
"paths": {
"blocks": "compositions",
"components": "compositions/components",
"assets": "assets"
}
}
@@ -0,0 +1,128 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Composition Reliability Fixture</title>
<style>
html,
body {
width: 1280px;
height: 720px;
margin: 0;
overflow: hidden;
background: #11151c;
color: white;
font-family: Arial, sans-serif;
}
#composition-reliability {
position: relative;
width: 1280px;
height: 720px;
overflow: hidden;
}
.composition-host {
position: absolute;
width: 480px;
height: 220px;
}
#title-host-a {
left: 48px;
top: 48px;
}
#title-host-b {
left: 600px;
top: 48px;
}
#nested-host {
left: 48px;
top: 330px;
}
.collision-shape {
position: absolute;
top: 580px;
width: 220px;
height: 90px;
border-radius: 18px;
}
#collision-a {
left: 600px;
background: #7357ff;
}
#collision-b {
left: 740px;
background: #ff5c7a;
}
#layer-overlap {
left: 880px;
background: rgba(42, 211, 162, 0.75);
}
</style>
</head>
<body>
<main
id="composition-reliability"
data-composition-id="composition-reliability"
data-width="1280"
data-height="720"
data-start="0"
data-duration="12"
data-fps="30"
data-no-timeline
>
<div
id="title-host-a"
class="clip composition-host"
data-hf-id="title-host-a"
data-composition-id="title-card-a"
data-composition-src="compositions/title-card.html"
data-start="0"
data-duration="4"
data-track-index="0"
></div>
<div
id="title-host-b"
class="clip composition-host"
data-hf-id="title-host-b"
data-composition-id="title-card-b"
data-composition-src="compositions/title-card.html"
data-start="4"
data-duration="4"
data-track-index="0"
></div>
<div
id="nested-host"
class="clip composition-host"
data-hf-id="nested-host"
data-composition-id="nested-shell-host"
data-composition-src="compositions/nested-shell.html"
data-start="2"
data-duration="6"
data-track-index="1"
></div>
<div
id="collision-a"
class="clip collision-shape"
data-hf-id="collision-a"
data-start="1"
data-duration="2"
data-track-index="2"
></div>
<div
id="collision-b"
class="clip collision-shape"
data-hf-id="collision-b"
data-start="3"
data-duration="4"
data-track-index="2"
></div>
<div
id="layer-overlap"
class="clip collision-shape"
data-hf-id="layer-overlap"
data-start="3"
data-duration="4"
data-track-index="3"
></div>
</main>
</body>
</html>
@@ -0,0 +1,4 @@
{
"name": "composition-reliability-fixture",
"private": true
}