mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
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:
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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}
|
||||
/>
|
||||
|
||||
+105
@@ -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 label…value 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}`);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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.
|
||||
+24
@@ -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>
|
||||
+41
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user