chore: import upstream snapshot with attribution
regression / regression-shards (style-16-prod style-9-prod style-17-prod iframe-render-compat variables-prod mp4-h265-sdr, shard-4) (push) Has been cancelled
regression / regression-shards (style-4-prod style-11-prod style-2-prod animejs-adapter typegpu-adapter parallel-capture-regression, shard-5) (push) Has been cancelled
regression / regression-shards (style-7-prod style-8-prod style-10-prod css-spinner-render-compat webm-transparency mp4-h264-sdr webm-vp9, shard-3) (push) Has been cancelled
regression / regression-shards (sub-composition-video style-18-prod raf-ball-render-compat font-variant-numeric sub-comp-t0 sub-comp-id-selector, shard-7) (push) Has been cancelled
Windows render verification / Detect changes (push) Has been cancelled
Windows render verification / Preflight (lint + format) (push) Has been cancelled
Windows render verification / Render on windows-latest (push) Has been cancelled
Windows render verification / Tests on windows-latest (push) Has been cancelled
CI / Detect changes (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Fallow audit (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Producer: integration tests (push) Has been cancelled
CI / Producer: unit tests (push) Has been cancelled
CI / File size check (push) Has been cancelled
CI / Test: skills (push) Has been cancelled
CI / Skills: manifest in sync (push) Has been cancelled
CI / CLI: npx shim (macos-latest) (push) Has been cancelled
CI / CLI: npx shim (ubuntu-latest) (push) Has been cancelled
CI / CLI: npx shim (windows-latest) (push) Has been cancelled
CI / SDK: unit + contract + smoke (push) Has been cancelled
CI / Test: runtime contract (push) Has been cancelled
CI / Studio: load smoke (push) Has been cancelled
CI / Smoke: global install (push) Has been cancelled
CI / CLI smoke (required) (push) Has been cancelled
CI / Semantic PR title (push) Has been cancelled
Player perf / Detect changes (push) Has been cancelled
Player perf / Preflight (lint + format) (push) Has been cancelled
Player perf / player-perf (push) Has been cancelled
Player perf / Perf: drift (push) Has been cancelled
Player perf / Perf: fps (push) Has been cancelled
Player perf / Perf: parity (push) Has been cancelled
Player perf / Perf: scrub (push) Has been cancelled
Player perf / Perf: load (push) Has been cancelled
preview-regression / Detect changes (push) Has been cancelled
preview-regression / Preflight (lint + format) (push) Has been cancelled
preview-regression / Preview parity (push) Has been cancelled
preview-regression / preview-regression (push) Has been cancelled
regression / regression (push) Has been cancelled
regression / Detect changes (push) Has been cancelled
regression / Preflight (lint + format) (push) Has been cancelled
regression / regression-shards (hdr-regression style-5-prod style-3-prod mov-prores, shard-1) (push) Has been cancelled
regression / regression-shards (overlay-montage-prod style-12-prod chat missing-host-comp-id png-sequence portrait-edge-bleed, shard-6) (push) Has been cancelled
regression / regression-shards (style-13-prod style-6-prod vignelli-stacking gsap-letters-render-compat audio-mux-parity, shard-8) (push) Has been cancelled
regression / regression-shards (style-15-prod hdr-hlg-regression style-1-prod many-cuts vfr-screen-recording render-symlinked-assets, shard-2) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Docs / Validate docs (push) Has been cancelled
Sync skills to ClawHub / Publish changed skills (push) Has been cancelled
regression / regression-shards (style-16-prod style-9-prod style-17-prod iframe-render-compat variables-prod mp4-h265-sdr, shard-4) (push) Has been cancelled
regression / regression-shards (style-4-prod style-11-prod style-2-prod animejs-adapter typegpu-adapter parallel-capture-regression, shard-5) (push) Has been cancelled
regression / regression-shards (style-7-prod style-8-prod style-10-prod css-spinner-render-compat webm-transparency mp4-h264-sdr webm-vp9, shard-3) (push) Has been cancelled
regression / regression-shards (sub-composition-video style-18-prod raf-ball-render-compat font-variant-numeric sub-comp-t0 sub-comp-id-selector, shard-7) (push) Has been cancelled
Windows render verification / Detect changes (push) Has been cancelled
Windows render verification / Preflight (lint + format) (push) Has been cancelled
Windows render verification / Render on windows-latest (push) Has been cancelled
Windows render verification / Tests on windows-latest (push) Has been cancelled
CI / Detect changes (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Fallow audit (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Producer: integration tests (push) Has been cancelled
CI / Producer: unit tests (push) Has been cancelled
CI / File size check (push) Has been cancelled
CI / Test: skills (push) Has been cancelled
CI / Skills: manifest in sync (push) Has been cancelled
CI / CLI: npx shim (macos-latest) (push) Has been cancelled
CI / CLI: npx shim (ubuntu-latest) (push) Has been cancelled
CI / CLI: npx shim (windows-latest) (push) Has been cancelled
CI / SDK: unit + contract + smoke (push) Has been cancelled
CI / Test: runtime contract (push) Has been cancelled
CI / Studio: load smoke (push) Has been cancelled
CI / Smoke: global install (push) Has been cancelled
CI / CLI smoke (required) (push) Has been cancelled
CI / Semantic PR title (push) Has been cancelled
Player perf / Detect changes (push) Has been cancelled
Player perf / Preflight (lint + format) (push) Has been cancelled
Player perf / player-perf (push) Has been cancelled
Player perf / Perf: drift (push) Has been cancelled
Player perf / Perf: fps (push) Has been cancelled
Player perf / Perf: parity (push) Has been cancelled
Player perf / Perf: scrub (push) Has been cancelled
Player perf / Perf: load (push) Has been cancelled
preview-regression / Detect changes (push) Has been cancelled
preview-regression / Preflight (lint + format) (push) Has been cancelled
preview-regression / Preview parity (push) Has been cancelled
preview-regression / preview-regression (push) Has been cancelled
regression / regression (push) Has been cancelled
regression / Detect changes (push) Has been cancelled
regression / Preflight (lint + format) (push) Has been cancelled
regression / regression-shards (hdr-regression style-5-prod style-3-prod mov-prores, shard-1) (push) Has been cancelled
regression / regression-shards (overlay-montage-prod style-12-prod chat missing-host-comp-id png-sequence portrait-edge-bleed, shard-6) (push) Has been cancelled
regression / regression-shards (style-13-prod style-6-prod vignelli-stacking gsap-letters-render-compat audio-mux-parity, shard-8) (push) Has been cancelled
regression / regression-shards (style-15-prod hdr-hlg-regression style-1-prod many-cuts vfr-screen-recording render-symlinked-assets, shard-2) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Docs / Validate docs (push) Has been cancelled
Sync skills to ClawHub / Publish changed skills (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { findClipForAsset, isPointerClick, DRAG_THRESHOLD_PX } from "./assetClickBehavior";
|
||||
import type { TimelineElement } from "../player/store/playerStore";
|
||||
|
||||
// Minimal TimelineElement factory — only the fields the function inspects.
|
||||
function makeEl(overrides: Partial<TimelineElement> & { id: string }): TimelineElement {
|
||||
return {
|
||||
tag: "div",
|
||||
start: 0,
|
||||
duration: 5,
|
||||
track: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("findClipForAsset", () => {
|
||||
it("returns null when elements array is empty", () => {
|
||||
expect(findClipForAsset([], "assets/foo.mp4")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when no element matches", () => {
|
||||
const el = makeEl({ id: "el1", src: "assets/other.mp4" });
|
||||
expect(findClipForAsset([el], "assets/foo.mp4")).toBeNull();
|
||||
});
|
||||
|
||||
it("matches a bare relative src against the project-relative asset path", () => {
|
||||
const el = makeEl({ id: "el1", src: "assets/clip.mp4", start: 2 });
|
||||
expect(findClipForAsset([el], "assets/clip.mp4")).toBe(el);
|
||||
});
|
||||
|
||||
it("matches a src with a ./ prefix", () => {
|
||||
const el = makeEl({ id: "el1", src: "./assets/logo.png" });
|
||||
expect(findClipForAsset([el], "assets/logo.png")).toBe(el);
|
||||
});
|
||||
|
||||
it("matches a server-relative /api/projects/…/preview/ src", () => {
|
||||
const el = makeEl({ id: "el1", src: "/api/projects/demo/preview/assets/bgm.mp3" });
|
||||
expect(findClipForAsset([el], "assets/bgm.mp3")).toBe(el);
|
||||
});
|
||||
|
||||
it("matches a fully-absolute URL (as produced by the core runtime)", () => {
|
||||
const el = makeEl({
|
||||
id: "el1",
|
||||
src: "http://localhost:3012/api/projects/demo/preview/assets/clip.mp4",
|
||||
});
|
||||
expect(findClipForAsset([el], "assets/clip.mp4")).toBe(el);
|
||||
});
|
||||
|
||||
it("decodes percent-encoded filenames when matching", () => {
|
||||
const el = makeEl({
|
||||
id: "el1",
|
||||
src: "http://localhost:3012/api/projects/p/preview/assets/my%20file%20(1).mp4",
|
||||
});
|
||||
expect(findClipForAsset([el], "assets/my file (1).mp4")).toBe(el);
|
||||
});
|
||||
|
||||
it("strips query strings from src before matching", () => {
|
||||
const el = makeEl({ id: "el1", src: "assets/clip.mp4?v=2" });
|
||||
expect(findClipForAsset([el], "assets/clip.mp4")).toBe(el);
|
||||
});
|
||||
|
||||
it("returns the element with the earliest start when multiple clips match", () => {
|
||||
const later = makeEl({ id: "late", src: "assets/clip.mp4", start: 10 });
|
||||
const earlier = makeEl({ id: "early", src: "assets/clip.mp4", start: 2 });
|
||||
const first = makeEl({ id: "first", src: "assets/clip.mp4", start: 0 });
|
||||
expect(findClipForAsset([later, earlier, first], "assets/clip.mp4")).toBe(first);
|
||||
});
|
||||
|
||||
it("prefers the key over id when the element has both", () => {
|
||||
// findClipForAsset returns the element object itself; callers do `clip.key ?? clip.id`
|
||||
const el = makeEl({ id: "el1", key: "clip-key", src: "assets/img.png" });
|
||||
const found = findClipForAsset([el], "assets/img.png");
|
||||
expect(found?.key).toBe("clip-key");
|
||||
});
|
||||
|
||||
it("skips elements with no src", () => {
|
||||
const noSrc = makeEl({ id: "nosrc" });
|
||||
const withSrc = makeEl({ id: "withsrc", src: "assets/img.png" });
|
||||
expect(findClipForAsset([noSrc, withSrc], "assets/img.png")).toBe(withSrc);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isPointerClick", () => {
|
||||
it("returns true for zero movement", () => {
|
||||
expect(isPointerClick(0, 0)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for movement within the threshold", () => {
|
||||
expect(isPointerClick(DRAG_THRESHOLD_PX - 1, 0)).toBe(true);
|
||||
expect(isPointerClick(0, DRAG_THRESHOLD_PX - 1)).toBe(true);
|
||||
expect(isPointerClick(DRAG_THRESHOLD_PX - 1, DRAG_THRESHOLD_PX - 1)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false at or beyond the threshold", () => {
|
||||
expect(isPointerClick(DRAG_THRESHOLD_PX, 0)).toBe(false);
|
||||
expect(isPointerClick(0, DRAG_THRESHOLD_PX)).toBe(false);
|
||||
expect(isPointerClick(DRAG_THRESHOLD_PX + 10, 0)).toBe(false);
|
||||
});
|
||||
|
||||
it("handles negative movement (pointer moved left/up)", () => {
|
||||
expect(isPointerClick(-(DRAG_THRESHOLD_PX - 1), 0)).toBe(true);
|
||||
expect(isPointerClick(-DRAG_THRESHOLD_PX, 0)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Pure helpers for CapCut-style asset card click behavior.
|
||||
*
|
||||
* Clicking an asset card that is ALREADY ADDED to the timeline selects the
|
||||
* corresponding clip. Clicking one NOT yet in the timeline opens a lightweight
|
||||
* preview overlay. Both behaviors are gated on "this was a click, not a drag".
|
||||
*
|
||||
* Pure — unit-tested.
|
||||
*/
|
||||
import type { TimelineElement } from "../player/store/playerStore";
|
||||
|
||||
/**
|
||||
* Find the TimelineElement that references `assetPath`, returning the one with
|
||||
* the earliest start time when multiple clips share the same source.
|
||||
*
|
||||
* Matching mirrors `deriveUsedPaths` in AssetsTab: an element's `src` may be a
|
||||
* fully-absolute URL, a server-relative `/api/projects/…/preview/…` path, a
|
||||
* `./`-prefixed relative path, or a bare relative path — all normalised to the
|
||||
* project-relative form that `assetPath` carries.
|
||||
*
|
||||
* Returns `null` when no element matches.
|
||||
*/
|
||||
export function findClipForAsset(
|
||||
elements: TimelineElement[],
|
||||
assetPath: string,
|
||||
): TimelineElement | null {
|
||||
let best: TimelineElement | null = null;
|
||||
for (const el of elements) {
|
||||
if (!el.src) continue;
|
||||
if (normalizeSrc(el.src) !== assetPath) continue;
|
||||
if (best === null || el.start < best.start) best = el;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise a raw element `src` to the bare project-relative path so it can be
|
||||
* compared against the asset-list strings (which have no leading slash, no
|
||||
* origin, no query string).
|
||||
*
|
||||
* Mirrors the logic in `deriveUsedPaths` (AssetsTab.tsx) — keep in sync.
|
||||
*/
|
||||
function normalizeSrc(src: string): string {
|
||||
let s = src;
|
||||
try {
|
||||
const u = new URL(s);
|
||||
s = u.pathname;
|
||||
} catch {
|
||||
// Not an absolute URL — leave as-is
|
||||
}
|
||||
s = s
|
||||
.replace(/^\/api\/projects\/[^/]+\/preview\//, "")
|
||||
.replace(/^\.?\//, "")
|
||||
.split(/[?#]/)[0];
|
||||
try {
|
||||
s = decodeURIComponent(s);
|
||||
} catch {
|
||||
// Malformed encoding — use as-is
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
/** Drag-detection threshold in pixels — movements within this are treated as clicks. */
|
||||
export const DRAG_THRESHOLD_PX = 4;
|
||||
|
||||
/**
|
||||
* Determine whether a pointer-up event should be treated as a click given the
|
||||
* total pointer displacement since pointer-down.
|
||||
*
|
||||
* @param dx Horizontal distance moved in pixels.
|
||||
* @param dy Vertical distance moved in pixels.
|
||||
*/
|
||||
export function isPointerClick(dx: number, dy: number): boolean {
|
||||
return Math.abs(dx) < DRAG_THRESHOLD_PX && Math.abs(dy) < DRAG_THRESHOLD_PX;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Authored-opacity contract, studio side. The runtime stamps every graded
|
||||
* element's authored inline opacity at document parse time (see
|
||||
* installAuthoredOpacityCapture in @hyperframes/core); studio code that makes
|
||||
* GSAP re-initialize tweens (soft reload, in-place patches) restores it so
|
||||
* re-captures never bake a runtime transient in as a tween bound.
|
||||
*/
|
||||
import { COLOR_GRADING_AUTHORED_OPACITY_ATTR } from "@hyperframes/core/color-grading";
|
||||
|
||||
interface AttributeReader {
|
||||
getAttribute(name: string): string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The stamped authored inline opacity. Three-state:
|
||||
* "0.98" — the authored value; "" — captured, authored none;
|
||||
* null — never captured (unknown).
|
||||
* Duck-typed so iframe-realm elements (no shared HTMLElement) work.
|
||||
*/
|
||||
export function readStampedAuthoredOpacity(element: AttributeReader): string | null {
|
||||
return element.getAttribute(COLOR_GRADING_AUTHORED_OPACITY_ATTR);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write an authored inline opacity back: "" removes the property, a value sets
|
||||
* it. Priority-lossy by design: the capture reads `style.opacity` (value only)
|
||||
* and the write sets no priority, so an authored `opacity: X !important`
|
||||
* round-trips as `opacity: X`. The only `!important` opacity in the pipeline
|
||||
* is the color-grading runtime hide — a transient this contract exists to
|
||||
* discard — and authored compositions don't `!important` their opacity.
|
||||
*/
|
||||
export function applyAuthoredInlineOpacity(style: CSSStyleDeclaration, authored: string): void {
|
||||
if (authored === "") style.removeProperty("opacity");
|
||||
else style.setProperty("opacity", authored);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
// Imperative beat-edit operations driven by the player store. Times passed in
|
||||
// are COMPOSITION coordinates (timeline seconds); they're converted to audio-file
|
||||
// coordinates internally and strength is measured from the decoded audio.
|
||||
|
||||
import { usePlayerStore, type TimelineElement } from "../player/store/playerStore";
|
||||
import { isMusicTrack } from "./timelineInspector";
|
||||
import { strengthAtTime, type MusicBeatAnalysis } from "@hyperframes/core/beats";
|
||||
import {
|
||||
addUserBeat,
|
||||
removeUserBeat,
|
||||
moveUserBeat,
|
||||
mergeUserBeats,
|
||||
type BeatEditState,
|
||||
} from "./beatEditing";
|
||||
|
||||
/**
|
||||
* Merge user beat edits into the detected analysis and remap from audio-file to
|
||||
* composition coordinates (filtered to the music clip's visible range). Returns
|
||||
* null when there's no music element, so beats never paint at wrong positions.
|
||||
*/
|
||||
export function remapBeatAnalysisToComposition(
|
||||
beatAnalysis: MusicBeatAnalysis | null,
|
||||
musicElement: Pick<TimelineElement, "src" | "start" | "playbackStart" | "duration"> | null,
|
||||
beatEdits: BeatEditState | null,
|
||||
): MusicBeatAnalysis | null {
|
||||
if (!beatAnalysis || !musicElement) return null;
|
||||
const merged = mergeUserBeats(
|
||||
beatAnalysis.beatTimes,
|
||||
beatAnalysis.beatStrengths,
|
||||
beatEdits,
|
||||
musicElement.src ?? null,
|
||||
);
|
||||
const playbackStart = musicElement.playbackStart ?? 0;
|
||||
const clipEnd = playbackStart + musicElement.duration;
|
||||
const offset = musicElement.start - playbackStart;
|
||||
const times: number[] = [];
|
||||
const strengths: number[] = [];
|
||||
merged.times.forEach((t, i) => {
|
||||
if (t >= playbackStart && t <= clipEnd) {
|
||||
times.push(Math.round((t + offset) * 1000) / 1000);
|
||||
strengths.push(merged.strengths[i] ?? 1);
|
||||
}
|
||||
});
|
||||
return { ...beatAnalysis, beatTimes: times, beatStrengths: strengths };
|
||||
}
|
||||
|
||||
function ctx() {
|
||||
const s = usePlayerStore.getState();
|
||||
const music = s.elements.find(isMusicTrack);
|
||||
const analysis = s.beatAnalysis;
|
||||
if (!music || !analysis || !music.src) return null;
|
||||
return { s, music, analysis, src: music.src };
|
||||
}
|
||||
|
||||
function compToAudio(start: number, playbackStart: number, compT: number): number {
|
||||
return playbackStart + (compT - start);
|
||||
}
|
||||
|
||||
// Clip length on the timeline. Falls back to source/analysis length when the
|
||||
// media duration hasn't been probed yet (0), so the add window isn't degenerate.
|
||||
function clipDuration(music: { duration: number; sourceDuration?: number }): number {
|
||||
if (music.duration > 0) return music.duration;
|
||||
if (music.sourceDuration && music.sourceDuration > 0) return music.sourceDuration;
|
||||
return Number.POSITIVE_INFINITY;
|
||||
}
|
||||
|
||||
/** True when a music track with analysis exists and the time is inside the clip. */
|
||||
export function canAddBeatAt(compT: number): boolean {
|
||||
const c = ctx();
|
||||
if (!c) return false;
|
||||
return compT >= c.music.start && compT <= c.music.start + clipDuration(c.music);
|
||||
}
|
||||
|
||||
export function addBeatAtCompositionTime(compT: number): void {
|
||||
const c = ctx();
|
||||
if (!c) return;
|
||||
const playbackStart = c.music.playbackStart ?? 0;
|
||||
const audioT = compToAudio(c.music.start, playbackStart, compT);
|
||||
if (audioT < playbackStart || audioT > playbackStart + clipDuration(c.music)) return;
|
||||
const strength = strengthAtTime(c.analysis, audioT);
|
||||
const next = addUserBeat(c.s.beatEdits, c.src, { time: audioT, strength }, c.analysis.beatTimes);
|
||||
// No-op when the beat lands on an existing one — skip the undo entry + write.
|
||||
if (next !== c.s.beatEdits) c.s.commitBeatEdits(next, "add beat");
|
||||
}
|
||||
|
||||
export function deleteBeatAtCompositionTime(compT: number): void {
|
||||
const c = ctx();
|
||||
if (!c) return;
|
||||
const audioT = compToAudio(c.music.start, c.music.playbackStart ?? 0, compT);
|
||||
const next = removeUserBeat(c.s.beatEdits, c.src, c.analysis.beatTimes, audioT);
|
||||
// No-op when there was no beat to remove — skip the undo entry + write.
|
||||
if (next !== c.s.beatEdits) c.s.commitBeatEdits(next, "delete beat");
|
||||
}
|
||||
|
||||
export function moveBeatCompositionTime(fromCompT: number, toCompT: number): void {
|
||||
const c = ctx();
|
||||
if (!c) return;
|
||||
const playbackStart = c.music.playbackStart ?? 0;
|
||||
const fromAudio = compToAudio(c.music.start, playbackStart, fromCompT);
|
||||
const toAudio = compToAudio(c.music.start, playbackStart, toCompT);
|
||||
const clamped = Math.max(playbackStart, Math.min(playbackStart + clipDuration(c.music), toAudio));
|
||||
const strength = strengthAtTime(c.analysis, clamped);
|
||||
const next = moveUserBeat(c.s.beatEdits, c.src, c.analysis.beatTimes, fromAudio, {
|
||||
time: clamped,
|
||||
strength,
|
||||
});
|
||||
// No-op when the move resolves to no change — skip the undo entry + write.
|
||||
if (next !== c.s.beatEdits) c.s.commitBeatEdits(next, "move beat");
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
// User edits to the detected beat grid. All times are in AUDIO-FILE coordinates
|
||||
// (offsets into the music source), matching MusicBeatAnalysis.beatTimes, so edits
|
||||
// survive moving/trimming the music clip on the timeline.
|
||||
|
||||
export interface UserBeat {
|
||||
time: number; // audio-file seconds
|
||||
strength: number; // 0–1, measured from audio
|
||||
}
|
||||
|
||||
export interface BeatEditState {
|
||||
/** Music src these edits apply to; edits reset when the src changes. */
|
||||
src: string;
|
||||
/** Beats the user added (audio-file coords). */
|
||||
added: UserBeat[];
|
||||
/** Audio-file times of detected beats the user removed. */
|
||||
removed: number[];
|
||||
}
|
||||
|
||||
// Two beat times within this many seconds are treated as the same beat.
|
||||
const MATCH_EPS = 0.015;
|
||||
|
||||
function near(a: number, b: number): boolean {
|
||||
return Math.abs(a - b) < MATCH_EPS;
|
||||
}
|
||||
|
||||
function activeEdits(edits: BeatEditState | null, src: string | null): BeatEditState | null {
|
||||
return edits && src && edits.src === src ? edits : null;
|
||||
}
|
||||
|
||||
/** Merge detected beats with user edits → effective beats (audio-file coords). */
|
||||
export function mergeUserBeats(
|
||||
detectedTimes: number[],
|
||||
detectedStrengths: number[],
|
||||
edits: BeatEditState | null,
|
||||
src: string | null,
|
||||
): { times: number[]; strengths: number[] } {
|
||||
const e = activeEdits(edits, src);
|
||||
const removed = e?.removed ?? [];
|
||||
const merged: UserBeat[] = [];
|
||||
for (let i = 0; i < detectedTimes.length; i++) {
|
||||
const t = detectedTimes[i]!;
|
||||
if (removed.some((r) => near(r, t))) continue;
|
||||
merged.push({ time: t, strength: detectedStrengths[i] ?? 0.5 });
|
||||
}
|
||||
if (e) {
|
||||
// Skip added beats that land on an already-present (detected) beat so an
|
||||
// "add" near an existing beat doesn't create a near-duplicate.
|
||||
for (const b of e.added) {
|
||||
if (!merged.some((m) => near(m.time, b.time))) merged.push(b);
|
||||
}
|
||||
}
|
||||
merged.sort((a, b) => a.time - b.time);
|
||||
return { times: merged.map((b) => b.time), strengths: merged.map((b) => b.strength) };
|
||||
}
|
||||
|
||||
function base(edits: BeatEditState | null, src: string): BeatEditState {
|
||||
const e = activeEdits(edits, src);
|
||||
return e
|
||||
? { ...e, added: [...e.added], removed: [...e.removed] }
|
||||
: { src, added: [], removed: [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a beat at an audio-file time. `detectedTimes` lets us no-op when the beat
|
||||
* lands on an existing (non-removed) detected beat — otherwise the merge would
|
||||
* drop it anyway and we'd record a phantom edit/undo/write. Returns the SAME
|
||||
* reference when nothing changed so callers can skip persisting.
|
||||
*/
|
||||
export function addUserBeat(
|
||||
edits: BeatEditState | null,
|
||||
src: string,
|
||||
beat: UserBeat,
|
||||
detectedTimes: number[] = [],
|
||||
): BeatEditState | null {
|
||||
const active = activeEdits(edits, src);
|
||||
// Already covered by a surviving detected beat → nothing to do.
|
||||
const onLiveDetected =
|
||||
detectedTimes.some((t) => near(t, beat.time)) &&
|
||||
!(active?.removed ?? []).some((r) => near(r, beat.time));
|
||||
if (onLiveDetected) return edits;
|
||||
// Already an added beat here → nothing to do.
|
||||
if ((active?.added ?? []).some((b) => near(b.time, beat.time))) return edits;
|
||||
|
||||
const next = base(edits, src);
|
||||
// If a detected beat here was previously removed, drop the removal instead of stacking.
|
||||
const ri = next.removed.findIndex((r) => near(r, beat.time));
|
||||
if (ri >= 0) {
|
||||
next.removed.splice(ri, 1);
|
||||
return next;
|
||||
}
|
||||
next.added.push(beat);
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the beat nearest `time` — drops a user-added beat or hides a detected
|
||||
* one. Returns the SAME reference when nothing changed (no added beat near
|
||||
* `time`, and no live detected beat to hide) so callers can skip persisting a
|
||||
* phantom edit/undo/write.
|
||||
*/
|
||||
export function removeUserBeat(
|
||||
edits: BeatEditState | null,
|
||||
src: string,
|
||||
detectedTimes: number[],
|
||||
time: number,
|
||||
): BeatEditState | null {
|
||||
const active = activeEdits(edits, src);
|
||||
const hasAdded = (active?.added ?? []).some((b) => near(b.time, time));
|
||||
const detected = detectedTimes.find((t) => near(t, time));
|
||||
const alreadyHidden =
|
||||
detected !== undefined && (active?.removed ?? []).some((r) => near(r, detected));
|
||||
if (!hasAdded && (detected === undefined || alreadyHidden)) return edits;
|
||||
|
||||
const next = base(edits, src);
|
||||
const ai = next.added.findIndex((b) => near(b.time, time));
|
||||
if (ai >= 0) {
|
||||
next.added.splice(ai, 1);
|
||||
return next;
|
||||
}
|
||||
if (detected !== undefined && !next.removed.some((r) => near(r, detected))) {
|
||||
next.removed.push(detected);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Move the beat at `fromTime` to `toBeat` (delete original, add new). */
|
||||
export function moveUserBeat(
|
||||
edits: BeatEditState | null,
|
||||
src: string,
|
||||
detectedTimes: number[],
|
||||
fromTime: number,
|
||||
toBeat: UserBeat,
|
||||
): BeatEditState | null {
|
||||
const removed = removeUserBeat(edits, src, detectedTimes, fromTime);
|
||||
return addUserBeat(removed, src, toBeat, detectedTimes) ?? removed;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import {
|
||||
type BlockCategory,
|
||||
BLOCK_CATEGORIES,
|
||||
resolveBlockCategory,
|
||||
} from "@hyperframes/core/registry";
|
||||
|
||||
export type { BlockCategory };
|
||||
export { BLOCK_CATEGORIES, resolveBlockCategory };
|
||||
|
||||
const COLOR_MAP: Record<BlockCategory, { bg: string; text: string; dot: string }> = {
|
||||
transitions: { bg: "bg-blue-500/15", text: "text-blue-400", dot: "bg-blue-400" },
|
||||
vfx: { bg: "bg-purple-500/15", text: "text-purple-400", dot: "bg-purple-400" },
|
||||
social: { bg: "bg-pink-500/15", text: "text-pink-400", dot: "bg-pink-400" },
|
||||
data: { bg: "bg-green-500/15", text: "text-green-400", dot: "bg-green-400" },
|
||||
scenes: { bg: "bg-amber-500/15", text: "text-amber-400", dot: "bg-amber-400" },
|
||||
captions: { bg: "bg-cyan-500/15", text: "text-cyan-400", dot: "bg-cyan-400" },
|
||||
effects: { bg: "bg-rose-500/15", text: "text-rose-400", dot: "bg-rose-400" },
|
||||
"text-effects": { bg: "bg-violet-500/15", text: "text-violet-400", dot: "bg-violet-400" },
|
||||
"code-animation": { bg: "bg-emerald-500/15", text: "text-emerald-400", dot: "bg-emerald-400" },
|
||||
};
|
||||
|
||||
export function getCategoryColors(category: BlockCategory) {
|
||||
return COLOR_MAP[category];
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import type { RegistryItem } from "@hyperframes/core/registry";
|
||||
import type { TimelineElement } from "../player";
|
||||
import {
|
||||
insertTimelineAssetIntoSource,
|
||||
resolveTimelineAssetInitialGeometry,
|
||||
} from "./timelineAssetDrop";
|
||||
import { collectHtmlIds } from "./studioHelpers";
|
||||
import { formatTimelineAttributeNumber } from "../player/components/timelineEditing";
|
||||
import { saveProjectFilesWithHistory } from "./studioFileHistory";
|
||||
import type { EditHistoryKind } from "./editHistory";
|
||||
import { extendRootDurationInSource } from "./rootDuration";
|
||||
|
||||
function getMaxZIndexFromIframe(iframe: HTMLIFrameElement | null): number {
|
||||
try {
|
||||
const doc = iframe?.contentDocument;
|
||||
if (!doc) return 0;
|
||||
let max = 0;
|
||||
for (const el of doc.body.querySelectorAll("*")) {
|
||||
const z = parseInt(getComputedStyle(el).zIndex, 10);
|
||||
if (Number.isFinite(z) && z > max) max = z;
|
||||
}
|
||||
return max;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
interface AddBlockOptions {
|
||||
projectId: string;
|
||||
blockName: string;
|
||||
activeCompPath: string | null;
|
||||
placement?: { start: number; track: number };
|
||||
visualPosition?: { left: number; top: number };
|
||||
previewIframe?: HTMLIFrameElement | null;
|
||||
currentTime?: number;
|
||||
timelineElements: TimelineElement[];
|
||||
readProjectFile: (path: string) => Promise<string>;
|
||||
writeProjectFile: (path: string, content: string) => Promise<void>;
|
||||
recordEdit: (entry: {
|
||||
label: string;
|
||||
kind: EditHistoryKind;
|
||||
coalesceKey?: string;
|
||||
files: Record<string, { before: string; after: string }>;
|
||||
}) => Promise<void>;
|
||||
refreshFileTree: () => Promise<void>;
|
||||
reloadPreview: () => void;
|
||||
showToast: (msg: string) => void;
|
||||
}
|
||||
|
||||
function buildUniqueCompositionId(baseName: string, existingIds: Iterable<string>): string {
|
||||
const idSet = new Set(existingIds);
|
||||
if (!idSet.has(baseName)) return baseName;
|
||||
let i = 2;
|
||||
while (idSet.has(`${baseName}_${i}`)) i++;
|
||||
return `${baseName}_${i}`;
|
||||
}
|
||||
|
||||
export async function addBlockToProject(
|
||||
opts: AddBlockOptions,
|
||||
): Promise<{ block: RegistryItem; compositionPath: string } | null> {
|
||||
const {
|
||||
projectId,
|
||||
blockName,
|
||||
activeCompPath,
|
||||
placement,
|
||||
visualPosition,
|
||||
timelineElements,
|
||||
readProjectFile,
|
||||
writeProjectFile,
|
||||
recordEdit,
|
||||
refreshFileTree,
|
||||
reloadPreview,
|
||||
showToast,
|
||||
} = opts;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/projects/${projectId}/registry/install`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ blockName }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: "Install failed" }));
|
||||
showToast((err as { error?: string }).error || "Failed to install block");
|
||||
return null;
|
||||
}
|
||||
|
||||
const { written, block } = (await res.json()) as {
|
||||
written: string[];
|
||||
block: RegistryItem;
|
||||
};
|
||||
|
||||
const compositionFile = written.find((f) => f.endsWith(".html")) ?? written[0];
|
||||
if (!compositionFile) {
|
||||
showToast("Installed but no composition file was written");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (block.type === "hyperframes:component") {
|
||||
const compContent = await readProjectFile(compositionFile);
|
||||
const transparentContent = compContent.replace(
|
||||
/background:\s*(?:#(?:0a0a0a|000000|000|0a0805)|rgba?\([^)]*\))\s*;/g,
|
||||
"background: transparent;",
|
||||
);
|
||||
if (transparentContent !== compContent) {
|
||||
await writeProjectFile(compositionFile, transparentContent);
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
const targetPath = activeCompPath || "index.html";
|
||||
const originalContent = await readProjectFile(targetPath);
|
||||
const existingIds = collectHtmlIds(originalContent);
|
||||
const compId = buildUniqueCompositionId(block.name, existingIds);
|
||||
|
||||
const resolvedTargetPath = targetPath || "index.html";
|
||||
const relevantElements = timelineElements.filter(
|
||||
(te) => (te.sourceFile || activeCompPath || "index.html") === resolvedTargetPath,
|
||||
);
|
||||
|
||||
const isBlock = block.type === "hyperframes:block";
|
||||
const hostDims = resolveTimelineAssetInitialGeometry(originalContent);
|
||||
|
||||
const currentTime = opts.currentTime ?? 0;
|
||||
const start = placement
|
||||
? Number(formatTimelineAttributeNumber(placement.start))
|
||||
: Number(formatTimelineAttributeNumber(currentTime));
|
||||
const blockDuration =
|
||||
"duration" in block ? (block as { duration: number }).duration : undefined;
|
||||
const duration =
|
||||
blockDuration ??
|
||||
relevantElements.reduce(
|
||||
(max, te) => Math.max(max, (te.start ?? 0) + (te.duration ?? 0)),
|
||||
10,
|
||||
);
|
||||
const track =
|
||||
placement?.track ??
|
||||
(isBlock
|
||||
? 0
|
||||
: relevantElements.length > 0
|
||||
? Math.max(...relevantElements.map((te) => te.track)) + 1
|
||||
: 1);
|
||||
|
||||
const zIndex = getMaxZIndexFromIframe(opts.previewIframe ?? null) + 1;
|
||||
|
||||
const width = hostDims.width;
|
||||
const height = hostDims.height;
|
||||
|
||||
const left = visualPosition ? Math.round(visualPosition.left) : 0;
|
||||
const top = visualPosition ? Math.round(visualPosition.top) : 0;
|
||||
|
||||
const subCompHtml = [
|
||||
`<div`,
|
||||
` data-composition-id="${compId}"`,
|
||||
` data-composition-src="${compositionFile}"`,
|
||||
` data-start="${formatTimelineAttributeNumber(start)}"`,
|
||||
` data-duration="${formatTimelineAttributeNumber(duration)}"`,
|
||||
` data-track-index="${track}"`,
|
||||
` data-width="${width}"`,
|
||||
` data-height="${height}"`,
|
||||
` style="position: absolute; left: ${left}px; top: ${top}px; width: ${width}px; height: ${height}px; z-index: ${zIndex}"`,
|
||||
`></div>`,
|
||||
].join("\n");
|
||||
|
||||
let patchedContent = insertTimelineAssetIntoSource(originalContent, subCompHtml);
|
||||
patchedContent = extendRootDurationInSource(patchedContent, start + duration);
|
||||
|
||||
await saveProjectFilesWithHistory({
|
||||
projectId,
|
||||
label: `Add ${isBlock ? "block" : "component"}: ${block.title}`,
|
||||
kind: "timeline",
|
||||
files: { [targetPath]: patchedContent },
|
||||
readFile: async () => originalContent,
|
||||
writeFile: writeProjectFile,
|
||||
recordEdit,
|
||||
});
|
||||
}
|
||||
|
||||
await refreshFileTree();
|
||||
reloadPreview();
|
||||
|
||||
return { block, compositionPath: compositionFile };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to add block";
|
||||
showToast(message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { __resetForTests, acquireCanvasNudgeKeys, canvasNudgeKeysClaimed } from "./canvasNudgeGate";
|
||||
|
||||
describe("canvasNudgeGate", () => {
|
||||
// The claim counter is module-level state; reset it so an unbalanced claim in one
|
||||
// test can't leak into the next.
|
||||
beforeEach(() => {
|
||||
__resetForTests();
|
||||
});
|
||||
|
||||
it("reports claimed while at least one claim is held", () => {
|
||||
expect(canvasNudgeKeysClaimed()).toBe(false);
|
||||
const releaseA = acquireCanvasNudgeKeys();
|
||||
const releaseB = acquireCanvasNudgeKeys();
|
||||
expect(canvasNudgeKeysClaimed()).toBe(true);
|
||||
releaseA();
|
||||
expect(canvasNudgeKeysClaimed()).toBe(true);
|
||||
releaseB();
|
||||
expect(canvasNudgeKeysClaimed()).toBe(false);
|
||||
});
|
||||
|
||||
it("makes release idempotent so an effect re-run cannot underflow", () => {
|
||||
const release = acquireCanvasNudgeKeys();
|
||||
release();
|
||||
release();
|
||||
expect(canvasNudgeKeysClaimed()).toBe(false);
|
||||
const releaseNext = acquireCanvasNudgeKeys();
|
||||
expect(canvasNudgeKeysClaimed()).toBe(true);
|
||||
releaseNext();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Arrow-key ownership gate between the canvas nudge (DomEditOverlay) and the
|
||||
* playback frame-step shortcuts (usePlaybackKeyboard). Both listen for arrow
|
||||
* keys with window capture listeners, and their relative order depends on
|
||||
* mount order (DomEditOverlay remounts when caption edit mode toggles), so
|
||||
* `event.defaultPrevented` alone can't arbitrate. While a nudgeable canvas
|
||||
* selection holds a claim, the playback handler skips ArrowLeft/ArrowRight.
|
||||
*/
|
||||
|
||||
let claims = 0;
|
||||
|
||||
/** Claim the arrow keys for canvas nudging. Returns an idempotent release. */
|
||||
export function acquireCanvasNudgeKeys(): () => void {
|
||||
claims += 1;
|
||||
let released = false;
|
||||
return () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
claims -= 1;
|
||||
};
|
||||
}
|
||||
|
||||
/** True while a nudgeable canvas selection owns the arrow keys. */
|
||||
export function canvasNudgeKeysClaimed(): boolean {
|
||||
return claims > 0;
|
||||
}
|
||||
|
||||
/** Test-only: reset the module-level claim counter so leaked claims from one test
|
||||
* can't bleed into the next (call in a `beforeEach`). */
|
||||
export function __resetForTests(): void {
|
||||
claims = 0;
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { Window } from "happy-dom";
|
||||
import { copyTextToClipboard } from "./clipboard";
|
||||
|
||||
function installDocument(execCommand: (command: string) => boolean): void {
|
||||
const window = new Window();
|
||||
Object.assign(window, { SyntaxError });
|
||||
Object.defineProperty(window.document, "execCommand", {
|
||||
configurable: true,
|
||||
value: execCommand,
|
||||
});
|
||||
vi.stubGlobal("document", window.document);
|
||||
}
|
||||
|
||||
function installNavigator(
|
||||
writeText: (text: string) => Promise<void>,
|
||||
userAgent = "Mozilla/5.0 Chrome/120.0.0.0 Safari/537.36",
|
||||
): void {
|
||||
vi.stubGlobal("navigator", {
|
||||
clipboard: { writeText },
|
||||
userAgent,
|
||||
});
|
||||
}
|
||||
|
||||
describe("copyTextToClipboard", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("uses the synchronous selection copy path first in Safari", async () => {
|
||||
const execCommand = vi.fn((command: string) => command === "copy");
|
||||
const writeText = vi.fn((_text: string) => Promise.resolve());
|
||||
|
||||
installDocument(execCommand);
|
||||
installNavigator(
|
||||
writeText,
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Safari/605.1.15",
|
||||
);
|
||||
|
||||
await expect(copyTextToClipboard("copy me")).resolves.toBe(true);
|
||||
|
||||
expect(execCommand).toHaveBeenCalledWith("copy");
|
||||
expect(writeText).not.toHaveBeenCalled();
|
||||
expect(document.querySelector("textarea")).toBeNull();
|
||||
});
|
||||
|
||||
it("uses navigator.clipboard first outside Safari", async () => {
|
||||
const execCommand = vi.fn((command: string) => command === "copy");
|
||||
const writeText = vi.fn((_text: string) => Promise.resolve());
|
||||
|
||||
installDocument(execCommand);
|
||||
installNavigator(writeText);
|
||||
|
||||
await expect(copyTextToClipboard("copy me")).resolves.toBe(true);
|
||||
|
||||
expect(writeText).toHaveBeenCalledWith("copy me");
|
||||
expect(execCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to selection copy outside Safari when navigator.clipboard fails", async () => {
|
||||
const execCommand = vi.fn((command: string) => command === "copy");
|
||||
const writeText = vi.fn((_text: string) => Promise.reject(new Error("blocked")));
|
||||
|
||||
installDocument(execCommand);
|
||||
installNavigator(writeText);
|
||||
|
||||
await expect(copyTextToClipboard("copy me")).resolves.toBe(true);
|
||||
|
||||
expect(writeText).toHaveBeenCalledWith("copy me");
|
||||
expect(execCommand).toHaveBeenCalledWith("copy");
|
||||
});
|
||||
|
||||
it("reports failure when both copy paths fail", async () => {
|
||||
const execCommand = vi.fn(() => false);
|
||||
const writeText = vi.fn((_text: string) => Promise.reject(new Error("blocked")));
|
||||
|
||||
installDocument(execCommand);
|
||||
installNavigator(
|
||||
writeText,
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Safari/605.1.15",
|
||||
);
|
||||
|
||||
await expect(copyTextToClipboard("copy me")).resolves.toBe(false);
|
||||
|
||||
expect(execCommand).toHaveBeenCalledWith("copy");
|
||||
expect(writeText).toHaveBeenCalledWith("copy me");
|
||||
expect(document.querySelector("textarea")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
function copyWithSelection(text: string): boolean {
|
||||
if (typeof document === "undefined" || !document.body || !document.execCommand) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = text;
|
||||
textarea.setAttribute("readonly", "true");
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.top = "0";
|
||||
textarea.style.left = "0";
|
||||
textarea.style.width = "1px";
|
||||
textarea.style.height = "1px";
|
||||
textarea.style.padding = "0";
|
||||
textarea.style.border = "0";
|
||||
textarea.style.opacity = "0";
|
||||
textarea.style.pointerEvents = "none";
|
||||
|
||||
document.body.appendChild(textarea);
|
||||
textarea.focus({ preventScroll: true });
|
||||
textarea.select();
|
||||
textarea.setSelectionRange(0, text.length);
|
||||
|
||||
try {
|
||||
return document.execCommand("copy");
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
}
|
||||
|
||||
function shouldCopyWithSelectionFirst(): boolean {
|
||||
if (typeof navigator === "undefined") return false;
|
||||
|
||||
const userAgent = navigator.userAgent;
|
||||
return /Safari/i.test(userAgent) && !/Chrome|Chromium|CriOS|FxiOS|Edg|OPR/i.test(userAgent);
|
||||
}
|
||||
|
||||
export async function copyTextToClipboard(text: string): Promise<boolean> {
|
||||
const useSelectionFirst = shouldCopyWithSelectionFirst();
|
||||
if (useSelectionFirst && copyWithSelection(text)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const clipboard = typeof navigator !== "undefined" ? navigator.clipboard : undefined;
|
||||
if (clipboard?.writeText) {
|
||||
try {
|
||||
await clipboard.writeText(text);
|
||||
return true;
|
||||
} catch {
|
||||
// Fall back below when the browser still allows synchronous copy.
|
||||
}
|
||||
}
|
||||
|
||||
return !useSelectionFirst && copyWithSelection(text);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// @vitest-environment node
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
deduplicateIds,
|
||||
serializeClipboardPayload,
|
||||
deserializeClipboardPayload,
|
||||
type ClipboardPayload,
|
||||
} from "./clipboardPayload";
|
||||
|
||||
describe("deduplicateIds", () => {
|
||||
it("renames ids that collide with existing ids", () => {
|
||||
const html = '<div id="hero"><img id="photo" src="a.png" /></div>';
|
||||
const existingIds = ["hero", "other"];
|
||||
const result = deduplicateIds(html, existingIds);
|
||||
expect(result).not.toContain('id="hero"');
|
||||
expect(result).toContain('id="photo"');
|
||||
expect(result).toMatch(/id="hero-\d+"/);
|
||||
});
|
||||
|
||||
it("returns html unchanged when no collisions", () => {
|
||||
const html = '<div id="unique"><p>hello</p></div>';
|
||||
const result = deduplicateIds(html, ["other"]);
|
||||
expect(result).toBe(html);
|
||||
});
|
||||
|
||||
it("does not rewrite data-composition-id or other data-*-id attributes", () => {
|
||||
const html = '<div data-composition-id="hero" data-clip-id="hero" id="hero">content</div>';
|
||||
const result = deduplicateIds(html, ["hero"]);
|
||||
expect(result).toContain('data-composition-id="hero"');
|
||||
expect(result).toContain('data-clip-id="hero"');
|
||||
expect(result).toMatch(/\sid="hero-\d+"/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("serializeClipboardPayload / deserializeClipboardPayload", () => {
|
||||
it("round-trips a timeline clip payload", () => {
|
||||
const payload: ClipboardPayload = {
|
||||
kind: "timeline-clip",
|
||||
html: '<img id="photo" src="a.png" data-start="1" data-duration="3" />',
|
||||
sourceFile: "index.html",
|
||||
};
|
||||
const json = serializeClipboardPayload(payload);
|
||||
const parsed = deserializeClipboardPayload(json);
|
||||
expect(parsed).toEqual(payload);
|
||||
});
|
||||
|
||||
it("round-trips a dom-element payload", () => {
|
||||
const payload: ClipboardPayload = {
|
||||
kind: "dom-element",
|
||||
html: '<div class="card"><p>Hello</p></div>',
|
||||
sourceFile: "compositions/scene.html",
|
||||
};
|
||||
const json = serializeClipboardPayload(payload);
|
||||
const parsed = deserializeClipboardPayload(json);
|
||||
expect(parsed).toEqual(payload);
|
||||
});
|
||||
|
||||
it("returns null for invalid JSON", () => {
|
||||
expect(deserializeClipboardPayload("not json")).toBeNull();
|
||||
expect(deserializeClipboardPayload('{"kind":"unknown"}')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
import { COMPOSITION_ROOT_OPEN_TAG_RE } from "./compositionPatterns";
|
||||
|
||||
const CLIPBOARD_MARKER = "hyperframes-clipboard:v1";
|
||||
|
||||
export interface ClipboardPayload {
|
||||
kind: "timeline-clip" | "dom-element";
|
||||
html: string;
|
||||
sourceFile: string;
|
||||
originSelector?: string;
|
||||
originSelectorIndex?: number;
|
||||
}
|
||||
|
||||
interface SerializedPayload {
|
||||
_marker: string;
|
||||
kind: "timeline-clip" | "dom-element";
|
||||
html: string;
|
||||
sourceFile: string;
|
||||
originSelector?: string;
|
||||
originSelectorIndex?: number;
|
||||
}
|
||||
|
||||
export function serializeClipboardPayload(payload: ClipboardPayload): string {
|
||||
const data: SerializedPayload = {
|
||||
_marker: CLIPBOARD_MARKER,
|
||||
kind: payload.kind,
|
||||
html: payload.html,
|
||||
sourceFile: payload.sourceFile,
|
||||
originSelector: payload.originSelector,
|
||||
originSelectorIndex: payload.originSelectorIndex,
|
||||
};
|
||||
return JSON.stringify(data);
|
||||
}
|
||||
|
||||
export function deserializeClipboardPayload(json: string): ClipboardPayload | null {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(json);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!parsed || typeof parsed !== "object") return null;
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
if (obj._marker !== CLIPBOARD_MARKER) return null;
|
||||
if (obj.kind !== "timeline-clip" && obj.kind !== "dom-element") return null;
|
||||
if (typeof obj.html !== "string" || typeof obj.sourceFile !== "string") return null;
|
||||
return {
|
||||
kind: obj.kind,
|
||||
html: obj.html,
|
||||
sourceFile: obj.sourceFile,
|
||||
originSelector: typeof obj.originSelector === "string" ? obj.originSelector : undefined,
|
||||
originSelectorIndex:
|
||||
typeof obj.originSelectorIndex === "number" ? obj.originSelectorIndex : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert `newHtml` as a sibling immediately after the element matched by
|
||||
* `selector` (at `selectorIndex`) in `source`. Falls back to inserting after
|
||||
* the composition root if the selector doesn't match — so paste never silently
|
||||
* drops the content.
|
||||
*/
|
||||
export function insertAsSibling(
|
||||
source: string,
|
||||
newHtml: string,
|
||||
selector: string | undefined,
|
||||
selectorIndex: number | undefined,
|
||||
): string {
|
||||
if (selector) {
|
||||
const idx = selectorIndex ?? 0;
|
||||
let matchCount = 0;
|
||||
|
||||
// Find the element by searching for its opening tag pattern.
|
||||
// For id selectors like #foo, search for id="foo".
|
||||
// For class selectors like .name-text, search for class="...name-text...".
|
||||
// For attribute selectors like [data-composition-id="x"], search literally.
|
||||
|
||||
let searchPattern: RegExp | null = null;
|
||||
if (selector.startsWith("#")) {
|
||||
const id = selector.slice(1);
|
||||
searchPattern = new RegExp(`<[a-z][^>]*\\bid="${id}"[^>]*>`, "gi");
|
||||
} else if (selector.startsWith(".")) {
|
||||
const cls = selector.slice(1);
|
||||
searchPattern = new RegExp(`<[a-z][^>]*\\bclass="[^"]*\\b${cls}\\b[^"]*"[^>]*>`, "gi");
|
||||
} else if (selector.startsWith("[")) {
|
||||
const inner = selector.slice(1, -1);
|
||||
searchPattern = new RegExp(`<[a-z][^>]*\\b${inner}[^>]*>`, "gi");
|
||||
}
|
||||
|
||||
if (searchPattern) {
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = searchPattern.exec(source)) !== null) {
|
||||
if (matchCount === idx) {
|
||||
const insertPos = findClosingTagPosition(source, match.index);
|
||||
if (insertPos > 0) {
|
||||
return source.slice(0, insertPos) + "\n" + newHtml + source.slice(insertPos);
|
||||
}
|
||||
}
|
||||
matchCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: insert after composition root opening tag (same as timeline clips)
|
||||
const rootMatch = COMPOSITION_ROOT_OPEN_TAG_RE.exec(source);
|
||||
if (rootMatch && rootMatch.index != null) {
|
||||
const insertAt = rootMatch.index + rootMatch[0].length;
|
||||
return source.slice(0, insertAt) + newHtml + source.slice(insertAt);
|
||||
}
|
||||
|
||||
return source + newHtml;
|
||||
}
|
||||
|
||||
function findClosingTagPosition(html: string, openTagStart: number): number {
|
||||
// Find the end of the opening tag
|
||||
const openTagEnd = html.indexOf(">", openTagStart);
|
||||
if (openTagEnd < 0) return -1;
|
||||
|
||||
// Self-closing tag?
|
||||
if (html[openTagEnd - 1] === "/") return openTagEnd + 1;
|
||||
|
||||
// Extract the tag name
|
||||
const tagNameMatch = html.slice(openTagStart).match(/^<([a-z][a-z0-9]*)/i);
|
||||
if (!tagNameMatch) return -1;
|
||||
const tagName = tagNameMatch[1]!;
|
||||
|
||||
// Walk forward counting open/close tags of the same name
|
||||
let depth = 1;
|
||||
let pos = openTagEnd + 1;
|
||||
const openRe = new RegExp(`<${tagName}(?:\\s|>|/>)`, "gi");
|
||||
const closeRe = new RegExp(`</${tagName}\\s*>`, "gi");
|
||||
|
||||
while (depth > 0 && pos < html.length) {
|
||||
openRe.lastIndex = pos;
|
||||
closeRe.lastIndex = pos;
|
||||
|
||||
const nextOpen = openRe.exec(html);
|
||||
const nextClose = closeRe.exec(html);
|
||||
|
||||
if (!nextClose) return -1;
|
||||
|
||||
if (nextOpen && nextOpen.index < nextClose.index) {
|
||||
// Check if it's self-closing
|
||||
const selfCloseCheck = html.lastIndexOf("/", html.indexOf(">", nextOpen.index));
|
||||
if (selfCloseCheck > nextOpen.index) {
|
||||
pos = html.indexOf(">", nextOpen.index) + 1;
|
||||
} else {
|
||||
depth++;
|
||||
pos = html.indexOf(">", nextOpen.index) + 1;
|
||||
}
|
||||
} else {
|
||||
depth--;
|
||||
if (depth === 0) return nextClose.index + nextClose[0].length;
|
||||
pos = nextClose.index + nextClose[0].length;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
export function deduplicateIds(html: string, existingIds: string[]): string {
|
||||
const existingSet = new Set(existingIds);
|
||||
return html.replace(/(?<=\s)id="([^"]+)"/g, (full, id: string) => {
|
||||
if (!existingSet.has(id)) return full;
|
||||
let counter = 2;
|
||||
while (existingSet.has(`${id}-${counter}`)) counter++;
|
||||
const newId = `${id}-${counter}`;
|
||||
existingSet.add(newId);
|
||||
return `id="${newId}"`;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
/** Matches the opening tag of a composition root element (e.g. `<div data-composition-id="main">`). */
|
||||
export const COMPOSITION_ROOT_OPEN_TAG_RE = /<[^>]*data-composition-id="[^"]+"[^>]*>/i;
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { DomEditSelection } from "../components/editor/domEditing";
|
||||
import { getDomEditTargetKey } from "../components/editor/domEditing";
|
||||
|
||||
export function domEditSelectionsTargetSame(
|
||||
a: DomEditSelection | null,
|
||||
b: DomEditSelection | null,
|
||||
): boolean {
|
||||
if (a === b) return true;
|
||||
if (!a || !b) return false;
|
||||
return getDomEditTargetKey(a) === getDomEditTargetKey(b);
|
||||
}
|
||||
|
||||
export function domEditSelectionInGroup(
|
||||
group: DomEditSelection[],
|
||||
selection: DomEditSelection | null,
|
||||
): boolean {
|
||||
if (!selection) return false;
|
||||
return group.some((entry) => domEditSelectionsTargetSame(entry, selection));
|
||||
}
|
||||
|
||||
export function toggleDomEditGroupSelection(
|
||||
group: DomEditSelection[],
|
||||
selection: DomEditSelection,
|
||||
): DomEditSelection[] {
|
||||
if (domEditSelectionInGroup(group, selection)) {
|
||||
return group.filter((entry) => !domEditSelectionsTargetSame(entry, selection));
|
||||
}
|
||||
return [...group, selection];
|
||||
}
|
||||
|
||||
export function replaceDomEditGroupSelection(
|
||||
group: DomEditSelection[],
|
||||
selection: DomEditSelection,
|
||||
): DomEditSelection[] {
|
||||
let replaced = false;
|
||||
const nextGroup = group.map((entry) => {
|
||||
if (!domEditSelectionsTargetSame(entry, selection)) return entry;
|
||||
replaced = true;
|
||||
return selection;
|
||||
});
|
||||
return replaced ? nextGroup : [...group, selection];
|
||||
}
|
||||
|
||||
export function seedDomEditGroupWithSelection(
|
||||
group: DomEditSelection[],
|
||||
selection: DomEditSelection | null,
|
||||
): DomEditSelection[] {
|
||||
if (!selection || domEditSelectionInGroup(group, selection)) return group;
|
||||
return [selection, ...group];
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createDomEditSaveQueue } from "./domEditSaveQueue";
|
||||
import { StudioSaveHttpError } from "./studioSaveDiagnostics";
|
||||
|
||||
describe("dom edit save queue", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("opens the breaker after consecutive failures and rejects new work until reset", async () => {
|
||||
const onOpen = vi.fn();
|
||||
const onReset = vi.fn();
|
||||
const queue = createDomEditSaveQueue({
|
||||
failureThreshold: 2,
|
||||
onOpen,
|
||||
onReset,
|
||||
});
|
||||
|
||||
await expect(
|
||||
queue.enqueue(async () => {
|
||||
throw new StudioSaveHttpError("Server down", 503);
|
||||
}),
|
||||
).rejects.toThrow("Server down");
|
||||
await expect(
|
||||
queue.enqueue(async () => {
|
||||
throw new StudioSaveHttpError("Still down", 503);
|
||||
}),
|
||||
).rejects.toThrow("Still down");
|
||||
|
||||
expect(onOpen).toHaveBeenCalledWith({
|
||||
consecutiveFailures: 2,
|
||||
errorMessage: "Still down",
|
||||
statusCode: 503,
|
||||
});
|
||||
|
||||
let thirdRan = false;
|
||||
await expect(
|
||||
queue.enqueue(async () => {
|
||||
thirdRan = true;
|
||||
}),
|
||||
).rejects.toThrow("Auto-save is paused");
|
||||
expect(thirdRan).toBe(false);
|
||||
|
||||
queue.reset();
|
||||
expect(onReset).toHaveBeenCalledOnce();
|
||||
|
||||
await queue.enqueue(async () => {
|
||||
thirdRan = true;
|
||||
});
|
||||
expect(thirdRan).toBe(true);
|
||||
queue.destroy();
|
||||
});
|
||||
|
||||
it("keeps an open breaker paused even when already queued work succeeds", async () => {
|
||||
const onOpen = vi.fn();
|
||||
const onReset = vi.fn();
|
||||
const queue = createDomEditSaveQueue({
|
||||
failureThreshold: 1,
|
||||
onOpen,
|
||||
onReset,
|
||||
});
|
||||
|
||||
let rejectFirst: ((error: Error) => void) | null = null;
|
||||
let resolveFirstStarted: (() => void) | null = null;
|
||||
const firstStarted = new Promise<void>((resolve) => {
|
||||
resolveFirstStarted = resolve;
|
||||
});
|
||||
const first = queue.enqueue(
|
||||
() =>
|
||||
new Promise<void>((_resolve, reject) => {
|
||||
rejectFirst = reject;
|
||||
resolveFirstStarted?.();
|
||||
}),
|
||||
);
|
||||
const second = queue.enqueue(async () => {});
|
||||
|
||||
await firstStarted;
|
||||
expect(rejectFirst).toBeTypeOf("function");
|
||||
rejectFirst?.(new StudioSaveHttpError("Server down", 503));
|
||||
await expect(first).rejects.toThrow("Server down");
|
||||
await expect(second).resolves.toBeUndefined();
|
||||
|
||||
expect(onOpen).toHaveBeenCalledOnce();
|
||||
expect(onReset).not.toHaveBeenCalled();
|
||||
|
||||
await expect(queue.enqueue(async () => {})).rejects.toThrow("Auto-save is paused");
|
||||
|
||||
queue.reset();
|
||||
expect(onReset).toHaveBeenCalledOnce();
|
||||
queue.destroy();
|
||||
});
|
||||
|
||||
it("resets consecutive failures after a successful save", async () => {
|
||||
const onOpen = vi.fn();
|
||||
const queue = createDomEditSaveQueue({
|
||||
failureThreshold: 2,
|
||||
onOpen,
|
||||
});
|
||||
|
||||
await expect(
|
||||
queue.enqueue(async () => {
|
||||
throw new Error("first failure");
|
||||
}),
|
||||
).rejects.toThrow("first failure");
|
||||
|
||||
await queue.enqueue(async () => {});
|
||||
|
||||
await expect(
|
||||
queue.enqueue(async () => {
|
||||
throw new Error("second failure after success");
|
||||
}),
|
||||
).rejects.toThrow("second failure after success");
|
||||
|
||||
expect(onOpen).not.toHaveBeenCalled();
|
||||
queue.destroy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import { getStudioSaveErrorMessage, getStudioSaveStatusCode } from "./studioSaveDiagnostics";
|
||||
|
||||
interface DomEditSaveQueueOpenEvent {
|
||||
consecutiveFailures: number;
|
||||
errorMessage: string;
|
||||
statusCode: number | null;
|
||||
}
|
||||
|
||||
interface DomEditSaveQueueOptions {
|
||||
failureThreshold?: number;
|
||||
onOpen?: (event: DomEditSaveQueueOpenEvent) => void;
|
||||
onReset?: () => void;
|
||||
}
|
||||
|
||||
export interface DomEditSaveQueue {
|
||||
enqueue: (save: () => Promise<void>) => Promise<void>;
|
||||
waitForIdle: () => Promise<void>;
|
||||
reset: () => void;
|
||||
destroy: () => void;
|
||||
}
|
||||
|
||||
const DEFAULT_FAILURE_THRESHOLD = 5;
|
||||
|
||||
export class DomEditSaveQueueOpenError extends Error {
|
||||
constructor() {
|
||||
super("Auto-save is paused. Dismiss the warning to retry DOM edits.");
|
||||
this.name = "DomEditSaveQueueOpenError";
|
||||
}
|
||||
}
|
||||
|
||||
export function createDomEditSaveQueue(options: DomEditSaveQueueOptions = {}): DomEditSaveQueue {
|
||||
const failureThreshold = options.failureThreshold ?? DEFAULT_FAILURE_THRESHOLD;
|
||||
|
||||
let tail = Promise.resolve();
|
||||
let consecutiveFailures = 0;
|
||||
let breakerOpen = false;
|
||||
|
||||
const reset = (notify = true) => {
|
||||
const wasOpen = breakerOpen;
|
||||
consecutiveFailures = 0;
|
||||
breakerOpen = false;
|
||||
if (notify && wasOpen) options.onReset?.();
|
||||
};
|
||||
|
||||
const open = (error: unknown) => {
|
||||
if (breakerOpen) return;
|
||||
breakerOpen = true;
|
||||
options.onOpen?.({
|
||||
consecutiveFailures,
|
||||
errorMessage: getStudioSaveErrorMessage(error),
|
||||
statusCode: getStudioSaveStatusCode(error) ?? null,
|
||||
});
|
||||
};
|
||||
|
||||
const run = async (save: () => Promise<void>) => {
|
||||
try {
|
||||
await save();
|
||||
if (!breakerOpen) consecutiveFailures = 0;
|
||||
} catch (error) {
|
||||
consecutiveFailures += 1;
|
||||
if (consecutiveFailures >= failureThreshold) open(error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
enqueue(save) {
|
||||
if (breakerOpen) return Promise.reject(new DomEditSaveQueueOpenError());
|
||||
const queued = tail.catch(() => undefined).then(() => run(save));
|
||||
tail = queued.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
return queued;
|
||||
},
|
||||
|
||||
async waitForIdle() {
|
||||
await tail.catch(() => undefined);
|
||||
},
|
||||
|
||||
reset,
|
||||
|
||||
destroy() {
|
||||
reset(false);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildEditHistoryEntry,
|
||||
canApplyEditHistoryEntry,
|
||||
createEmptyEditHistory,
|
||||
hashEditHistoryContent,
|
||||
pushEditHistoryEntry,
|
||||
redoEditHistory,
|
||||
undoEditHistory,
|
||||
} from "./editHistory";
|
||||
|
||||
describe("edit history", () => {
|
||||
it("pushes changed file snapshots onto undo and clears redo", () => {
|
||||
const state = createEmptyEditHistory();
|
||||
const entry = buildEditHistoryEntry({
|
||||
projectId: "project-1",
|
||||
label: "Move layer",
|
||||
files: {
|
||||
"index.html": {
|
||||
before: '<div style="left: 0px"></div>',
|
||||
after: '<div style="left: 20px"></div>',
|
||||
},
|
||||
},
|
||||
now: 100,
|
||||
id: "entry-1",
|
||||
});
|
||||
|
||||
const withUndo = pushEditHistoryEntry(state, entry);
|
||||
const redoEntry = buildEditHistoryEntry({
|
||||
projectId: "project-1",
|
||||
label: "Redoable edit",
|
||||
files: {
|
||||
"index.html": {
|
||||
before: '<div style="left: 20px"></div>',
|
||||
after: '<div style="left: 40px"></div>',
|
||||
},
|
||||
},
|
||||
now: 200,
|
||||
id: "redo-entry",
|
||||
});
|
||||
|
||||
const next = pushEditHistoryEntry(
|
||||
{
|
||||
...withUndo,
|
||||
redo: [redoEntry],
|
||||
},
|
||||
{
|
||||
...entry,
|
||||
id: "entry-2",
|
||||
label: "Resize layer",
|
||||
createdAt: 300,
|
||||
},
|
||||
);
|
||||
|
||||
expect(withUndo.undo).toHaveLength(1);
|
||||
expect(withUndo.redo).toHaveLength(0);
|
||||
expect(next.undo.map((item) => item.label)).toEqual(["Move layer", "Resize layer"]);
|
||||
expect(next.redo).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("undo returns before contents and moves entry to redo", () => {
|
||||
const entry = buildEditHistoryEntry({
|
||||
projectId: "project-1",
|
||||
label: "Move layer",
|
||||
files: {
|
||||
"index.html": { before: "before", after: "after" },
|
||||
},
|
||||
now: 100,
|
||||
id: "entry-1",
|
||||
});
|
||||
const state = pushEditHistoryEntry(createEmptyEditHistory(), entry);
|
||||
|
||||
const result = undoEditHistory(state, { "index.html": hashEditHistoryContent("after") }, 200);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.filesToWrite).toEqual({ "index.html": "before" });
|
||||
expect(result.state.undo).toHaveLength(0);
|
||||
expect(result.state.redo.map((item) => item.id)).toEqual(["entry-1"]);
|
||||
});
|
||||
|
||||
it("redo returns after contents and moves entry to undo", () => {
|
||||
const entry = buildEditHistoryEntry({
|
||||
projectId: "project-1",
|
||||
label: "Move layer",
|
||||
files: {
|
||||
"index.html": { before: "before", after: "after" },
|
||||
},
|
||||
now: 100,
|
||||
id: "entry-1",
|
||||
});
|
||||
const undone = undoEditHistory(
|
||||
pushEditHistoryEntry(createEmptyEditHistory(), entry),
|
||||
{ "index.html": hashEditHistoryContent("after") },
|
||||
200,
|
||||
).state;
|
||||
|
||||
const result = redoEditHistory(undone, { "index.html": hashEditHistoryContent("before") }, 300);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.filesToWrite).toEqual({ "index.html": "after" });
|
||||
expect(result.state.undo.map((item) => item.id)).toEqual(["entry-1"]);
|
||||
expect(result.state.redo).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("blocks undo when current content hash does not match the recorded after hash", () => {
|
||||
const entry = buildEditHistoryEntry({
|
||||
projectId: "project-1",
|
||||
label: "Move layer",
|
||||
files: {
|
||||
"index.html": { before: "before", after: "after" },
|
||||
},
|
||||
now: 100,
|
||||
id: "entry-1",
|
||||
});
|
||||
const state = pushEditHistoryEntry(createEmptyEditHistory(), entry);
|
||||
|
||||
const result = undoEditHistory(
|
||||
state,
|
||||
{ "index.html": hashEditHistoryContent("external") },
|
||||
200,
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.reason).toBe("content-mismatch");
|
||||
expect(result.state).toBe(state);
|
||||
expect(result.filesToWrite).toEqual({});
|
||||
});
|
||||
|
||||
it("can validate all files in a multi-file entry before applying", () => {
|
||||
const entry = buildEditHistoryEntry({
|
||||
projectId: "project-1",
|
||||
label: "Update files",
|
||||
files: {
|
||||
"index.html": { before: "a", after: "b" },
|
||||
"compositions/title.html": { before: "c", after: "d" },
|
||||
},
|
||||
now: 100,
|
||||
id: "entry-1",
|
||||
});
|
||||
|
||||
expect(
|
||||
canApplyEditHistoryEntry(entry, "undo", {
|
||||
"index.html": hashEditHistoryContent("b"),
|
||||
"compositions/title.html": hashEditHistoryContent("d"),
|
||||
}),
|
||||
).toEqual({ ok: true });
|
||||
expect(
|
||||
canApplyEditHistoryEntry(entry, "undo", {
|
||||
"index.html": hashEditHistoryContent("b"),
|
||||
"compositions/title.html": hashEditHistoryContent("external"),
|
||||
}),
|
||||
).toEqual({ ok: false, reason: "content-mismatch", path: "compositions/title.html" });
|
||||
});
|
||||
|
||||
it("prunes oldest undo entries when the limit is exceeded", () => {
|
||||
let state = createEmptyEditHistory({ maxEntries: 2 });
|
||||
for (let index = 1; index <= 3; index += 1) {
|
||||
state = pushEditHistoryEntry(
|
||||
state,
|
||||
buildEditHistoryEntry({
|
||||
projectId: "project-1",
|
||||
label: `Edit ${index}`,
|
||||
files: {
|
||||
"index.html": { before: `${index - 1}`, after: `${index}` },
|
||||
},
|
||||
now: index,
|
||||
id: `entry-${index}`,
|
||||
}),
|
||||
{ maxEntries: 2 },
|
||||
);
|
||||
}
|
||||
|
||||
expect(state.undo.map((entry) => entry.id)).toEqual(["entry-2", "entry-3"]);
|
||||
});
|
||||
|
||||
it("coalesces source editor edits for the same file inside the coalesce window", () => {
|
||||
const first = buildEditHistoryEntry({
|
||||
projectId: "project-1",
|
||||
label: "Edit source",
|
||||
kind: "source",
|
||||
coalesceKey: "source:index.html",
|
||||
files: {
|
||||
"index.html": { before: "a", after: "b" },
|
||||
},
|
||||
now: 100,
|
||||
id: "entry-1",
|
||||
});
|
||||
const second = buildEditHistoryEntry({
|
||||
projectId: "project-1",
|
||||
label: "Edit source",
|
||||
kind: "source",
|
||||
coalesceKey: "source:index.html",
|
||||
files: {
|
||||
"index.html": { before: "b", after: "c" },
|
||||
},
|
||||
now: 300,
|
||||
id: "entry-2",
|
||||
});
|
||||
|
||||
const state = pushEditHistoryEntry(
|
||||
pushEditHistoryEntry(createEmptyEditHistory(), first),
|
||||
second,
|
||||
{ coalesceMs: 1000 },
|
||||
);
|
||||
|
||||
expect(state.undo).toHaveLength(1);
|
||||
expect(state.undo[0].id).toBe("entry-2");
|
||||
expect(state.undo[0].files["index.html"].before).toBe("a");
|
||||
expect(state.undo[0].files["index.html"].after).toBe("c");
|
||||
});
|
||||
|
||||
it("folds a slow GSAP follow-up into the timing edit via a per-entry coalesceMs override", () => {
|
||||
const timing = buildEditHistoryEntry({
|
||||
projectId: "project-1",
|
||||
label: "Resize timeline clip",
|
||||
kind: "timeline",
|
||||
coalesceKey: "timeline-resize:clip",
|
||||
files: { "index.html": { before: "orig", after: "timing" } },
|
||||
now: 0,
|
||||
id: "timing",
|
||||
});
|
||||
// The server GSAP rewrite lands ~2s later, past the 300ms default window, but the
|
||||
// follow-up carries a large coalesceMs so undo still collapses to a single step.
|
||||
const gsap = buildEditHistoryEntry({
|
||||
projectId: "project-1",
|
||||
label: "Resize timeline clip",
|
||||
kind: "timeline",
|
||||
coalesceKey: "timeline-resize:clip",
|
||||
coalesceMs: 10_000,
|
||||
files: { "index.html": { before: "timing", after: "timing+gsap" } },
|
||||
now: 2000,
|
||||
id: "gsap",
|
||||
});
|
||||
|
||||
const state = pushEditHistoryEntry(
|
||||
pushEditHistoryEntry(createEmptyEditHistory(), timing),
|
||||
gsap,
|
||||
);
|
||||
|
||||
expect(state.undo).toHaveLength(1);
|
||||
expect(state.undo[0].files["index.html"].before).toBe("orig");
|
||||
expect(state.undo[0].files["index.html"].after).toBe("timing+gsap");
|
||||
});
|
||||
|
||||
it("does not merge a slow follow-up without the coalesceMs override", () => {
|
||||
const timing = buildEditHistoryEntry({
|
||||
projectId: "project-1",
|
||||
label: "Resize timeline clip",
|
||||
kind: "timeline",
|
||||
coalesceKey: "timeline-resize:clip",
|
||||
files: { "index.html": { before: "orig", after: "timing" } },
|
||||
now: 0,
|
||||
id: "timing",
|
||||
});
|
||||
const late = buildEditHistoryEntry({
|
||||
projectId: "project-1",
|
||||
label: "Resize timeline clip",
|
||||
kind: "timeline",
|
||||
coalesceKey: "timeline-resize:clip",
|
||||
files: { "index.html": { before: "timing", after: "timing+gsap" } },
|
||||
now: 2000,
|
||||
id: "late",
|
||||
});
|
||||
|
||||
const state = pushEditHistoryEntry(
|
||||
pushEditHistoryEntry(createEmptyEditHistory(), timing),
|
||||
late,
|
||||
);
|
||||
|
||||
expect(state.undo).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("coalesces entries with the same coalesceKey within the window (prop: format)", () => {
|
||||
const first = buildEditHistoryEntry({
|
||||
projectId: "project-1",
|
||||
label: "Edit title color",
|
||||
kind: "source",
|
||||
coalesceKey: "prop:title.color",
|
||||
files: {
|
||||
"index.html": { before: "a", after: "b" },
|
||||
},
|
||||
now: 100,
|
||||
id: "entry-1",
|
||||
});
|
||||
const second = buildEditHistoryEntry({
|
||||
projectId: "project-1",
|
||||
label: "Edit title color",
|
||||
kind: "source",
|
||||
coalesceKey: "prop:title.color",
|
||||
files: {
|
||||
"index.html": { before: "b", after: "c" },
|
||||
},
|
||||
now: 200,
|
||||
id: "entry-2",
|
||||
});
|
||||
|
||||
const state = pushEditHistoryEntry(
|
||||
pushEditHistoryEntry(createEmptyEditHistory(), first),
|
||||
second,
|
||||
{ coalesceMs: 1000 },
|
||||
);
|
||||
|
||||
expect(state.undo).toHaveLength(1);
|
||||
expect(state.undo[0].id).toBe("entry-2");
|
||||
expect(state.undo[0].files["index.html"].before).toBe("a");
|
||||
expect(state.undo[0].files["index.html"].after).toBe("c");
|
||||
});
|
||||
|
||||
it("does not coalesce entries with different coalesceKeys (cross-prop separation)", () => {
|
||||
const titleEdit = buildEditHistoryEntry({
|
||||
projectId: "project-1",
|
||||
label: "Edit title color",
|
||||
kind: "source",
|
||||
coalesceKey: "prop:title.color",
|
||||
files: {
|
||||
"index.html": { before: "a", after: "b" },
|
||||
},
|
||||
now: 100,
|
||||
id: "entry-title",
|
||||
});
|
||||
const bodyEdit = buildEditHistoryEntry({
|
||||
projectId: "project-1",
|
||||
label: "Edit body color",
|
||||
kind: "source",
|
||||
coalesceKey: "prop:body.color",
|
||||
files: {
|
||||
"index.html": { before: "b", after: "c" },
|
||||
},
|
||||
now: 200,
|
||||
id: "entry-body",
|
||||
});
|
||||
|
||||
const state = pushEditHistoryEntry(
|
||||
pushEditHistoryEntry(createEmptyEditHistory(), titleEdit),
|
||||
bodyEdit,
|
||||
{ coalesceMs: 1000 },
|
||||
);
|
||||
|
||||
expect(state.undo.map((e) => e.id)).toEqual(["entry-title", "entry-body"]);
|
||||
});
|
||||
|
||||
it("does not coalesce source editor edits outside the coalesce window", () => {
|
||||
const first = buildEditHistoryEntry({
|
||||
projectId: "project-1",
|
||||
label: "Edit source",
|
||||
kind: "source",
|
||||
coalesceKey: "source:index.html",
|
||||
files: {
|
||||
"index.html": { before: "a", after: "b" },
|
||||
},
|
||||
now: 100,
|
||||
id: "entry-1",
|
||||
});
|
||||
const second = buildEditHistoryEntry({
|
||||
projectId: "project-1",
|
||||
label: "Edit source",
|
||||
kind: "source",
|
||||
coalesceKey: "source:index.html",
|
||||
files: {
|
||||
"index.html": { before: "b", after: "c" },
|
||||
},
|
||||
now: 5000,
|
||||
id: "entry-2",
|
||||
});
|
||||
|
||||
const state = pushEditHistoryEntry(
|
||||
pushEditHistoryEntry(createEmptyEditHistory(), first),
|
||||
second,
|
||||
{ coalesceMs: 1000 },
|
||||
);
|
||||
|
||||
expect(state.undo.map((entry) => entry.id)).toEqual(["entry-1", "entry-2"]);
|
||||
});
|
||||
|
||||
it("coalesces entries exactly at the coalesce boundary (delta === coalesceMs is inclusive)", () => {
|
||||
const first = buildEditHistoryEntry({
|
||||
projectId: "project-1",
|
||||
label: "Edit source",
|
||||
kind: "source",
|
||||
coalesceKey: "source:index.html",
|
||||
files: {
|
||||
"index.html": { before: "a", after: "b" },
|
||||
},
|
||||
now: 100,
|
||||
id: "entry-1",
|
||||
});
|
||||
const second = buildEditHistoryEntry({
|
||||
projectId: "project-1",
|
||||
label: "Edit source",
|
||||
kind: "source",
|
||||
coalesceKey: "source:index.html",
|
||||
files: {
|
||||
"index.html": { before: "b", after: "c" },
|
||||
},
|
||||
now: 1100, // exactly coalesceMs=1000ms after first
|
||||
id: "entry-2",
|
||||
});
|
||||
|
||||
const state = pushEditHistoryEntry(
|
||||
pushEditHistoryEntry(createEmptyEditHistory(), first),
|
||||
second,
|
||||
{ coalesceMs: 1000 },
|
||||
);
|
||||
|
||||
// Boundary is <=: delta of exactly 1000ms coalesces into one entry.
|
||||
expect(state.undo).toHaveLength(1);
|
||||
expect(state.undo[0].id).toBe("entry-2");
|
||||
expect(state.undo[0].files["index.html"].before).toBe("a");
|
||||
expect(state.undo[0].files["index.html"].after).toBe("c");
|
||||
});
|
||||
|
||||
it.todo("gesture-start/commit collapses intermediate drag steps into one undo entry");
|
||||
|
||||
it.todo(
|
||||
"origin:applyPatches edits are excluded from undo stack to prevent undo loops (requires SDK session)",
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,224 @@
|
||||
export type EditHistoryKind = "manual" | "motion" | "timeline" | "source";
|
||||
|
||||
export interface EditHistoryFileSnapshot {
|
||||
before: string;
|
||||
after: string;
|
||||
beforeHash: string;
|
||||
afterHash: string;
|
||||
}
|
||||
|
||||
export interface EditHistoryEntry {
|
||||
id: string;
|
||||
projectId: string;
|
||||
label: string;
|
||||
kind: EditHistoryKind;
|
||||
coalesceKey?: string;
|
||||
/** Per-entry coalesce window override (ms). Falls back to the reducer default. */
|
||||
coalesceMs?: number;
|
||||
createdAt: number;
|
||||
files: Record<string, EditHistoryFileSnapshot>;
|
||||
}
|
||||
|
||||
export interface EditHistoryState {
|
||||
version: 1;
|
||||
updatedAt: number;
|
||||
undo: EditHistoryEntry[];
|
||||
redo: EditHistoryEntry[];
|
||||
}
|
||||
|
||||
export interface EditHistoryOptions {
|
||||
maxEntries?: number;
|
||||
coalesceMs?: number;
|
||||
}
|
||||
|
||||
export interface BuildEditHistoryEntryInput {
|
||||
id: string;
|
||||
projectId: string;
|
||||
label: string;
|
||||
kind?: EditHistoryKind;
|
||||
coalesceKey?: string;
|
||||
coalesceMs?: number;
|
||||
now: number;
|
||||
files: Record<string, { before: string; after: string }>;
|
||||
}
|
||||
|
||||
export type EditHistoryDirection = "undo" | "redo";
|
||||
|
||||
export type EditHistoryApplyCheck =
|
||||
| { ok: true }
|
||||
| { ok: false; reason: "content-mismatch"; path: string };
|
||||
|
||||
export type EditHistoryTransitionResult =
|
||||
| {
|
||||
ok: true;
|
||||
state: EditHistoryState;
|
||||
entry: EditHistoryEntry;
|
||||
filesToWrite: Record<string, string>;
|
||||
}
|
||||
| {
|
||||
ok: false;
|
||||
reason: "empty" | "content-mismatch";
|
||||
state: EditHistoryState;
|
||||
filesToWrite: Record<string, string>;
|
||||
path?: string;
|
||||
};
|
||||
|
||||
const DEFAULT_MAX_ENTRIES = 100;
|
||||
const DEFAULT_COALESCE_MS = 300;
|
||||
|
||||
export function hashEditHistoryContent(content: string): string {
|
||||
let hash = 2166136261;
|
||||
for (let index = 0; index < content.length; index += 1) {
|
||||
hash ^= content.charCodeAt(index);
|
||||
hash = Math.imul(hash, 16777619);
|
||||
}
|
||||
return (hash >>> 0).toString(16).padStart(8, "0");
|
||||
}
|
||||
|
||||
export function createEmptyEditHistory(_options?: EditHistoryOptions): EditHistoryState {
|
||||
return {
|
||||
version: 1,
|
||||
updatedAt: 0,
|
||||
undo: [],
|
||||
redo: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function buildEditHistoryEntry(input: BuildEditHistoryEntryInput): EditHistoryEntry {
|
||||
const files: Record<string, EditHistoryFileSnapshot> = {};
|
||||
for (const [path, snapshot] of Object.entries(input.files)) {
|
||||
if (snapshot.before === snapshot.after) continue;
|
||||
files[path] = {
|
||||
before: snapshot.before,
|
||||
after: snapshot.after,
|
||||
beforeHash: hashEditHistoryContent(snapshot.before),
|
||||
afterHash: hashEditHistoryContent(snapshot.after),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id: input.id,
|
||||
projectId: input.projectId,
|
||||
label: input.label,
|
||||
kind: input.kind ?? "manual",
|
||||
coalesceKey: input.coalesceKey,
|
||||
coalesceMs: input.coalesceMs,
|
||||
createdAt: input.now,
|
||||
files,
|
||||
};
|
||||
}
|
||||
|
||||
export function pushEditHistoryEntry(
|
||||
state: EditHistoryState,
|
||||
entry: EditHistoryEntry,
|
||||
options?: EditHistoryOptions,
|
||||
): EditHistoryState {
|
||||
if (Object.keys(entry.files).length === 0) return state;
|
||||
|
||||
// The incoming entry's own window wins so a caller can guarantee a merge even when a
|
||||
// slow async step (e.g. a server GSAP rewrite) sits between the two records.
|
||||
const coalesceMs = entry.coalesceMs ?? options?.coalesceMs ?? DEFAULT_COALESCE_MS;
|
||||
const maxEntries = options?.maxEntries ?? DEFAULT_MAX_ENTRIES;
|
||||
const previous = state.undo[state.undo.length - 1];
|
||||
let undo = state.undo;
|
||||
|
||||
if (
|
||||
previous &&
|
||||
previous.coalesceKey &&
|
||||
previous.coalesceKey === entry.coalesceKey &&
|
||||
entry.createdAt - previous.createdAt <= coalesceMs
|
||||
) {
|
||||
const files: Record<string, EditHistoryFileSnapshot> = {};
|
||||
for (const [path, snapshot] of Object.entries(entry.files)) {
|
||||
const previousSnapshot = previous.files[path];
|
||||
files[path] = previousSnapshot
|
||||
? {
|
||||
before: previousSnapshot.before,
|
||||
after: snapshot.after,
|
||||
beforeHash: previousSnapshot.beforeHash,
|
||||
afterHash: snapshot.afterHash,
|
||||
}
|
||||
: snapshot;
|
||||
}
|
||||
undo = [...state.undo.slice(0, -1), { ...entry, files }];
|
||||
} else {
|
||||
undo = [...state.undo, entry];
|
||||
}
|
||||
|
||||
return {
|
||||
version: 1,
|
||||
updatedAt: entry.createdAt,
|
||||
undo: undo.slice(Math.max(0, undo.length - maxEntries)),
|
||||
redo: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function canApplyEditHistoryEntry(
|
||||
entry: EditHistoryEntry,
|
||||
direction: EditHistoryDirection,
|
||||
currentHashes: Record<string, string>,
|
||||
): EditHistoryApplyCheck {
|
||||
for (const [path, snapshot] of Object.entries(entry.files)) {
|
||||
const expected = direction === "undo" ? snapshot.afterHash : snapshot.beforeHash;
|
||||
if (currentHashes[path] !== expected) {
|
||||
return { ok: false, reason: "content-mismatch", path };
|
||||
}
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export function undoEditHistory(
|
||||
state: EditHistoryState,
|
||||
currentHashes: Record<string, string>,
|
||||
now: number,
|
||||
): EditHistoryTransitionResult {
|
||||
const entry = state.undo[state.undo.length - 1];
|
||||
if (!entry) return { ok: false, reason: "empty", state, filesToWrite: {} };
|
||||
|
||||
const check = canApplyEditHistoryEntry(entry, "undo", currentHashes);
|
||||
if (!check.ok) {
|
||||
return { ok: false, reason: check.reason, path: check.path, state, filesToWrite: {} };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
entry,
|
||||
filesToWrite: Object.fromEntries(
|
||||
Object.entries(entry.files).map(([path, snapshot]) => [path, snapshot.before]),
|
||||
),
|
||||
state: {
|
||||
version: 1,
|
||||
updatedAt: now,
|
||||
undo: state.undo.slice(0, -1),
|
||||
redo: [...state.redo, entry],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function redoEditHistory(
|
||||
state: EditHistoryState,
|
||||
currentHashes: Record<string, string>,
|
||||
now: number,
|
||||
): EditHistoryTransitionResult {
|
||||
const entry = state.redo[state.redo.length - 1];
|
||||
if (!entry) return { ok: false, reason: "empty", state, filesToWrite: {} };
|
||||
|
||||
const check = canApplyEditHistoryEntry(entry, "redo", currentHashes);
|
||||
if (!check.ok) {
|
||||
return { ok: false, reason: check.reason, path: check.path, state, filesToWrite: {} };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
entry,
|
||||
filesToWrite: Object.fromEntries(
|
||||
Object.entries(entry.files).map(([path, snapshot]) => [path, snapshot.after]),
|
||||
),
|
||||
state: {
|
||||
version: 1,
|
||||
updatedAt: now,
|
||||
undo: [...state.undo, entry],
|
||||
redo: state.redo.slice(0, -1),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { buildEditHistoryEntry, createEmptyEditHistory, pushEditHistoryEntry } from "./editHistory";
|
||||
import {
|
||||
createMemoryEditHistoryStorage,
|
||||
loadEditHistoryState,
|
||||
saveEditHistoryState,
|
||||
} from "./editHistoryStorage";
|
||||
|
||||
describe("edit history storage", () => {
|
||||
let storage: ReturnType<typeof createMemoryEditHistoryStorage>;
|
||||
|
||||
beforeEach(() => {
|
||||
storage = createMemoryEditHistoryStorage();
|
||||
});
|
||||
|
||||
it("returns empty history for projects without persisted state", async () => {
|
||||
const state = await loadEditHistoryState(storage, "project-1");
|
||||
|
||||
expect(state).toEqual(createEmptyEditHistory());
|
||||
});
|
||||
|
||||
it("saves and loads history per project", async () => {
|
||||
const entry = buildEditHistoryEntry({
|
||||
id: "entry-1",
|
||||
projectId: "project-1",
|
||||
label: "Move layer",
|
||||
files: { "index.html": { before: "a", after: "b" } },
|
||||
now: 100,
|
||||
});
|
||||
const state = pushEditHistoryEntry(createEmptyEditHistory(), entry);
|
||||
|
||||
await saveEditHistoryState(storage, "project-1", state);
|
||||
|
||||
expect(await loadEditHistoryState(storage, "project-1")).toEqual(state);
|
||||
expect(await loadEditHistoryState(storage, "project-2")).toEqual(createEmptyEditHistory());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import { createEmptyEditHistory, type EditHistoryState } from "./editHistory";
|
||||
|
||||
export interface EditHistoryStorageAdapter {
|
||||
get(projectId: string): Promise<EditHistoryState | null>;
|
||||
set(projectId: string, state: EditHistoryState): Promise<void>;
|
||||
delete(projectId: string): Promise<void>;
|
||||
}
|
||||
|
||||
const DB_NAME = "hyperframes-studio-edit-history";
|
||||
const DB_VERSION = 1;
|
||||
const STORE_NAME = "project-history";
|
||||
|
||||
export function createMemoryEditHistoryStorage(): EditHistoryStorageAdapter {
|
||||
const states = new Map<string, EditHistoryState>();
|
||||
return {
|
||||
async get(projectId) {
|
||||
return states.get(projectId) ?? null;
|
||||
},
|
||||
async set(projectId, state) {
|
||||
states.set(projectId, structuredClone(state));
|
||||
},
|
||||
async delete(projectId) {
|
||||
states.delete(projectId);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function openEditHistoryDb(): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!globalThis.indexedDB) {
|
||||
reject(new Error("IndexedDB is not available"));
|
||||
return;
|
||||
}
|
||||
|
||||
const request = globalThis.indexedDB.open(DB_NAME, DB_VERSION);
|
||||
request.onupgradeneeded = () => {
|
||||
const db = request.result;
|
||||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||
db.createObjectStore(STORE_NAME);
|
||||
}
|
||||
};
|
||||
request.onerror = () => reject(request.error ?? new Error("Failed to open edit history db"));
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
});
|
||||
}
|
||||
|
||||
function withStore<T>(
|
||||
mode: IDBTransactionMode,
|
||||
callback: (store: IDBObjectStore) => IDBRequest<T>,
|
||||
): Promise<T> {
|
||||
return openEditHistoryDb().then(
|
||||
(db) =>
|
||||
new Promise<T>((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, mode);
|
||||
const request = callback(tx.objectStore(STORE_NAME));
|
||||
request.onerror = () => reject(request.error ?? new Error("IndexedDB request failed"));
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
tx.oncomplete = () => db.close();
|
||||
tx.onerror = () => {
|
||||
db.close();
|
||||
reject(tx.error ?? new Error("IndexedDB transaction failed"));
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function createIndexedDbEditHistoryStorage(): EditHistoryStorageAdapter {
|
||||
return {
|
||||
async get(projectId) {
|
||||
return (
|
||||
(await withStore<EditHistoryState | undefined>("readonly", (store) =>
|
||||
store.get(projectId),
|
||||
)) ?? null
|
||||
);
|
||||
},
|
||||
async set(projectId, state) {
|
||||
await withStore<IDBValidKey>("readwrite", (store) => store.put(state, projectId));
|
||||
},
|
||||
async delete(projectId) {
|
||||
await withStore<undefined>("readwrite", (store) => store.delete(projectId));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadEditHistoryState(
|
||||
storage: EditHistoryStorageAdapter,
|
||||
projectId: string,
|
||||
): Promise<EditHistoryState> {
|
||||
const state = await storage.get(projectId);
|
||||
return state ?? createEmptyEditHistory();
|
||||
}
|
||||
|
||||
export async function saveEditHistoryState(
|
||||
storage: EditHistoryStorageAdapter,
|
||||
projectId: string,
|
||||
state: EditHistoryState,
|
||||
): Promise<void> {
|
||||
await storage.set(projectId, state);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* GSAP access through an ELEMENT'S OWN window (the preview iframe's runtime),
|
||||
* not the studio window. This is the single way studio gesture code touches an
|
||||
* iframe element's GSAP position outside the commit pipeline — the resize
|
||||
* anchor pin (apply + restore) and the post-commit live correction. The commit
|
||||
* pipeline itself stays the owner of persisted values.
|
||||
*/
|
||||
type ElementGsapWindow = Window & {
|
||||
gsap?: {
|
||||
set?: (target: Element, vars: Record<string, number>) => void;
|
||||
getProperty?: (target: Element, prop: string) => unknown;
|
||||
};
|
||||
};
|
||||
|
||||
function gsapOf(element: HTMLElement): ElementGsapWindow["gsap"] | undefined {
|
||||
return (element.ownerDocument.defaultView as ElementGsapWindow | null)?.gsap;
|
||||
}
|
||||
|
||||
/** Set the element's GSAP x/y. Returns false when no runtime is reachable. */
|
||||
export function setElementGsapPosition(element: HTMLElement, x: number, y: number): boolean {
|
||||
const gsap = gsapOf(element);
|
||||
if (!gsap?.set) return false;
|
||||
gsap.set(element, { x, y });
|
||||
return true;
|
||||
}
|
||||
|
||||
/** The element's GSAP numeric property, or null when unreadable. */
|
||||
export function readElementGsapNumber(element: HTMLElement, prop: string): number | null {
|
||||
const value = Number(gsapOf(element)?.getProperty?.(element, prop));
|
||||
return Number.isFinite(value) ? value : null;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { buildFrameCaptureFilename, buildFrameCaptureUrl } from "./frameCapture";
|
||||
|
||||
describe("frame capture utilities", () => {
|
||||
it("builds a PNG capture URL for the master composition", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-04-29T12:00:00Z"));
|
||||
|
||||
expect(
|
||||
buildFrameCaptureUrl({
|
||||
projectId: "demo project",
|
||||
compositionPath: null,
|
||||
currentTime: 1.23456,
|
||||
origin: "http://localhost:5194",
|
||||
}),
|
||||
).toBe(
|
||||
"http://localhost:5194/api/projects/demo%20project/thumbnail/index.html?t=1.235&format=png&v=1777464000000",
|
||||
);
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("builds a safe filename from a nested composition path", () => {
|
||||
expect(buildFrameCaptureFilename("compositions/intro.html", 2.5)).toBe("intro-2-500s.png");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { buildProjectApiPath } from "./projectRouting";
|
||||
|
||||
export interface FrameCaptureRequest {
|
||||
projectId: string;
|
||||
compositionPath: string | null;
|
||||
currentTime: number;
|
||||
origin?: string;
|
||||
}
|
||||
|
||||
function normalizeCompositionPath(compositionPath: string | null): string {
|
||||
return compositionPath && compositionPath !== "master" ? compositionPath : "index.html";
|
||||
}
|
||||
|
||||
export function buildFrameCaptureUrl({
|
||||
projectId,
|
||||
compositionPath,
|
||||
currentTime,
|
||||
origin = window.location.origin,
|
||||
}: FrameCaptureRequest): string {
|
||||
const compPath = normalizeCompositionPath(compositionPath);
|
||||
const url = new URL(
|
||||
buildProjectApiPath(projectId, `/thumbnail/${encodeURIComponent(compPath)}`),
|
||||
origin,
|
||||
);
|
||||
url.searchParams.set("t", Math.max(0, currentTime).toFixed(3));
|
||||
url.searchParams.set("format", "png");
|
||||
url.searchParams.set("v", String(Date.now()));
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
export function buildFrameCaptureFilename(compositionPath: string | null, currentTime: number) {
|
||||
const compPath = normalizeCompositionPath(compositionPath);
|
||||
const base =
|
||||
compPath
|
||||
.split("/")
|
||||
.pop()
|
||||
?.replace(/\.html$/i, "") || "frame";
|
||||
const frameTime = Math.max(0, currentTime).toFixed(3).replace(".", "-");
|
||||
return `${base}-${frameTime}s.png`;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
// ponytail: crypto.randomUUID is undefined on plain HTTP non-localhost origins
|
||||
export function generateId(): string {
|
||||
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { smoothGestureKeyframes } from "./gestureSmoother";
|
||||
|
||||
describe("smoothGestureKeyframes", () => {
|
||||
it("returns input unchanged for ≤2 keyframes", () => {
|
||||
const kfs = [
|
||||
{ percentage: 0, properties: { x: 0, y: 0 } },
|
||||
{ percentage: 100, properties: { x: 100, y: 100 } },
|
||||
];
|
||||
expect(smoothGestureKeyframes(kfs, 3)).toEqual(kfs);
|
||||
});
|
||||
|
||||
it("pins first and last keyframes", () => {
|
||||
const kfs = [
|
||||
{ percentage: 0, properties: { x: 0 } },
|
||||
{ percentage: 50, properties: { x: 999 } },
|
||||
{ percentage: 100, properties: { x: 200 } },
|
||||
];
|
||||
const result = smoothGestureKeyframes(kfs, 3);
|
||||
expect(result[0].properties.x).toBe(0);
|
||||
expect(result[result.length - 1].properties.x).toBe(200);
|
||||
});
|
||||
|
||||
it("smooths a zigzag into a gentler curve", () => {
|
||||
const kfs = [
|
||||
{ percentage: 0, properties: { x: 0 } },
|
||||
{ percentage: 25, properties: { x: 100 } },
|
||||
{ percentage: 50, properties: { x: 0 } },
|
||||
{ percentage: 75, properties: { x: 100 } },
|
||||
{ percentage: 100, properties: { x: 0 } },
|
||||
];
|
||||
const result = smoothGestureKeyframes(kfs, 2);
|
||||
const mid = result[2].properties.x as number;
|
||||
// The sharp 0→100→0 zigzag should be softened — mid should be
|
||||
// pulled toward the neighbors, not stay at exactly 0.
|
||||
expect(mid).toBeGreaterThan(0);
|
||||
expect(mid).toBeLessThan(100);
|
||||
});
|
||||
|
||||
it("returns input unchanged with radius 0", () => {
|
||||
const kfs = [
|
||||
{ percentage: 0, properties: { x: 0 } },
|
||||
{ percentage: 50, properties: { x: 999 } },
|
||||
{ percentage: 100, properties: { x: 0 } },
|
||||
];
|
||||
expect(smoothGestureKeyframes(kfs, 0)).toEqual(kfs);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
// ponytail: Gaussian-weighted moving average over gesture keyframes.
|
||||
// Rounds off jittery corners from raw pointer input while preserving
|
||||
// overall path shape. First/last keyframes are pinned (never moved).
|
||||
// Upgrade path: Catmull-Rom spline if users need curve-fitted paths.
|
||||
|
||||
interface Keyframe {
|
||||
percentage: number;
|
||||
properties: Record<string, number | string>;
|
||||
}
|
||||
|
||||
function gaussianWeight(distance: number, sigma: number): number {
|
||||
return Math.exp(-(distance * distance) / (2 * sigma * sigma));
|
||||
}
|
||||
|
||||
export function smoothGestureKeyframes(keyframes: Keyframe[], radius: number): Keyframe[] {
|
||||
if (keyframes.length <= 2 || radius <= 0) return keyframes;
|
||||
const sigma = radius / 2;
|
||||
const numericKeys = new Set<string>();
|
||||
for (const kf of keyframes) {
|
||||
for (const [k, v] of Object.entries(kf.properties)) {
|
||||
if (typeof v === "number") numericKeys.add(k);
|
||||
}
|
||||
}
|
||||
if (numericKeys.size === 0) return keyframes;
|
||||
|
||||
return keyframes.map((kf, i) => {
|
||||
if (i === 0 || i === keyframes.length - 1) return kf;
|
||||
const smoothed: Record<string, number | string> = { ...kf.properties };
|
||||
for (const key of numericKeys) {
|
||||
let weightSum = 0;
|
||||
let valueSum = 0;
|
||||
for (let j = Math.max(0, i - radius); j <= Math.min(keyframes.length - 1, i + radius); j++) {
|
||||
const v = keyframes[j].properties[key];
|
||||
if (typeof v !== "number") continue;
|
||||
// Weight by index distance, not time. Samples here are roughly evenly
|
||||
// spaced, so for the small radius (3) this is fine; switch to a
|
||||
// percentage-domain distance if the window ever grows much larger.
|
||||
const w = gaussianWeight(j - i, sigma);
|
||||
weightSum += w;
|
||||
valueSum += v * w;
|
||||
}
|
||||
if (weightSum > 0) smoothed[key] = Math.round((valueSum / weightSum) * 1000) / 1000;
|
||||
}
|
||||
return { percentage: kf.percentage, properties: smoothed };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
absoluteToPercentage,
|
||||
absoluteToPercentageForAnimation,
|
||||
findTweenAtTime,
|
||||
isTimeWithinTween,
|
||||
percentageToAbsolute,
|
||||
percentageToAbsoluteForAnimation,
|
||||
resolveTweenDuration,
|
||||
resolveTweenStart,
|
||||
} from "./globalTimeCompiler";
|
||||
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
||||
|
||||
function makeAnim(overrides: Partial<GsapAnimation> = {}): GsapAnimation {
|
||||
return {
|
||||
id: "#el-to-0",
|
||||
targetSelector: "#el",
|
||||
method: "to",
|
||||
position: 0,
|
||||
properties: { x: 100 },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("absoluteToPercentage", () => {
|
||||
test("mid-point of a tween", () => {
|
||||
expect(absoluteToPercentage(0.5, 0, 2)).toBe(25);
|
||||
});
|
||||
|
||||
test("tween with offset start", () => {
|
||||
expect(absoluteToPercentage(1.0, 0.5, 1)).toBe(50);
|
||||
});
|
||||
|
||||
test("clamps below tween start to 0%", () => {
|
||||
expect(absoluteToPercentage(-1, 0, 2)).toBe(0);
|
||||
});
|
||||
|
||||
test("clamps past tween end to 100%", () => {
|
||||
expect(absoluteToPercentage(5, 0, 2)).toBe(100);
|
||||
});
|
||||
|
||||
test("zero duration returns 0", () => {
|
||||
expect(absoluteToPercentage(1, 0, 0)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("percentageToAbsolute", () => {
|
||||
test("converts percentage back to absolute time", () => {
|
||||
expect(percentageToAbsolute(50, 0.5, 1)).toBe(1.0);
|
||||
});
|
||||
|
||||
test("0% returns tween start", () => {
|
||||
expect(percentageToAbsolute(0, 2, 3)).toBe(2);
|
||||
});
|
||||
|
||||
test("100% returns tween end", () => {
|
||||
expect(percentageToAbsolute(100, 2, 3)).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isTimeWithinTween", () => {
|
||||
test("time inside returns true", () => {
|
||||
expect(isTimeWithinTween(0.5, 0, 2)).toBe(true);
|
||||
});
|
||||
|
||||
test("time at start returns true", () => {
|
||||
expect(isTimeWithinTween(0, 0, 2)).toBe(true);
|
||||
});
|
||||
|
||||
test("time at end returns true", () => {
|
||||
expect(isTimeWithinTween(2, 0, 2)).toBe(true);
|
||||
});
|
||||
|
||||
test("time before returns false", () => {
|
||||
expect(isTimeWithinTween(-0.1, 0, 2)).toBe(false);
|
||||
});
|
||||
|
||||
test("time after returns false", () => {
|
||||
expect(isTimeWithinTween(2.1, 0, 2)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveTweenStart", () => {
|
||||
test("numeric position", () => {
|
||||
expect(resolveTweenStart(makeAnim({ position: 1.5 }))).toBe(1.5);
|
||||
});
|
||||
|
||||
test("parseable string position", () => {
|
||||
expect(resolveTweenStart(makeAnim({ position: "2.5" }))).toBe(2.5);
|
||||
});
|
||||
|
||||
test("unparseable string position returns null", () => {
|
||||
expect(resolveTweenStart(makeAnim({ position: "myLabel" }))).toBeNull();
|
||||
});
|
||||
|
||||
test("relative position +=0.5 returns null", () => {
|
||||
expect(resolveTweenStart(makeAnim({ position: "+=0.5" }))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveTweenDuration", () => {
|
||||
test("explicit duration", () => {
|
||||
expect(resolveTweenDuration(makeAnim({ duration: 2 }))).toBe(2);
|
||||
});
|
||||
|
||||
test("missing duration defaults to GSAP default (0.5)", () => {
|
||||
expect(resolveTweenDuration(makeAnim({ duration: undefined }))).toBe(0.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findTweenAtTime", () => {
|
||||
const anims = [
|
||||
makeAnim({ id: "#el-to-0", position: 0, duration: 0.5 }),
|
||||
makeAnim({ id: "#el-to-1", position: 1, duration: 1 }),
|
||||
makeAnim({
|
||||
id: "#other-to-0",
|
||||
targetSelector: "#other",
|
||||
position: 0,
|
||||
duration: 2,
|
||||
}),
|
||||
];
|
||||
|
||||
test("finds tween at time within range", () => {
|
||||
expect(findTweenAtTime(0.3, anims, "#el")?.id).toBe("#el-to-0");
|
||||
});
|
||||
|
||||
test("finds second tween", () => {
|
||||
expect(findTweenAtTime(1.5, anims, "#el")?.id).toBe("#el-to-1");
|
||||
});
|
||||
|
||||
test("returns null for gap between tweens", () => {
|
||||
expect(findTweenAtTime(0.7, anims, "#el")).toBeNull();
|
||||
});
|
||||
|
||||
test("filters by selector", () => {
|
||||
expect(findTweenAtTime(0.3, anims, "#other")?.id).toBe("#other-to-0");
|
||||
});
|
||||
|
||||
test("returns null for unmatched selector", () => {
|
||||
expect(findTweenAtTime(0.3, anims, "#missing")).toBeNull();
|
||||
});
|
||||
|
||||
test("skips tweens with unresolvable string positions", () => {
|
||||
const withLabel = [makeAnim({ id: "#el-to-0", position: "myLabel", duration: 1 })];
|
||||
expect(findTweenAtTime(0.5, withLabel, "#el")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("animation-level helpers", () => {
|
||||
const anim = makeAnim({ position: 0.5, duration: 2 });
|
||||
|
||||
test("absoluteToPercentageForAnimation", () => {
|
||||
expect(absoluteToPercentageForAnimation(1.5, anim)).toBe(50);
|
||||
});
|
||||
|
||||
test("absoluteToPercentageForAnimation returns null for string position", () => {
|
||||
const labelAnim = makeAnim({ position: "label" });
|
||||
expect(absoluteToPercentageForAnimation(0.5, labelAnim)).toBeNull();
|
||||
});
|
||||
|
||||
test("percentageToAbsoluteForAnimation", () => {
|
||||
expect(percentageToAbsoluteForAnimation(50, anim)).toBe(1.5);
|
||||
});
|
||||
|
||||
test("percentageToAbsoluteForAnimation returns null for string position", () => {
|
||||
const labelAnim = makeAnim({ position: "+=1" });
|
||||
expect(percentageToAbsoluteForAnimation(50, labelAnim)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
||||
|
||||
export function absoluteToPercentage(
|
||||
time: number,
|
||||
tweenStart: number,
|
||||
tweenDuration: number,
|
||||
): number {
|
||||
if (tweenDuration <= 0) return 0;
|
||||
const raw = ((time - tweenStart) / tweenDuration) * 100;
|
||||
return Math.max(0, Math.min(100, Math.round(raw * 10) / 10));
|
||||
}
|
||||
|
||||
export function percentageToAbsolute(
|
||||
pct: number,
|
||||
tweenStart: number,
|
||||
tweenDuration: number,
|
||||
): number {
|
||||
return tweenStart + (pct / 100) * tweenDuration;
|
||||
}
|
||||
|
||||
export function isTimeWithinTween(
|
||||
time: number,
|
||||
tweenStart: number,
|
||||
tweenDuration: number,
|
||||
): boolean {
|
||||
return time >= tweenStart && time <= tweenStart + tweenDuration;
|
||||
}
|
||||
|
||||
export function resolveTweenStart(animation: GsapAnimation): number | null {
|
||||
if (animation.resolvedStart != null) return animation.resolvedStart;
|
||||
if (typeof animation.position === "number") return animation.position;
|
||||
const parsed = Number.parseFloat(animation.position as string);
|
||||
if (!Number.isNaN(parsed)) return parsed;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolveTweenDuration(animation: GsapAnimation): number {
|
||||
return animation.duration ?? 0.5;
|
||||
}
|
||||
|
||||
export function findTweenAtTime(
|
||||
time: number,
|
||||
animations: GsapAnimation[],
|
||||
selector: string,
|
||||
): GsapAnimation | null {
|
||||
for (const anim of animations) {
|
||||
if (!matchesSelector(anim.targetSelector, selector)) continue;
|
||||
const start = resolveTweenStart(anim);
|
||||
if (start === null) continue;
|
||||
const duration = resolveTweenDuration(anim);
|
||||
if (isTimeWithinTween(time, start, duration)) return anim;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function absoluteToPercentageForAnimation(
|
||||
time: number,
|
||||
animation: GsapAnimation,
|
||||
): number | null {
|
||||
const start = resolveTweenStart(animation);
|
||||
if (start === null) return null;
|
||||
const duration = resolveTweenDuration(animation);
|
||||
return absoluteToPercentage(time, start, duration);
|
||||
}
|
||||
|
||||
export function percentageToAbsoluteForAnimation(
|
||||
pct: number,
|
||||
animation: GsapAnimation,
|
||||
): number | null {
|
||||
const start = resolveTweenStart(animation);
|
||||
if (start === null) return null;
|
||||
const duration = resolveTweenDuration(animation);
|
||||
return percentageToAbsolute(pct, start, duration);
|
||||
}
|
||||
|
||||
function matchesSelector(tweenSelector: string, querySelector: string): boolean {
|
||||
return tweenSelector.split(",").some((part) => part.trim() === querySelector);
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { applySoftReload, ensureMotionPathPluginLoaded } from "./gsapSoftReload";
|
||||
|
||||
const SCRIPT_TEXT = `
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#box", { opacity: 0.8 });
|
||||
window.__timelines["root"] = tl;
|
||||
`;
|
||||
|
||||
const MOTION_PATH_SCRIPT_TEXT = `
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#box", { motionPath: { path: [{ x: 0, y: 0 }, { x: 100, y: 50 }] } });
|
||||
window.__timelines["root"] = tl;
|
||||
`;
|
||||
|
||||
function buildMockIframe(overrides: Record<string, unknown> = {}) {
|
||||
const scriptEl = document.createElement("script");
|
||||
scriptEl.textContent =
|
||||
'const tl = gsap.timeline({ paused: true }); tl.to("#box", { opacity: 0.5 });';
|
||||
const container = document.createElement("div");
|
||||
container.appendChild(scriptEl);
|
||||
|
||||
const mockTimeline = { kill: vi.fn(), pause: vi.fn() };
|
||||
const contentWindow = {
|
||||
gsap: { timeline: vi.fn() },
|
||||
__hfForceTimelineRebind: vi.fn(),
|
||||
__timelines: { root: mockTimeline } as Record<string, typeof mockTimeline>,
|
||||
__player: { getTime: () => 2.0, seek: vi.fn() },
|
||||
__hfStudioManualEditsApply: vi.fn(),
|
||||
__hfSuppressSceneMutations: undefined as undefined | (<T>(fn: () => T) => T),
|
||||
...overrides,
|
||||
};
|
||||
|
||||
// Intercept appendChild: when a <script> is appended, simulate execution by
|
||||
// repopulating __timelines (mimicking what the real GSAP script would do).
|
||||
const realAppendChild = container.appendChild.bind(container);
|
||||
container.appendChild = <T extends Node>(node: T): T => {
|
||||
const result = realAppendChild(node);
|
||||
if (node instanceof HTMLScriptElement && node.textContent?.includes("gsap.timeline")) {
|
||||
// Simulate the script populating __timelines
|
||||
const cw = contentWindow as { __timelines?: Record<string, unknown> };
|
||||
if (cw.__timelines) {
|
||||
cw.__timelines.root = { kill: vi.fn(), pause: vi.fn() };
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const contentDocument = {
|
||||
querySelectorAll: (sel: string) => (sel === "script:not([src])" ? [scriptEl] : []),
|
||||
createElement: (tag: string) => document.createElement(tag),
|
||||
body: container,
|
||||
head: document.createElement("div"),
|
||||
};
|
||||
|
||||
return {
|
||||
iframe: { contentWindow, contentDocument } as unknown as HTMLIFrameElement,
|
||||
contentWindow,
|
||||
mockTimeline,
|
||||
};
|
||||
}
|
||||
|
||||
describe("applySoftReload", () => {
|
||||
it('returns "cannot-soft-reload" when iframe is null', () => {
|
||||
expect(applySoftReload(null, SCRIPT_TEXT)).toBe("cannot-soft-reload");
|
||||
});
|
||||
|
||||
it('returns "cannot-soft-reload" when scriptText is empty', () => {
|
||||
const { iframe } = buildMockIframe();
|
||||
expect(applySoftReload(iframe, "")).toBe("cannot-soft-reload");
|
||||
});
|
||||
|
||||
it('returns "cannot-soft-reload" when gsap is not on iframe window', () => {
|
||||
const { iframe } = buildMockIframe({ gsap: undefined });
|
||||
expect(applySoftReload(iframe, SCRIPT_TEXT)).toBe("cannot-soft-reload");
|
||||
});
|
||||
|
||||
it('returns "cannot-soft-reload" when __hfForceTimelineRebind is missing', () => {
|
||||
const { iframe } = buildMockIframe({ __hfForceTimelineRebind: undefined });
|
||||
expect(applySoftReload(iframe, SCRIPT_TEXT)).toBe("cannot-soft-reload");
|
||||
});
|
||||
|
||||
it('returns "cannot-soft-reload" when the script registers no scopable key', () => {
|
||||
// No __timelines["key"] pattern → targetKeys is empty → can't scope safely.
|
||||
const { iframe } = buildMockIframe();
|
||||
expect(applySoftReload(iframe, 'gsap.to("#box", { x: 1 });')).toBe("cannot-soft-reload");
|
||||
});
|
||||
|
||||
it("kills existing timelines, rebinds, and re-seeks on success", () => {
|
||||
const { iframe, contentWindow, mockTimeline } = buildMockIframe();
|
||||
const result = applySoftReload(iframe, SCRIPT_TEXT);
|
||||
expect(result).toBe("applied");
|
||||
expect(mockTimeline.kill).toHaveBeenCalled();
|
||||
expect(contentWindow.__hfForceTimelineRebind).toHaveBeenCalled();
|
||||
expect(contentWindow.__player.seek).toHaveBeenCalledWith(2.0);
|
||||
expect(contentWindow.__hfStudioManualEditsApply).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("seeks to the caller-supplied currentTime override instead of the iframe's own __player.getTime()", () => {
|
||||
// Regression: the iframe's raw __player.getTime() (2.0 here, per the mock)
|
||||
// can desync from the studio's authoritative scrub position — e.g. a
|
||||
// keyframe-node drag parks the playhead via the store before this reload's
|
||||
// async commit resolves. The rebuilt timeline must re-seek to the caller's
|
||||
// value, not the iframe's possibly-stale one.
|
||||
const { iframe, contentWindow } = buildMockIframe();
|
||||
const result = applySoftReload(iframe, SCRIPT_TEXT, { currentTimeOverride: 0 });
|
||||
expect(result).toBe("applied");
|
||||
expect(contentWindow.__player.seek).toHaveBeenCalledWith(0);
|
||||
});
|
||||
|
||||
it("strips a stale inline transform from an orphaned (non-timeline-child) element", () => {
|
||||
// Repro: an element dragged via gsap.set whose keyframes were then removed is
|
||||
// no longer a timeline child, so the timeline-children sweep misses it. Its
|
||||
// stale inline transform must still be cleared so it snaps back to its source
|
||||
// (overlay) position instead of rendering offset.
|
||||
const orphan = document.createElement("div");
|
||||
orphan.style.cssText = "left: 1240px; top: 200px; transform: translate(449px, 0px)";
|
||||
Object.assign(orphan, { _gsap: {} }); // GSAP cache marker (set by gsap.set)
|
||||
|
||||
const scriptEl = document.createElement("script");
|
||||
scriptEl.textContent = 'const tl = gsap.timeline({ paused: true }); tl.to("#x", { x: 1 });';
|
||||
const container = document.createElement("div");
|
||||
container.appendChild(scriptEl);
|
||||
|
||||
const { iframe } = buildMockIframe({ gsap: { timeline: vi.fn(), set: vi.fn() } });
|
||||
(iframe as unknown as { contentDocument: unknown }).contentDocument = {
|
||||
querySelectorAll: (sel: string) =>
|
||||
sel === "script:not([src])" ? [scriptEl] : sel === "[style*='transform']" ? [orphan] : [],
|
||||
createElement: (tag: string) => document.createElement(tag),
|
||||
body: container,
|
||||
head: document.createElement("div"),
|
||||
};
|
||||
|
||||
applySoftReload(iframe, SCRIPT_TEXT);
|
||||
|
||||
expect(orphan.style.transform).toBe(""); // stale GSAP transform stripped
|
||||
expect(orphan.style.left).toBe("1240px"); // authored CSS base preserved
|
||||
});
|
||||
|
||||
it("wraps execution in __hfSuppressSceneMutations when available", () => {
|
||||
let suppressionCalled = false;
|
||||
const { iframe } = buildMockIframe({
|
||||
__hfSuppressSceneMutations: <T>(fn: () => T): T => {
|
||||
suppressionCalled = true;
|
||||
return fn();
|
||||
},
|
||||
});
|
||||
const result = applySoftReload(iframe, SCRIPT_TEXT);
|
||||
expect(result).toBe("applied");
|
||||
expect(suppressionCalled).toBe(true);
|
||||
});
|
||||
|
||||
it('returns "applied" when the re-run re-registers the script\'s expected key', () => {
|
||||
// SCRIPT_TEXT registers __timelines["root"]; buildMockIframe's appendChild
|
||||
// shim repopulates `root` on execution. The hardened verify checks the
|
||||
// expected target key is present (not merely "some key"), so a correct re-run
|
||||
// reliably reports "applied" — it doesn't spuriously hit the transient window.
|
||||
const { iframe, contentWindow } = buildMockIframe();
|
||||
expect(applySoftReload(iframe, SCRIPT_TEXT)).toBe("applied");
|
||||
expect(contentWindow.__timelines.root).toBeDefined();
|
||||
});
|
||||
|
||||
it('returns "verify-failed" (transient) when the re-run leaves the key empty', () => {
|
||||
// No appendChild shim repopulation: the body container has no shim, so the
|
||||
// re-run kills __timelines["root"] and the new script doesn't re-register it.
|
||||
// That is the TRANSIENT post-run window — surfaced as "verify-failed" so
|
||||
// callers know NOT to escalate (the live gsap.set already shows the value).
|
||||
const scriptEl = document.createElement("script");
|
||||
scriptEl.textContent = 'window.__timelines["root"] = gsap.timeline();';
|
||||
const container = document.createElement("div"); // no appendChild shim
|
||||
container.appendChild(scriptEl);
|
||||
const { iframe } = buildMockIframe();
|
||||
(iframe as unknown as { contentDocument: unknown }).contentDocument = {
|
||||
querySelectorAll: (sel: string) => (sel === "script:not([src])" ? [scriptEl] : []),
|
||||
createElement: (tag: string) => document.createElement(tag),
|
||||
body: container,
|
||||
head: document.createElement("div"),
|
||||
};
|
||||
expect(applySoftReload(iframe, SCRIPT_TEXT)).toBe("verify-failed");
|
||||
});
|
||||
|
||||
it("editing composition A leaves composition B's timeline intact (scoped kill)", () => {
|
||||
// Two comps live side by side; the soft reload only re-runs comp "root".
|
||||
// Comp "subscene" must survive untouched — the regression the full remount
|
||||
// (re-inline) used to cause.
|
||||
const subsceneTimeline = { kill: vi.fn(), pause: vi.fn() };
|
||||
const { iframe, contentWindow, mockTimeline } = buildMockIframe({
|
||||
__timelines: {
|
||||
root: { kill: vi.fn(), pause: vi.fn() },
|
||||
subscene: subsceneTimeline,
|
||||
} as Record<string, { kill: ReturnType<typeof vi.fn>; pause: ReturnType<typeof vi.fn> }>,
|
||||
});
|
||||
void mockTimeline;
|
||||
|
||||
expect(applySoftReload(iframe, SCRIPT_TEXT)).toBe("applied");
|
||||
// Comp B was never killed and is still registered.
|
||||
expect(subsceneTimeline.kill).not.toHaveBeenCalled();
|
||||
expect(contentWindow.__timelines.subscene).toBe(subsceneTimeline);
|
||||
});
|
||||
|
||||
it("runs synchronously (no async plugin load) when MotionPathPlugin is already present", () => {
|
||||
// The preview bootstrap pre-loads MotionPathPlugin, so win.MotionPathPlugin
|
||||
// is set before any motion-path edit. The soft reload must then execute the
|
||||
// script inline — no CDN <script> appended to <head>, the timeline is
|
||||
// repopulated synchronously, and verifyTimelinesPopulated reports the real
|
||||
// result (not the optimistic-true async path).
|
||||
const headAppends: Node[] = [];
|
||||
const head = document.createElement("div");
|
||||
const realHeadAppend = head.appendChild.bind(head);
|
||||
head.appendChild = <T extends Node>(node: T): T => {
|
||||
headAppends.push(node);
|
||||
return realHeadAppend(node);
|
||||
};
|
||||
const { iframe, contentWindow } = buildMockIframe({ MotionPathPlugin: {} });
|
||||
(iframe.contentDocument as unknown as { head: unknown }).head = head;
|
||||
|
||||
const result = applySoftReload(iframe, MOTION_PATH_SCRIPT_TEXT);
|
||||
|
||||
expect(result).toBe("applied");
|
||||
// No CDN plugin <script> was appended to <head> — ran inline.
|
||||
expect(headAppends.filter((n) => n instanceof HTMLScriptElement)).toHaveLength(0);
|
||||
expect(contentWindow.__hfForceTimelineRebind).toHaveBeenCalled();
|
||||
expect(contentWindow.__player.seek).toHaveBeenCalledWith(2.0);
|
||||
expect(contentWindow.__timelines.root).toBeDefined();
|
||||
});
|
||||
|
||||
it("falls back to the async plugin load when MotionPathPlugin is genuinely absent", () => {
|
||||
const head = document.createElement("div");
|
||||
const appendedScripts: HTMLScriptElement[] = [];
|
||||
const realHeadAppend = head.appendChild.bind(head);
|
||||
head.appendChild = <T extends Node>(node: T): T => {
|
||||
if (node instanceof HTMLScriptElement) appendedScripts.push(node);
|
||||
return realHeadAppend(node);
|
||||
};
|
||||
// gsap present but MotionPathPlugin unset → async load path.
|
||||
const { iframe, contentWindow } = buildMockIframe({
|
||||
MotionPathPlugin: undefined,
|
||||
gsap: { timeline: vi.fn(), registerPlugin: vi.fn() },
|
||||
});
|
||||
(iframe.contentDocument as unknown as { head: unknown }).head = head;
|
||||
|
||||
const onAsyncFailure = vi.fn();
|
||||
const result = applySoftReload(iframe, MOTION_PATH_SCRIPT_TEXT, { onAsyncFailure });
|
||||
|
||||
// Optimistically "applied" (script will run once the plugin loads) — and the
|
||||
// script has NOT executed yet, so the timeline isn't rebound synchronously.
|
||||
expect(result).toBe("applied");
|
||||
expect(appendedScripts).toHaveLength(1);
|
||||
expect(appendedScripts[0]!.src).toContain("MotionPathPlugin");
|
||||
expect(contentWindow.__hfForceTimelineRebind).not.toHaveBeenCalled();
|
||||
|
||||
// onerror must NOT run the script (that would reference a missing plugin) —
|
||||
// it escalates via onAsyncFailure so the caller can full-reload to recover,
|
||||
// and clears the in-flight loading flag.
|
||||
appendedScripts[0]!.onerror?.(new Event("error"));
|
||||
expect(onAsyncFailure).toHaveBeenCalledTimes(1);
|
||||
expect(contentWindow.__hfForceTimelineRebind).not.toHaveBeenCalled();
|
||||
expect(contentWindow.__hfMotionPathPluginLoading).toBe(false);
|
||||
});
|
||||
|
||||
it('returns "cannot-soft-reload" when multiple GSAP scripts exist (ambiguous)', () => {
|
||||
const script1 = document.createElement("script");
|
||||
script1.textContent = "const tl = gsap.timeline({ paused: true });";
|
||||
const script2 = document.createElement("script");
|
||||
script2.textContent = 'tl.to("#other", { x: 10 });';
|
||||
const container = document.createElement("div");
|
||||
container.appendChild(script1);
|
||||
container.appendChild(script2);
|
||||
|
||||
const { iframe } = buildMockIframe();
|
||||
(iframe as unknown as { contentDocument: unknown }).contentDocument = {
|
||||
querySelectorAll: (sel: string) => (sel === "script:not([src])" ? [script1, script2] : []),
|
||||
createElement: (tag: string) => document.createElement(tag),
|
||||
body: container,
|
||||
};
|
||||
// Multiple scripts, none registering "root" → can't identify what to replace
|
||||
// → structural failure that genuinely needs a full reload.
|
||||
expect(applySoftReload(iframe, SCRIPT_TEXT)).toBe("cannot-soft-reload");
|
||||
});
|
||||
});
|
||||
|
||||
function buildBootstrapIframe(overrides: Record<string, unknown> = {}) {
|
||||
const head = document.createElement("div");
|
||||
const appendedScripts: HTMLScriptElement[] = [];
|
||||
const realHeadAppend = head.appendChild.bind(head);
|
||||
head.appendChild = <T extends Node>(node: T): T => {
|
||||
if (node instanceof HTMLScriptElement) appendedScripts.push(node);
|
||||
return realHeadAppend(node);
|
||||
};
|
||||
|
||||
const registerPlugin = vi.fn();
|
||||
const contentWindow = {
|
||||
gsap: { registerPlugin } as Record<string, unknown> | undefined,
|
||||
MotionPathPlugin: undefined as unknown,
|
||||
__hfMotionPathPluginLoading: undefined as boolean | undefined,
|
||||
...overrides,
|
||||
};
|
||||
const contentDocument = {
|
||||
createElement: (tag: string) => document.createElement(tag),
|
||||
head,
|
||||
};
|
||||
return {
|
||||
iframe: { contentWindow, contentDocument } as unknown as HTMLIFrameElement,
|
||||
contentWindow,
|
||||
appendedScripts,
|
||||
registerPlugin,
|
||||
};
|
||||
}
|
||||
|
||||
describe("ensureMotionPathPluginLoaded", () => {
|
||||
it("no-ops when the iframe is null", () => {
|
||||
expect(() => ensureMotionPathPluginLoaded(null)).not.toThrow();
|
||||
});
|
||||
|
||||
it("no-ops when gsap is unavailable", () => {
|
||||
const { iframe, appendedScripts } = buildBootstrapIframe({ gsap: undefined });
|
||||
ensureMotionPathPluginLoaded(iframe);
|
||||
expect(appendedScripts).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("appends the plugin script once and registers it on load", () => {
|
||||
const { iframe, contentWindow, appendedScripts, registerPlugin } = buildBootstrapIframe();
|
||||
ensureMotionPathPluginLoaded(iframe);
|
||||
expect(appendedScripts).toHaveLength(1);
|
||||
expect(appendedScripts[0]!.src).toContain("MotionPathPlugin");
|
||||
expect(contentWindow.__hfMotionPathPluginLoading).toBe(true);
|
||||
|
||||
// Simulate the CDN load completing; the plugin is now present.
|
||||
contentWindow.MotionPathPlugin = {};
|
||||
appendedScripts[0]!.onload?.(new Event("load"));
|
||||
expect(registerPlugin).toHaveBeenCalledWith(contentWindow.MotionPathPlugin);
|
||||
expect(contentWindow.__hfMotionPathPluginLoading).toBe(false);
|
||||
});
|
||||
|
||||
it("is idempotent: a second call while loading does not append a second script", () => {
|
||||
const { iframe, appendedScripts } = buildBootstrapIframe();
|
||||
ensureMotionPathPluginLoaded(iframe);
|
||||
ensureMotionPathPluginLoaded(iframe);
|
||||
expect(appendedScripts).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("registers an already-present plugin without appending a script", () => {
|
||||
const plugin = {};
|
||||
const { iframe, appendedScripts, registerPlugin } = buildBootstrapIframe({
|
||||
MotionPathPlugin: plugin,
|
||||
});
|
||||
ensureMotionPathPluginLoaded(iframe);
|
||||
expect(appendedScripts).toHaveLength(0);
|
||||
expect(registerPlugin).toHaveBeenCalledWith(plugin);
|
||||
});
|
||||
|
||||
it("clears the loading flag and still resolves when the CDN load errors", () => {
|
||||
const { iframe, contentWindow, appendedScripts } = buildBootstrapIframe();
|
||||
ensureMotionPathPluginLoaded(iframe);
|
||||
appendedScripts[0]!.onerror?.(new Event("error"));
|
||||
expect(contentWindow.__hfMotionPathPluginLoading).toBe(false);
|
||||
// A subsequent call can retry (plugin still absent, flag cleared).
|
||||
ensureMotionPathPluginLoaded(iframe);
|
||||
expect(appendedScripts).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
// The authored-opacity restore: before the script re-runs (and its tweens
|
||||
// re-capture bounds), every animated element's inline opacity must be put back
|
||||
// to its AUTHORED value — from the after-write file HTML when provided, else
|
||||
// from the parse-time stamp. Otherwise a runtime transient (the color-grading
|
||||
// hide's 0, a mid-flight tween value) becomes a permanent tween bound.
|
||||
describe("applySoftReload authored-opacity restore", () => {
|
||||
function buildIframeWithTarget(el: HTMLElement, overrides: Record<string, unknown> = {}) {
|
||||
const scriptEl = document.createElement("script");
|
||||
scriptEl.textContent =
|
||||
'const tl = gsap.timeline({ paused: true }); tl.to("#box", { opacity: 0.5 });';
|
||||
const tl = {
|
||||
kill: vi.fn(),
|
||||
pause: vi.fn(),
|
||||
getChildren: () => [{ targets: () => [el] }],
|
||||
};
|
||||
const contentWindow = {
|
||||
gsap: { timeline: vi.fn(), set: vi.fn() },
|
||||
__hfForceTimelineRebind: vi.fn(),
|
||||
__timelines: { root: tl } as Record<string, unknown>,
|
||||
__player: { getTime: () => 2.0, seek: vi.fn() },
|
||||
__hfStudioManualEditsApply: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
const container = document.createElement("div");
|
||||
container.appendChild(scriptEl);
|
||||
// Intercept only POST-SETUP appends: simulate the re-run script
|
||||
// repopulating __timelines (as in buildMockIframe).
|
||||
const realAppendChild = container.appendChild.bind(container);
|
||||
container.appendChild = <T extends Node>(node: T): T => {
|
||||
const result = realAppendChild(node);
|
||||
if (node instanceof HTMLScriptElement && node.textContent?.includes("gsap.timeline")) {
|
||||
contentWindow.__timelines.root = { kill: vi.fn(), pause: vi.fn() };
|
||||
}
|
||||
return result;
|
||||
};
|
||||
const contentDocument = {
|
||||
querySelectorAll: (sel: string) => (sel === "script:not([src])" ? [scriptEl] : []),
|
||||
createElement: (tag: string) => document.createElement(tag),
|
||||
body: container,
|
||||
head: document.createElement("div"),
|
||||
};
|
||||
return { iframe: { contentWindow, contentDocument } as unknown as HTMLIFrameElement };
|
||||
}
|
||||
|
||||
/** Run one restore cycle over `el` and return the final inline opacity. */
|
||||
function restoreOpacity(el: HTMLElement, authoredHtml?: string): string {
|
||||
const { iframe } = buildIframeWithTarget(el);
|
||||
expect(applySoftReload(iframe, SCRIPT_TEXT, authoredHtml ? { authoredHtml } : {})).toBe(
|
||||
"applied",
|
||||
);
|
||||
return el.style.getPropertyValue("opacity");
|
||||
}
|
||||
|
||||
it("restores opacity from the after-write HTML (matched by data-hf-id)", () => {
|
||||
const el = document.createElement("img");
|
||||
el.setAttribute("data-hf-id", "hf-1");
|
||||
el.style.setProperty("opacity", "0", "important"); // the grading hide
|
||||
|
||||
const opacity = restoreOpacity(
|
||||
el,
|
||||
'<html><body><img data-hf-id="hf-1" style="opacity: 0.98"></body></html>',
|
||||
);
|
||||
|
||||
expect(opacity).toBe("0.98");
|
||||
expect(el.style.getPropertyPriority("opacity")).toBe("");
|
||||
});
|
||||
|
||||
it("falls back to the parse-time stamp when no after-write HTML is given", () => {
|
||||
const el = document.createElement("img");
|
||||
el.setAttribute("data-hf-authored-opacity", "0.75");
|
||||
el.style.opacity = "0.123"; // mid-flight tween transient
|
||||
|
||||
expect(restoreOpacity(el)).toBe("0.75");
|
||||
});
|
||||
|
||||
it("an empty stamp (authored none) removes the inline opacity", () => {
|
||||
const el = document.createElement("img");
|
||||
el.setAttribute("data-hf-authored-opacity", "");
|
||||
el.style.opacity = "0";
|
||||
|
||||
expect(restoreOpacity(el)).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,447 @@
|
||||
import { COLOR_GRADING_SOURCE_HIDDEN_ATTR } from "@hyperframes/core/color-grading";
|
||||
import { applyAuthoredInlineOpacity, readStampedAuthoredOpacity } from "./authoredOpacity";
|
||||
|
||||
type IframeWindow = Window & {
|
||||
__timelines?: Record<string, { kill?: () => void; pause?: () => void }>;
|
||||
__player?: { getTime?: () => number; seek?: (t: number) => void };
|
||||
__hfForceTimelineRebind?: () => void;
|
||||
__hfSuppressSceneMutations?: <T>(fn: () => T) => T;
|
||||
__hfStudioManualEditsApply?: () => void;
|
||||
// Set while a MotionPathPlugin <script> is being fetched, so overlapping soft
|
||||
// reloads (each needing the plugin) don't queue duplicate plugin scripts that
|
||||
// re-flash the iframe. Cleared once the plugin loads or errors.
|
||||
__hfMotionPathPluginLoading?: boolean;
|
||||
gsap?: {
|
||||
timeline?: (...args: unknown[]) => unknown;
|
||||
registerPlugin?: (...plugins: unknown[]) => unknown;
|
||||
set?: (targets: Element | Element[], vars: Record<string, unknown>) => void;
|
||||
globalTimeline?: { getChildren?: (deep: boolean) => Array<{ kill?: () => void }> };
|
||||
};
|
||||
MotionPathPlugin?: unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* CDN URL for the GSAP MotionPathPlugin. Shared between the one-time preview
|
||||
* bootstrap (ensureMotionPathPluginLoaded) and the soft-reload fallback so the
|
||||
* version is pinned in a single place.
|
||||
*/
|
||||
const MOTION_PATH_PLUGIN_CDN =
|
||||
"https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/MotionPathPlugin.min.js";
|
||||
|
||||
/**
|
||||
* Pre-load + register MotionPathPlugin ONCE in the preview iframe so
|
||||
* `win.MotionPathPlugin` is reliably set before any studio edit. Called from the
|
||||
* preview bootstrap (NLELayout's onIframeLoad) on every iframe load.
|
||||
*
|
||||
* Why: when a user ADDS a motion path to a composition that never used one, the
|
||||
* plugin isn't loaded, so the first soft reload takes the async `<script src>`
|
||||
* load path — the timeline is killed/cleared while the CDN load is pending,
|
||||
* producing a visible flash. Loading it eagerly here means the soft reload runs
|
||||
* synchronously and `needsMotionPath && !win.MotionPathPlugin` never fires for
|
||||
* studio edits.
|
||||
*
|
||||
* Idempotent (no-ops once the plugin is present or already loading) and
|
||||
* defensive: no-ops without gsap/registerPlugin and tolerates a CDN failure
|
||||
* (the soft-reload async fallback in applySoftReload still covers that case).
|
||||
*/
|
||||
export function ensureMotionPathPluginLoaded(iframe: HTMLIFrameElement | null): void {
|
||||
if (!iframe?.contentWindow || !iframe.contentDocument) return;
|
||||
const win = iframe.contentWindow as IframeWindow;
|
||||
const doc = iframe.contentDocument;
|
||||
|
||||
// Already registered (composition shipped its own plugin, or a prior bootstrap
|
||||
// ran) — register it on gsap to be safe, then bail.
|
||||
if (win.MotionPathPlugin) {
|
||||
try {
|
||||
if (win.gsap?.registerPlugin) win.gsap.registerPlugin(win.MotionPathPlugin);
|
||||
} catch {}
|
||||
return;
|
||||
}
|
||||
if (!win.gsap?.registerPlugin) return;
|
||||
// A load is already in flight for this iframe — don't queue a second script.
|
||||
if (win.__hfMotionPathPluginLoading) return;
|
||||
|
||||
try {
|
||||
win.__hfMotionPathPluginLoading = true;
|
||||
const pluginScript = doc.createElement("script");
|
||||
pluginScript.src = MOTION_PATH_PLUGIN_CDN;
|
||||
const finalize = () => {
|
||||
win.__hfMotionPathPluginLoading = false;
|
||||
try {
|
||||
if (win.MotionPathPlugin && win.gsap?.registerPlugin) {
|
||||
win.gsap.registerPlugin(win.MotionPathPlugin);
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
pluginScript.onload = finalize;
|
||||
pluginScript.onerror = finalize;
|
||||
doc.head.appendChild(pluginScript);
|
||||
} catch {
|
||||
win.__hfMotionPathPluginLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function isGsapScript(text: string): boolean {
|
||||
return (
|
||||
text.includes("gsap.timeline") ||
|
||||
text.includes("__timelines") ||
|
||||
text.includes(".to(") ||
|
||||
text.includes(".set(")
|
||||
);
|
||||
}
|
||||
|
||||
function findGsapScriptElements(doc: Document): HTMLScriptElement[] {
|
||||
const results: HTMLScriptElement[] = [];
|
||||
const scripts = doc.querySelectorAll<HTMLScriptElement>("script:not([src])");
|
||||
for (const script of scripts) {
|
||||
if (isGsapScript(script.textContent || "")) results.push(script);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the GSAP timeline script text from a serialized HTML document, for
|
||||
* feeding into applySoftReload. Returns null when zero or multiple GSAP scripts
|
||||
* are present (ambiguous — caller should fall back to a full reload), matching
|
||||
* applySoftReload's own single-script requirement.
|
||||
*/
|
||||
export function extractGsapScriptText(html: string): string | null {
|
||||
const doc = new DOMParser().parseFromString(html, "text/html");
|
||||
const scripts = findGsapScriptElements(doc);
|
||||
if (scripts.length !== 1) return null;
|
||||
return scripts[0].textContent || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm the re-run repopulated the timeline(s) this script owns. We check the
|
||||
* EXPECTED keys (the ones the script re-registers), not merely "any key": a
|
||||
* scoped soft reload only re-runs ONE composition, so the right success signal is
|
||||
* "my target keys are back", not "the global map is non-empty". Checking the
|
||||
* exact keys avoids the transient false where the global map momentarily looks
|
||||
* empty right after the re-run — the spurious trigger of the full-remount fallback.
|
||||
*/
|
||||
function verifyTimelinesPopulated(win: IframeWindow, targetKeys: string[]): boolean {
|
||||
const timelines = win.__timelines;
|
||||
if (!timelines) return false;
|
||||
if (targetKeys.length > 0) {
|
||||
return targetKeys.every((key) => timelines[key] != null);
|
||||
}
|
||||
return Object.keys(timelines).filter((k) => k !== "__proxied").length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Outcome of a soft-reload attempt. Callers must distinguish PERMANENT failures
|
||||
* (the preview genuinely can't be soft-updated — escalate to a full reload) from
|
||||
* the TRANSIENT post-run empty-timeline window (the live `gsap.set` already shows
|
||||
* the correct value — do NOT escalate; a remount would re-flash the WebGL context
|
||||
* and revert subcomposition keyframes):
|
||||
*
|
||||
* - `"applied"` — the script ran (or is deferred to the async plugin
|
||||
* load and WILL run). The preview is/will be correct.
|
||||
* - `"verify-failed"` — TRANSIENT: the re-run happened but `__timelines`
|
||||
* momentarily read empty. Live state is correct → do
|
||||
* NOT escalate. (Was a bare `false` before.)
|
||||
* - `"cannot-soft-reload"` — PERMANENT/STRUCTURAL: no gsap runtime, no rebind
|
||||
* hook, no scopable target key, or no script element
|
||||
* to replace. The preview is stale/broken → escalate.
|
||||
*
|
||||
* The async MotionPath-plugin load failure is still surfaced via
|
||||
* `onAsyncFailure` (it fires after this returned `"applied"` optimistically).
|
||||
*/
|
||||
export type SoftReloadResult = "applied" | "verify-failed" | "cannot-soft-reload";
|
||||
|
||||
/**
|
||||
* Replace the GSAP script in the live iframe without reloading. This preserves
|
||||
* the WebGL context and shader transition cache.
|
||||
*
|
||||
* Scoped to root-document GSAP scripts only — scripts inside `<template>`
|
||||
* elements (sub-compositions) are not visible to `querySelectorAll` and will
|
||||
* fall back to a full iframe reload.
|
||||
*
|
||||
* Returns `"cannot-soft-reload"` (caller should full-reload) when:
|
||||
* - The iframe or GSAP runtime isn't available
|
||||
* - The rebind hook isn't installed
|
||||
* - The script registers no scopable `__timelines` key
|
||||
* - No GSAP script element exists in the live DOM
|
||||
* - The synchronous re-run threw
|
||||
*
|
||||
* Returns `"verify-failed"` when the re-run executed but the target timeline
|
||||
* keys read empty in the transient post-run window (live state is still correct).
|
||||
*
|
||||
* `onAsyncFailure` is invoked when the soft reload was deferred to load the
|
||||
* MotionPath plugin (so this returned `"applied"` optimistically) but the plugin
|
||||
* `<script>` then failed to load — the iframe is left without the plugin and the
|
||||
* caller should perform a full reload to recover. It never fires on the
|
||||
* synchronous paths.
|
||||
*/
|
||||
export interface SoftReloadOptions {
|
||||
/** Escalation for async plugin-load failures (e.g. MotionPath CDN error). */
|
||||
onAsyncFailure?: () => void;
|
||||
/** Seek target for the rebuilt timeline; defaults to the iframe player time. */
|
||||
currentTimeOverride?: number;
|
||||
/** After-write file HTML — the primary source for authored-opacity restore. */
|
||||
authoredHtml?: string;
|
||||
}
|
||||
|
||||
export function applySoftReload(
|
||||
iframe: HTMLIFrameElement | null,
|
||||
scriptText: string,
|
||||
options: SoftReloadOptions = {},
|
||||
): SoftReloadResult {
|
||||
const { onAsyncFailure, currentTimeOverride, authoredHtml } = options;
|
||||
if (!iframe || !scriptText) return "cannot-soft-reload";
|
||||
|
||||
const win = iframe.contentWindow as IframeWindow | null;
|
||||
const doc = iframe.contentDocument;
|
||||
if (!win || !doc) return "cannot-soft-reload";
|
||||
if (!win.gsap || !win.__hfForceTimelineRebind) return "cannot-soft-reload";
|
||||
|
||||
// Which composition(s) does this script rebuild? A soft reload re-runs ONE
|
||||
// composition's GSAP script, which re-registers its own window.__timelines[key].
|
||||
// In a multi-composition preview (top-level + inlined subcompositions) each
|
||||
// composition owns a separate timeline keyed by its id, and they're all children
|
||||
// of the global timeline — so tearing down ALL of them (or the global timeline's
|
||||
// children) and re-running a single script wipes every OTHER composition,
|
||||
// reverting its edits. Scope the teardown to the keys THIS script re-registers.
|
||||
const targetKeys = [...scriptText.matchAll(/__timelines\s*\[\s*["'`]([^"'`]+)["'`]\s*\]/g)]
|
||||
.map((m) => m[1]!)
|
||||
.filter((key) => key !== "__proxied");
|
||||
if (targetKeys.length === 0) return "cannot-soft-reload"; // can't scope safely → full reload
|
||||
const gsapScripts = findGsapScriptElements(doc);
|
||||
if (gsapScripts.length === 0) return "cannot-soft-reload";
|
||||
// Remove only the stale script element(s) that registered a target key; one we
|
||||
// can't match in the doc is left alone (re-running appends a fresh element).
|
||||
const staleScripts = gsapScripts.filter((script) =>
|
||||
targetKeys.some((key) => {
|
||||
const text = script.textContent || "";
|
||||
return text.includes(`__timelines["${key}"]`) || text.includes(`__timelines['${key}']`);
|
||||
}),
|
||||
);
|
||||
// Multiple GSAP scripts exist but none registers a key this script owns — we
|
||||
// can't identify which element to replace (ambiguous, matching
|
||||
// extractGsapScriptText's single-script requirement). Escalate to a full reload
|
||||
// rather than killing the target timeline and appending an orphan script.
|
||||
if (gsapScripts.length > 1 && staleScripts.length === 0) return "cannot-soft-reload";
|
||||
|
||||
// Prefer the caller-supplied scrub position (the studio's own authoritative
|
||||
// currentTime, e.g. usePlayerStore) over the iframe's raw `__player.getTime()`:
|
||||
// the two can desync (a keyframe-node drag parks the playhead via the store
|
||||
// BEFORE this reload's async commit resolves, and the iframe's own GSAP clock
|
||||
// doesn't reliably reflect that yet), which re-seeks the freshly rebuilt
|
||||
// timeline to the wrong frame and leaves the element (and its overlay)
|
||||
// rendered at a stale/unrelated position.
|
||||
const currentTime = currentTimeOverride ?? win.__player?.getTime?.() ?? 0;
|
||||
|
||||
// Track whether the MotionPath async path was taken. When it is, the script
|
||||
// executes inside pluginScript.onload — after applySoftReload has already
|
||||
// returned. We optimistically return true because the script WILL execute
|
||||
// once the plugin loads; the alternative (returning false) would trigger a
|
||||
// full iframe reload that destroys the very WebGL context we're preserving.
|
||||
let deferredToAsync = false;
|
||||
|
||||
// Authored-opacity resolution for the restore loop below. Three-state:
|
||||
// "0.98" — the element's authored inline opacity
|
||||
// "" — resolved, and the element has NO authored inline opacity
|
||||
// null — unknown (no authored HTML supplied, element not found in it,
|
||||
// and no runtime parse-time stamp)
|
||||
// The just-written file (`authoredHtml`) is the current truth; the runtime's
|
||||
// parse-time stamp (data-hf-authored-opacity, installAuthoredOpacityCapture)
|
||||
// covers elements the file lookup can't resolve. Parsed lazily, at most once.
|
||||
let authoredDoc: Document | null | undefined;
|
||||
const findAuthoredSource = (el: HTMLElement): Element | null => {
|
||||
if (authoredDoc === undefined) {
|
||||
try {
|
||||
authoredDoc = authoredHtml
|
||||
? new DOMParser().parseFromString(authoredHtml, "text/html")
|
||||
: null;
|
||||
} catch {
|
||||
authoredDoc = null;
|
||||
}
|
||||
}
|
||||
if (!authoredDoc) return null;
|
||||
const hfId = el.getAttribute("data-hf-id");
|
||||
if (hfId) return authoredDoc.querySelector(`[data-hf-id="${hfId}"]`);
|
||||
return el.id ? authoredDoc.getElementById(el.id) : null;
|
||||
};
|
||||
const readAuthoredOpacity = (el: HTMLElement): string | null => {
|
||||
const source = findAuthoredSource(el);
|
||||
if (source instanceof HTMLElement) return source.style.opacity;
|
||||
return readStampedAuthoredOpacity(el);
|
||||
};
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
const doReload = () => {
|
||||
const timelines = win.__timelines;
|
||||
const allTargets: Element[] = [];
|
||||
|
||||
// Kill ONLY the target composition's timeline(s) — leaving every other
|
||||
// composition's timeline (and its children on the global timeline) intact.
|
||||
if (timelines) {
|
||||
for (const key of targetKeys) {
|
||||
const tl = timelines[key] as
|
||||
| {
|
||||
kill?: () => void;
|
||||
getChildren?: (deep: boolean) => Array<{ targets?: () => Element[] }>;
|
||||
}
|
||||
| undefined;
|
||||
if (!tl) continue;
|
||||
if (tl.getChildren) {
|
||||
try {
|
||||
for (const child of tl.getChildren(true)) {
|
||||
if (typeof child.targets === "function") {
|
||||
for (const t of child.targets()) allTargets.push(t);
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
try {
|
||||
tl.kill?.();
|
||||
} catch {}
|
||||
delete timelines[key];
|
||||
}
|
||||
}
|
||||
|
||||
// Also reset elements carrying a GSAP-applied inline `transform` that the
|
||||
// timeline-children sweep above missed — a dragged element whose position
|
||||
// was a standalone `gsap.set` (never a timeline child), or one whose
|
||||
// keyframes were just removed (no longer in any timeline). Their last
|
||||
// `gsap.set` transform is otherwise orphaned: the re-run won't re-set it
|
||||
// and the sweep above can't see it, so the element renders offset from its
|
||||
// source position (matching the overlay) until a full reload. The clear
|
||||
// below runs BEFORE the re-run, which re-applies the transform for any
|
||||
// element the new script still animates.
|
||||
const seenTargets = new Set<Element>(allTargets);
|
||||
for (const el of doc.querySelectorAll<HTMLElement>("[style*='transform']")) {
|
||||
// Gate on the GSAP cache (`_gsap`) so we only reset transforms GSAP owns —
|
||||
// never strip an authored, non-GSAP inline transform.
|
||||
if (el.style.transform && "_gsap" in el && !seenTargets.has(el)) {
|
||||
seenTargets.add(el);
|
||||
allTargets.push(el);
|
||||
}
|
||||
}
|
||||
|
||||
// Reset GSAP's internal transform cache so from() tweens don't read stale
|
||||
// end values. `clearProps: "all"` is needed to flush the cache, but it also
|
||||
// nukes the element's CSS base (position, width, height, etc.) from the
|
||||
// HTML `style=""` attribute. Save → clear → restore → strip `transform`.
|
||||
if (allTargets.length > 0 && win.gsap?.set) {
|
||||
const saved: Array<[HTMLElement, string]> = [];
|
||||
for (const el of allTargets) {
|
||||
// Iframe-realm node: instanceof HTMLElement fails across realms, and
|
||||
// gsap targets() only yields elements here — style access is duck-typed.
|
||||
const styled = el as HTMLElement;
|
||||
if (styled.style?.cssText != null) saved.push([styled, styled.style.cssText]);
|
||||
}
|
||||
try {
|
||||
win.gsap.set(allTargets, { clearProps: "all" });
|
||||
} catch {}
|
||||
for (const [el, css] of saved) {
|
||||
const s = el.style;
|
||||
s.cssText = css;
|
||||
s.removeProperty("transform");
|
||||
// The restored cssText carries RUNTIME opacity, not authored opacity:
|
||||
// a mid-flight tween's interpolated value, or the color-grading hide
|
||||
// (`opacity: 0 !important`). The re-run script's tweens re-initialize
|
||||
// against it — a from() captures it as its END, a to() as its START —
|
||||
// turning the transient into the tween's permanent bound (dimmed or
|
||||
// invisible elements). Put the AUTHORED inline opacity back; the seek
|
||||
// below re-renders the correct animated value either way.
|
||||
const authored = readAuthoredOpacity(el);
|
||||
if (authored !== null) {
|
||||
applyAuthoredInlineOpacity(s, authored);
|
||||
} else if (
|
||||
el.hasAttribute(COLOR_GRADING_SOURCE_HIDDEN_ATTR) &&
|
||||
s.getPropertyValue("opacity") === "0" &&
|
||||
s.getPropertyPriority("opacity") === "important"
|
||||
) {
|
||||
// Authored value unknown, but this is definitely the grading hide —
|
||||
// never let a from() capture 0; fall back to the CSS cascade.
|
||||
s.removeProperty("opacity");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const script of staleScripts) script.remove();
|
||||
|
||||
const executeScript = () => {
|
||||
if (win.MotionPathPlugin && win.gsap?.registerPlugin) {
|
||||
win.gsap.registerPlugin(win.MotionPathPlugin);
|
||||
}
|
||||
const s = doc.createElement("script");
|
||||
s.textContent = `(function(){${scriptText}\n})();`;
|
||||
doc.body.appendChild(s);
|
||||
// Seek BEFORE rebind: __hfForceTimelineRebind's own internal force-render
|
||||
// (see init.ts) renders the freshly-created timeline at whatever the
|
||||
// runtime's internal scrub position already is, not at whatever we pass
|
||||
// here afterward — a redundant seek() call after rebind can be a GSAP
|
||||
// no-op if the timeline already reports being at that time internally.
|
||||
win.__player?.seek?.(currentTime);
|
||||
win.__hfForceTimelineRebind?.();
|
||||
win.__hfStudioManualEditsApply?.();
|
||||
};
|
||||
|
||||
const needsMotionPath = /motionPath\s*[:{]/.test(scriptText);
|
||||
if (needsMotionPath && !win.MotionPathPlugin && win.gsap) {
|
||||
deferredToAsync = true;
|
||||
// A prior soft reload is already fetching the plugin — don't queue a second
|
||||
// <script> (it re-flashes the iframe). Defer THIS script's execution until
|
||||
// the in-flight load settles via a one-shot poll. The bootstrap guard is
|
||||
// the single source of truth for "plugin fetch in progress".
|
||||
if (win.__hfMotionPathPluginLoading) {
|
||||
const started = Date.now();
|
||||
const poll = win.setInterval(() => {
|
||||
if (win.MotionPathPlugin) {
|
||||
win.clearInterval(poll);
|
||||
executeScript();
|
||||
} else if (!win.__hfMotionPathPluginLoading || Date.now() - started > 10000) {
|
||||
// The in-flight load finished without registering the plugin (errored)
|
||||
// or we timed out — recover with a full reload instead of running a
|
||||
// script that references a missing plugin.
|
||||
win.clearInterval(poll);
|
||||
onAsyncFailure?.();
|
||||
}
|
||||
}, 50);
|
||||
return;
|
||||
}
|
||||
win.__hfMotionPathPluginLoading = true;
|
||||
const pluginScript = doc.createElement("script");
|
||||
pluginScript.src = MOTION_PATH_PLUGIN_CDN;
|
||||
pluginScript.onload = () => {
|
||||
win.__hfMotionPathPluginLoading = false;
|
||||
executeScript();
|
||||
};
|
||||
pluginScript.onerror = () => {
|
||||
// The plugin failed to load. Running executeScript() now would leave the
|
||||
// iframe with a motionPath tween referencing a missing plugin while the
|
||||
// caller already thinks the soft reload succeeded. Signal failure so the
|
||||
// caller can full-reload (which fetches the plugin fresh) instead.
|
||||
win.__hfMotionPathPluginLoading = false;
|
||||
onAsyncFailure?.();
|
||||
};
|
||||
doc.head.appendChild(pluginScript);
|
||||
return;
|
||||
}
|
||||
|
||||
executeScript();
|
||||
};
|
||||
|
||||
try {
|
||||
if (win.__hfSuppressSceneMutations) {
|
||||
win.__hfSuppressSceneMutations(doReload);
|
||||
} else {
|
||||
doReload();
|
||||
}
|
||||
// When MotionPath needs async loading, the script hasn't executed yet —
|
||||
// skip the __timelines check and report success optimistically (the script
|
||||
// WILL run on plugin load; onAsyncFailure covers the CDN-error case).
|
||||
if (deferredToAsync) return "applied";
|
||||
// The re-run executed. If the target keys read back, we're done; otherwise
|
||||
// it's the TRANSIENT empty-timeline window (live state is correct) — surfaced
|
||||
// as "verify-failed" so callers know NOT to escalate.
|
||||
return verifyTimelinesPopulated(win, targetKeys) ? "applied" : "verify-failed";
|
||||
} catch {
|
||||
// The synchronous re-run threw — the preview is now genuinely broken (target
|
||||
// timeline killed, script not re-registered). Escalate to a full reload.
|
||||
return "cannot-soft-reload";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
// keep-in-sync-with: packages/core/src/utils/htmlAttrSafety.ts
|
||||
const ALLOWED_HTML_ATTRS = new Set([
|
||||
"id",
|
||||
"class",
|
||||
"style",
|
||||
"title",
|
||||
"name",
|
||||
"for",
|
||||
"type",
|
||||
"lang",
|
||||
"dir",
|
||||
"translate",
|
||||
"hidden",
|
||||
"tabindex",
|
||||
"draggable",
|
||||
"contenteditable",
|
||||
"role",
|
||||
"slot",
|
||||
"href",
|
||||
"target",
|
||||
"rel",
|
||||
"src",
|
||||
"srcset",
|
||||
"sizes",
|
||||
"alt",
|
||||
"poster",
|
||||
"loading",
|
||||
"decoding",
|
||||
"crossorigin",
|
||||
"preload",
|
||||
"autoplay",
|
||||
"loop",
|
||||
"muted",
|
||||
"controls",
|
||||
"playsinline",
|
||||
"width",
|
||||
"height",
|
||||
"colspan",
|
||||
"rowspan",
|
||||
"scope",
|
||||
"placeholder",
|
||||
"value",
|
||||
"min",
|
||||
"max",
|
||||
"step",
|
||||
"pattern",
|
||||
"required",
|
||||
"disabled",
|
||||
"readonly",
|
||||
"checked",
|
||||
"selected",
|
||||
"multiple",
|
||||
"accept",
|
||||
"maxlength",
|
||||
"minlength",
|
||||
"rows",
|
||||
"cols",
|
||||
"wrap",
|
||||
]);
|
||||
|
||||
const URI_BEARING_ATTRS = new Set([
|
||||
"src",
|
||||
"href",
|
||||
"action",
|
||||
"formaction",
|
||||
"poster",
|
||||
"srcset",
|
||||
"xlink:href",
|
||||
]);
|
||||
|
||||
const DANGEROUS_URI_SCHEMES = /^(?:javascript|vbscript):/i;
|
||||
const DANGEROUS_DATA_URI = /^data\s*:\s*text\/html/i;
|
||||
|
||||
export function isAllowedHtmlAttribute(name: string): boolean {
|
||||
const lower = name.toLowerCase();
|
||||
if (lower.startsWith("on")) return false;
|
||||
if (ALLOWED_HTML_ATTRS.has(lower)) return true;
|
||||
if (lower.startsWith("data-")) return true;
|
||||
if (lower.startsWith("aria-")) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function isSafeAttributeValue(name: string, value: string): boolean {
|
||||
if (URI_BEARING_ATTRS.has(name.toLowerCase())) {
|
||||
const trimmed = value.trim();
|
||||
if (DANGEROUS_URI_SCHEMES.test(trimmed)) return false;
|
||||
if (DANGEROUS_DATA_URI.test(trimmed)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* HTML Editor — Utility functions for parsing and manipulating HyperFrame HTML source.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Parse a CSS inline style string into a key-value map.
|
||||
* e.g. "opacity: 0.5; transform: matrix(1,0,0,1,0,0)" →
|
||||
* { opacity: "0.5", transform: "matrix(1,0,0,1,0,0)" }
|
||||
*/
|
||||
export function parseStyleString(style: string): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
for (const decl of style.split(";")) {
|
||||
const colonIdx = decl.indexOf(":");
|
||||
if (colonIdx < 0) continue;
|
||||
const key = decl.slice(0, colonIdx).trim();
|
||||
const value = decl.slice(colonIdx + 1).trim();
|
||||
if (key && value) result[key] = value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge `newStyles` into an opening tag string's `style` attribute.
|
||||
* - New values win over existing ones.
|
||||
* - If no `style` attribute is present, one is added before the closing `>`.
|
||||
*/
|
||||
export function mergeStyleIntoTag(tag: string, newStyles: string): string {
|
||||
if (!newStyles.trim()) return tag;
|
||||
|
||||
const incoming = parseStyleString(newStyles);
|
||||
|
||||
// Match style="..." or style='...' — handle multi-line attrs via dotall-like trick
|
||||
const styleAttrRe = /style=(["'])([\s\S]*?)\1/;
|
||||
const match = tag.match(styleAttrRe);
|
||||
|
||||
if (match) {
|
||||
const quote = match[1];
|
||||
const existing = parseStyleString(match[2]);
|
||||
const merged = { ...existing, ...incoming };
|
||||
const serialized = Object.entries(merged)
|
||||
.map(([k, v]) => `${k}: ${v}`)
|
||||
.join("; ");
|
||||
return tag.replace(styleAttrRe, `style=${quote}${serialized}${quote}`);
|
||||
}
|
||||
|
||||
// No style attribute — insert one before the closing `>`
|
||||
const serialized = Object.entries(incoming)
|
||||
.map(([k, v]) => `${k}: ${v}`)
|
||||
.join("; ");
|
||||
// Handle self-closing tags (`/>`) and regular closing (`>`)
|
||||
return tag.replace(/(\/?>)$/, ` style="${serialized}"$1`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the full element block (opening tag through closing tag) in the source.
|
||||
* Uses quote-aware scanning to handle attributes containing >.
|
||||
* Uses depth counting to handle nested same-name tags.
|
||||
*/
|
||||
export function findElementBlock(
|
||||
html: string,
|
||||
elementId: string,
|
||||
): {
|
||||
start: number;
|
||||
end: number;
|
||||
openTag: string;
|
||||
tagName: string;
|
||||
indent: string;
|
||||
innerContent: string;
|
||||
isSelfClosing: boolean;
|
||||
} | null {
|
||||
let idIdx = html.indexOf(`id="${elementId}"`);
|
||||
if (idIdx < 0) idIdx = html.indexOf(`id='${elementId}'`);
|
||||
if (idIdx < 0) return null;
|
||||
|
||||
// Walk backward to find < and capture indent
|
||||
let tagStart = idIdx;
|
||||
while (tagStart > 0 && html[tagStart] !== "<") tagStart--;
|
||||
|
||||
let indentStart = tagStart;
|
||||
while (indentStart > 0 && html[indentStart - 1] !== "\n") indentStart--;
|
||||
const indent = html.slice(indentStart, tagStart);
|
||||
|
||||
// Walk forward from id to find the closing > of the opening tag
|
||||
let tagEnd = idIdx;
|
||||
let inQuote: string | null = null;
|
||||
while (tagEnd < html.length) {
|
||||
const ch = html[tagEnd];
|
||||
if (inQuote) {
|
||||
if (ch === inQuote) inQuote = null;
|
||||
} else {
|
||||
if (ch === '"' || ch === "'") inQuote = ch;
|
||||
if (ch === ">") {
|
||||
tagEnd++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
tagEnd++;
|
||||
}
|
||||
|
||||
const openTag = html.slice(tagStart, tagEnd);
|
||||
const tagNameMatch = openTag.match(/^<([a-z][a-z0-9]*)/i);
|
||||
if (!tagNameMatch) return null;
|
||||
|
||||
const tagName = tagNameMatch[1];
|
||||
const isSelfClosing =
|
||||
openTag.trimEnd().endsWith("/>") ||
|
||||
["img", "br", "hr", "input", "meta", "link", "source"].includes(tagName.toLowerCase());
|
||||
|
||||
if (isSelfClosing) {
|
||||
return {
|
||||
start: tagStart,
|
||||
end: tagStart + openTag.length,
|
||||
openTag,
|
||||
tagName,
|
||||
indent: /^[\t ]*$/.test(indent) ? indent : "",
|
||||
innerContent: "",
|
||||
isSelfClosing: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Find matching closing tag using depth counting
|
||||
const closeTag = `</${tagName.toLowerCase()}>`;
|
||||
const openPattern = `<${tagName.toLowerCase()}`;
|
||||
let depth = 0;
|
||||
let pos = tagStart;
|
||||
const lower = html.toLowerCase();
|
||||
|
||||
while (pos < html.length) {
|
||||
if (lower.startsWith("<!--", pos)) {
|
||||
const commentEnd = lower.indexOf("-->", pos + 4);
|
||||
pos = commentEnd < 0 ? html.length : commentEnd + 3;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (lower.startsWith(openPattern, pos) && /[\s>/]/.test(html[pos + openPattern.length] || "")) {
|
||||
depth++;
|
||||
pos += openPattern.length;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (lower.startsWith(closeTag, pos)) {
|
||||
depth--;
|
||||
if (depth === 0) {
|
||||
const end = pos + closeTag.length;
|
||||
const innerContent = html.slice(tagStart + openTag.length, pos);
|
||||
return {
|
||||
start: tagStart,
|
||||
end,
|
||||
openTag,
|
||||
tagName,
|
||||
indent: /^[\t ]*$/.test(indent) ? indent : "",
|
||||
innerContent,
|
||||
isSelfClosing: false,
|
||||
};
|
||||
}
|
||||
pos += closeTag.length;
|
||||
continue;
|
||||
}
|
||||
|
||||
pos++;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { selectedKeyframePercentagesForElement } from "./keyframeSelection";
|
||||
|
||||
describe("selectedKeyframePercentagesForElement", () => {
|
||||
it("returns the percentages of keyframes on the active element", () => {
|
||||
const selected = new Set(["comp#a:25", "comp#a:75"]);
|
||||
expect(selectedKeyframePercentagesForElement(selected, "comp#a")).toEqual([25, 75]);
|
||||
});
|
||||
|
||||
it("drops keyframes that belong to other elements", () => {
|
||||
// The bug: a stale shift-selection on `comp#b` would otherwise have its
|
||||
// percentages applied to the now-active `comp#a`, deleting the wrong keyframes.
|
||||
const selected = new Set(["comp#a:25", "comp#b:50", "comp#b:80"]);
|
||||
expect(selectedKeyframePercentagesForElement(selected, "comp#a")).toEqual([25]);
|
||||
});
|
||||
|
||||
it("returns nothing when no key belongs to the active element", () => {
|
||||
const selected = new Set(["comp#b:50"]);
|
||||
expect(selectedKeyframePercentagesForElement(selected, "comp#a")).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns nothing when there is no active element", () => {
|
||||
const selected = new Set(["comp#a:25"]);
|
||||
expect(selectedKeyframePercentagesForElement(selected, null)).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns nothing for an empty selection", () => {
|
||||
expect(selectedKeyframePercentagesForElement(new Set(), "comp#a")).toEqual([]);
|
||||
});
|
||||
|
||||
it("splits on the final colon so element ids containing ':' still match", () => {
|
||||
const selected = new Set(["a:b:40"]);
|
||||
expect(selectedKeyframePercentagesForElement(selected, "a:b")).toEqual([40]);
|
||||
});
|
||||
|
||||
it("skips keys without a percentage separator", () => {
|
||||
const selected = new Set(["comp#a"]);
|
||||
expect(selectedKeyframePercentagesForElement(selected, "comp#a")).toEqual([]);
|
||||
});
|
||||
|
||||
it("skips keys whose percentage is not a finite number", () => {
|
||||
const selected = new Set(["comp#a:abc", "comp#a:NaN", "comp#a:30"]);
|
||||
expect(selectedKeyframePercentagesForElement(selected, "comp#a")).toEqual([30]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Resolves which keyframe percentages a bulk operation should act on.
|
||||
*
|
||||
* `selectedKeyframes` holds `"<elementId>:<percentage>"` keys and can contain
|
||||
* keyframes from more than one element — e.g. a shift-selection made before the
|
||||
* active element changed (via a keyframe click, a clip click, the layers panel,
|
||||
* or the keyframe context menu). A bulk delete only targets the active
|
||||
* element's animation, so keys belonging to other elements must be dropped;
|
||||
* otherwise their percentages get applied to the active element and remove
|
||||
* keyframes the user never selected on it.
|
||||
*
|
||||
* The element id is everything before the final `:` so element ids that happen
|
||||
* to contain `:` are handled correctly.
|
||||
*/
|
||||
export function selectedKeyframePercentagesForElement(
|
||||
selectedKeyframes: ReadonlySet<string>,
|
||||
activeElementId: string | null,
|
||||
): number[] {
|
||||
if (!activeElementId) return [];
|
||||
const percentages: number[] = [];
|
||||
for (const key of selectedKeyframes) {
|
||||
const separator = key.lastIndexOf(":");
|
||||
if (separator < 0) continue;
|
||||
if (key.slice(0, separator) !== activeElementId) continue;
|
||||
const percentage = Number(key.slice(separator + 1));
|
||||
if (Number.isFinite(percentage)) percentages.push(percentage);
|
||||
}
|
||||
return percentages;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { rectsOverlap } from "./marqueeGeometry";
|
||||
|
||||
describe("rectsOverlap", () => {
|
||||
it("overlapping rects", () => {
|
||||
expect(
|
||||
rectsOverlap(
|
||||
{ left: 0, top: 0, width: 10, height: 10 },
|
||||
{ left: 5, top: 5, width: 10, height: 10 },
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("non-overlapping rects", () => {
|
||||
expect(
|
||||
rectsOverlap(
|
||||
{ left: 0, top: 0, width: 10, height: 10 },
|
||||
{ left: 20, top: 20, width: 10, height: 10 },
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("touching edges do not overlap", () => {
|
||||
expect(
|
||||
rectsOverlap(
|
||||
{ left: 0, top: 0, width: 10, height: 10 },
|
||||
{ left: 10, top: 0, width: 10, height: 10 },
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("one rect fully inside another overlaps", () => {
|
||||
expect(
|
||||
rectsOverlap(
|
||||
{ left: 0, top: 0, width: 100, height: 100 },
|
||||
{ left: 25, top: 25, width: 10, height: 10 },
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
export interface Rect {
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export function rectsOverlap(a: Rect, b: Rect): boolean {
|
||||
return (
|
||||
a.left < b.left + b.width &&
|
||||
a.left + a.width > b.left &&
|
||||
a.top < b.top + b.height &&
|
||||
a.top + a.height > b.top
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export const IMAGE_EXT = /\.(jpg|jpeg|png|gif|webp|svg|ico)$/i;
|
||||
export const VIDEO_EXT = /\.(mp4|webm|mov)$/i;
|
||||
export const AUDIO_EXT = /\.(mp3|wav|ogg|m4a|aac)$/i;
|
||||
export const FONT_EXT = /\.(woff|woff2|ttf|ttc|otf|eot)$/i;
|
||||
export const LUT_EXT = /\.cube$/i;
|
||||
export const MEDIA_EXT = /\.(mp4|webm|mov|mp3|wav|ogg|m4a|aac|jpg|jpeg|png|gif|webp|svg|ico)$/i;
|
||||
|
||||
export function isMediaFile(path: string): boolean {
|
||||
return MEDIA_EXT.test(path);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { executeOptimistic } from "./optimisticUpdate";
|
||||
|
||||
describe("executeOptimistic", () => {
|
||||
it("calls apply then persist on success, never rollback", async () => {
|
||||
const apply = vi.fn(() => "snapshot");
|
||||
const persist = vi.fn(() => Promise.resolve());
|
||||
const rollback = vi.fn();
|
||||
|
||||
await executeOptimistic({ apply, persist, rollback });
|
||||
|
||||
expect(apply).toHaveBeenCalledOnce();
|
||||
expect(persist).toHaveBeenCalledOnce();
|
||||
expect(rollback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls rollback with snapshot on persist failure", async () => {
|
||||
const apply = vi.fn(() => ({ prev: "data" }));
|
||||
const persist = vi.fn(() => Promise.reject(new Error("network")));
|
||||
const rollback = vi.fn();
|
||||
|
||||
await executeOptimistic({ apply, persist, rollback });
|
||||
|
||||
expect(apply).toHaveBeenCalledOnce();
|
||||
expect(persist).toHaveBeenCalledOnce();
|
||||
expect(rollback).toHaveBeenCalledWith({ prev: "data" });
|
||||
});
|
||||
|
||||
it("preserves complex snapshot objects through rollback", async () => {
|
||||
const snapshot = {
|
||||
format: "percentage",
|
||||
keyframes: [{ percentage: 0, properties: { opacity: 0 } }],
|
||||
};
|
||||
const apply = vi.fn(() => structuredClone(snapshot));
|
||||
const persist = vi.fn(() => Promise.reject(new Error("500")));
|
||||
const rollback = vi.fn();
|
||||
|
||||
await executeOptimistic({ apply, persist, rollback });
|
||||
|
||||
expect(rollback).toHaveBeenCalledOnce();
|
||||
expect(rollback.mock.calls[0][0]).toEqual(snapshot);
|
||||
});
|
||||
|
||||
it("handles undefined snapshot for rollback", async () => {
|
||||
const apply = vi.fn(() => undefined);
|
||||
const persist = vi.fn(() => Promise.reject(new Error("timeout")));
|
||||
const rollback = vi.fn();
|
||||
|
||||
await executeOptimistic({ apply, persist, rollback });
|
||||
|
||||
expect(rollback).toHaveBeenCalledWith(undefined);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
export interface OptimisticUpdateOptions<TSnapshot> {
|
||||
/** Apply the change to local state immediately. Return a snapshot for rollback. */
|
||||
apply: () => TSnapshot;
|
||||
/** Persist the change to the server. */
|
||||
persist: () => Promise<unknown>;
|
||||
/** Revert local state using the snapshot if persist fails. */
|
||||
rollback: (snapshot: TSnapshot) => void;
|
||||
}
|
||||
|
||||
export async function executeOptimistic<T>(options: OptimisticUpdateOptions<T>): Promise<void> {
|
||||
const snapshot = options.apply();
|
||||
try {
|
||||
await options.persist();
|
||||
} catch {
|
||||
options.rollback(snapshot);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { buildFrameCaptureUrl } from "./frameCapture";
|
||||
import {
|
||||
buildProjectApiPath,
|
||||
buildProjectHash,
|
||||
encodeProjectId,
|
||||
parseProjectHashRoute,
|
||||
parseProjectIdFromHash,
|
||||
} from "./projectRouting";
|
||||
|
||||
describe("project routing utilities", () => {
|
||||
it("decodes project ids from hash routes before building capture URLs", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-05-01T12:00:00Z"));
|
||||
|
||||
const projectId = parseProjectIdFromHash("#project/Notion%20Showcase");
|
||||
|
||||
expect(projectId).toBe("Notion Showcase");
|
||||
expect(
|
||||
buildFrameCaptureUrl({
|
||||
projectId: projectId ?? "",
|
||||
compositionPath: null,
|
||||
currentTime: 1.809,
|
||||
origin: "http://localhost:3002",
|
||||
}),
|
||||
).toBe(
|
||||
"http://localhost:3002/api/projects/Notion%20Showcase/thumbnail/index.html?t=1.809&format=png&v=1777636800000",
|
||||
);
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("accepts legacy raw-space hash routes", () => {
|
||||
expect(parseProjectIdFromHash("#project/Notion Showcase")).toBe("Notion Showcase");
|
||||
});
|
||||
|
||||
it("decodes reserved characters when the hash route is encoded", () => {
|
||||
expect(parseProjectIdFromHash("#project/Launch%20%231%3F%20v2")).toBe("Launch #1? v2");
|
||||
});
|
||||
|
||||
it("does not throw on malformed percent escapes in hash routes", () => {
|
||||
expect(parseProjectIdFromHash("#project/Broken%ZZName")).toBe("Broken%ZZName");
|
||||
});
|
||||
|
||||
it("ignores non-project hash routes", () => {
|
||||
expect(parseProjectIdFromHash("")).toBeNull();
|
||||
expect(parseProjectIdFromHash("#settings")).toBeNull();
|
||||
expect(parseProjectIdFromHash("#project/")).toBeNull();
|
||||
expect(parseProjectIdFromHash("#project/foo/bar")).toBeNull();
|
||||
});
|
||||
|
||||
it("encodes project ids when writing hash routes", () => {
|
||||
expect(buildProjectHash("Notion Showcase")).toBe("#project/Notion%20Showcase");
|
||||
expect(buildProjectHash("Notion%20Showcase")).toBe("#project/Notion%2520Showcase");
|
||||
expect(buildProjectHash("Launch #1? v2")).toBe("#project/Launch%20%231%3F%20v2");
|
||||
});
|
||||
|
||||
it("round-trips unicode project ids through hash routes", () => {
|
||||
const hash = buildProjectHash("Mañana demo");
|
||||
|
||||
expect(hash).toBe("#project/Ma%C3%B1ana%20demo");
|
||||
expect(parseProjectIdFromHash(hash)).toBe("Mañana demo");
|
||||
});
|
||||
|
||||
it("parses project hash routes with query params", () => {
|
||||
const route = parseProjectHashRoute("#project/Notion%20Showcase?tab=design&t=4.2");
|
||||
|
||||
expect(route?.projectId).toBe("Notion Showcase");
|
||||
expect(route?.params.get("tab")).toBe("design");
|
||||
expect(route?.params.get("t")).toBe("4.2");
|
||||
});
|
||||
|
||||
it("builds hash routes with query params", () => {
|
||||
expect(buildProjectHash("Notion Showcase", { tab: "design", t: "4.2" })).toBe(
|
||||
"#project/Notion%20Showcase?tab=design&t=4.2",
|
||||
);
|
||||
});
|
||||
|
||||
it("encodes project ids as one API path segment", () => {
|
||||
expect(encodeProjectId("Notion Showcase")).toBe("Notion%20Showcase");
|
||||
expect(encodeProjectId("Notion%20Showcase")).toBe("Notion%2520Showcase");
|
||||
expect(encodeProjectId("Launch #1? v2")).toBe("Launch%20%231%3F%20v2");
|
||||
});
|
||||
|
||||
it("builds API paths without double encoding decoded project ids", () => {
|
||||
expect(buildProjectApiPath("Notion Showcase", "/thumbnail/index.html")).toBe(
|
||||
"/api/projects/Notion%20Showcase/thumbnail/index.html",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps literal percent signs safe in API paths", () => {
|
||||
expect(buildProjectApiPath("Percent%20Name", "/preview")).toBe(
|
||||
"/api/projects/Percent%2520Name/preview",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps unicode project ids safe in API paths", () => {
|
||||
expect(buildProjectApiPath("Mañana demo", "/preview")).toBe(
|
||||
"/api/projects/Ma%C3%B1ana%20demo/preview",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
const PROJECT_HASH_PREFIX = "#project/";
|
||||
|
||||
export interface ProjectHashRoute {
|
||||
projectId: string;
|
||||
params: URLSearchParams;
|
||||
}
|
||||
|
||||
function decodeHashProjectId(value: string): string {
|
||||
try {
|
||||
return decodeURIComponent(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeHashParams(
|
||||
params?: URLSearchParams | Record<string, string | null | undefined>,
|
||||
): URLSearchParams {
|
||||
if (!params) return new URLSearchParams();
|
||||
if (params instanceof URLSearchParams) return params;
|
||||
|
||||
const next = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (!key || value == null || value === "") continue;
|
||||
next.set(key, value);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
export function encodeProjectId(projectId: string): string {
|
||||
return encodeURIComponent(projectId);
|
||||
}
|
||||
|
||||
export function buildProjectHash(
|
||||
projectId: string,
|
||||
params?: URLSearchParams | Record<string, string | null | undefined>,
|
||||
): string {
|
||||
const search = normalizeHashParams(params).toString();
|
||||
return `${PROJECT_HASH_PREFIX}${encodeProjectId(projectId)}${search ? `?${search}` : ""}`;
|
||||
}
|
||||
|
||||
export function parseProjectHashRoute(hash: string): ProjectHashRoute | null {
|
||||
if (!hash.startsWith(PROJECT_HASH_PREFIX)) return null;
|
||||
|
||||
const route = hash.slice(PROJECT_HASH_PREFIX.length);
|
||||
const queryIndex = route.indexOf("?");
|
||||
const encodedProjectId = queryIndex >= 0 ? route.slice(0, queryIndex) : route;
|
||||
if (!encodedProjectId || encodedProjectId.includes("/")) return null;
|
||||
|
||||
const rawParams = queryIndex >= 0 ? route.slice(queryIndex + 1) : "";
|
||||
return {
|
||||
projectId: decodeHashProjectId(encodedProjectId),
|
||||
params: new URLSearchParams(rawParams),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseProjectIdFromHash(hash: string): string | null {
|
||||
return parseProjectHashRoute(hash)?.projectId ?? null;
|
||||
}
|
||||
|
||||
export function buildProjectApiPath(projectId: string, suffix = ""): string {
|
||||
const normalizedSuffix = suffix && !suffix.startsWith("/") ? `/${suffix}` : suffix;
|
||||
return `/api/projects/${encodeProjectId(projectId)}${normalizedSuffix}`;
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* Ramer-Douglas-Peucker simplification for time-series data.
|
||||
*
|
||||
* Used to reduce gesture recording samples into a minimal set of keyframes
|
||||
* that approximate the original curve within a configurable tolerance.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1D time-series simplification
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Perpendicular distance from point (t, v) to the line segment between
|
||||
* (t1, v1) and (t2, v2). For 1D time-series this reduces to the vertical
|
||||
* distance from the point to the interpolated value on the line.
|
||||
*/
|
||||
function perpendicularDistance(
|
||||
t: number,
|
||||
v: number,
|
||||
t1: number,
|
||||
v1: number,
|
||||
t2: number,
|
||||
v2: number,
|
||||
): number {
|
||||
// Degenerate case: start and end share the same time
|
||||
if (t2 === t1) return Math.abs(v - v1);
|
||||
const interpolated = v1 + ((v2 - v1) * (t - t1)) / (t2 - t1);
|
||||
return Math.abs(v - interpolated);
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard Ramer-Douglas-Peucker on 1D time-series data.
|
||||
*
|
||||
* Each point is treated as (time, value) in 2D space. Returns the minimal
|
||||
* subset of input points that approximates the curve within `epsilon`.
|
||||
*
|
||||
* - `epsilon = 0` returns all points (no simplification).
|
||||
* - A large `epsilon` returns just the first and last points.
|
||||
* - Empty or single-point input is returned unchanged.
|
||||
*/
|
||||
function simplifyTimeSeries(
|
||||
points: Array<{ time: number; value: number }>,
|
||||
epsilon: number,
|
||||
): Array<{ time: number; value: number }> {
|
||||
if (points.length <= 2) return points;
|
||||
if (epsilon <= 0) return points;
|
||||
|
||||
const first = points[0];
|
||||
const last = points[points.length - 1];
|
||||
|
||||
let maxDist = 0;
|
||||
let maxIndex = 0;
|
||||
|
||||
for (let i = 1; i < points.length - 1; i++) {
|
||||
const d = perpendicularDistance(
|
||||
points[i].time,
|
||||
points[i].value,
|
||||
first.time,
|
||||
first.value,
|
||||
last.time,
|
||||
last.value,
|
||||
);
|
||||
if (d > maxDist) {
|
||||
maxDist = d;
|
||||
maxIndex = i;
|
||||
}
|
||||
}
|
||||
|
||||
if (maxDist > epsilon) {
|
||||
const left = simplifyTimeSeries(points.slice(0, maxIndex + 1), epsilon);
|
||||
const right = simplifyTimeSeries(points.slice(maxIndex), epsilon);
|
||||
// left includes maxIndex, right starts with maxIndex — drop the duplicate
|
||||
return left.slice(0, -1).concat(right);
|
||||
}
|
||||
|
||||
return [first, last];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Multi-property gesture simplification
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Simplify gesture recording samples into percentage-keyed keyframes.
|
||||
*
|
||||
* Runs `simplifyTimeSeries` independently per property across all samples,
|
||||
* then merges the retained time points into a single Map keyed by percentage
|
||||
* of `totalDuration` (0–100, rounded to 1 decimal).
|
||||
*
|
||||
* Independent per-property simplification means that complex motion on one
|
||||
* property (e.g. `x`) does not force extra keyframes on a simpler property
|
||||
* (e.g. `opacity`).
|
||||
*
|
||||
* At each retained percentage the output contains all properties interpolated
|
||||
* at that time — not just the property that caused the time point to survive.
|
||||
*/
|
||||
export function simplifyGestureSamples(
|
||||
samples: Array<{ time: number; properties: Record<string, number> }>,
|
||||
totalDuration: number,
|
||||
epsilon: number | ((key: string) => number),
|
||||
): Map<number, Record<string, number>> {
|
||||
if (samples.length === 0) return new Map();
|
||||
if (totalDuration <= 0) return new Map();
|
||||
|
||||
// Collect all property keys present across samples
|
||||
const propertyKeys = new Set<string>();
|
||||
for (const s of samples) {
|
||||
for (const key of Object.keys(s.properties)) {
|
||||
propertyKeys.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
// Run RDP independently per property and collect surviving times
|
||||
const survivingTimes = new Set<number>();
|
||||
|
||||
for (const key of propertyKeys) {
|
||||
const series: Array<{ time: number; value: number }> = [];
|
||||
for (const s of samples) {
|
||||
if (key in s.properties) {
|
||||
series.push({ time: s.time, value: s.properties[key] });
|
||||
}
|
||||
}
|
||||
const keyEpsilon = typeof epsilon === "function" ? epsilon(key) : epsilon;
|
||||
const simplified = simplifyTimeSeries(series, keyEpsilon);
|
||||
for (const pt of simplified) {
|
||||
survivingTimes.add(pt.time);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort surviving times so we can iterate in order
|
||||
const sortedTimes = Array.from(survivingTimes).sort((a, b) => a - b);
|
||||
|
||||
// For each surviving time, interpolate all properties and store by percentage
|
||||
const result = new Map<number, Record<string, number>>();
|
||||
|
||||
for (const t of sortedTimes) {
|
||||
const pct = Math.round((t / totalDuration) * 1000) / 10; // 1 decimal
|
||||
const props: Record<string, number> = {};
|
||||
|
||||
for (const key of propertyKeys) {
|
||||
props[key] = interpolatePropertyAtTime(samples, key, t);
|
||||
}
|
||||
|
||||
result.set(pct, props);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Linearly interpolate a single property value at the given time from the
|
||||
* samples array. Assumes samples are sorted by time.
|
||||
*/
|
||||
function interpolatePropertyAtTime(
|
||||
samples: Array<{ time: number; properties: Record<string, number> }>,
|
||||
key: string,
|
||||
t: number,
|
||||
): number {
|
||||
// Find bracketing samples that contain this property
|
||||
let before: { time: number; value: number } | undefined;
|
||||
let after: { time: number; value: number } | undefined;
|
||||
|
||||
for (const s of samples) {
|
||||
if (!(key in s.properties)) continue;
|
||||
const v = s.properties[key];
|
||||
|
||||
if (s.time <= t) {
|
||||
before = { time: s.time, value: v };
|
||||
}
|
||||
if (s.time >= t && after === undefined) {
|
||||
after = { time: s.time, value: v };
|
||||
}
|
||||
}
|
||||
|
||||
// Exact match or only one side available
|
||||
if (before && before.time === t) return before.value;
|
||||
if (after && after.time === t) return after.value;
|
||||
if (!before) return after!.value;
|
||||
if (!after) return before.value;
|
||||
|
||||
// Linear interpolation
|
||||
const ratio = (t - before.time) / (after.time - before.time);
|
||||
return before.value + (after.value - before.value) * ratio;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { extendRootDurationInSource } from "./rootDuration";
|
||||
|
||||
describe("extendRootDurationInSource", () => {
|
||||
it("extends data-duration when the new end is bigger than the root duration", () => {
|
||||
const source = [
|
||||
`<div data-composition-id="main" data-duration="4">`,
|
||||
` <section id="clip" data-start="2" data-duration="3"></section>`,
|
||||
`</div>`,
|
||||
].join("\n");
|
||||
|
||||
expect(extendRootDurationInSource(source, 5.25)).toContain(
|
||||
`data-composition-id="main" data-duration="5.25"`,
|
||||
);
|
||||
});
|
||||
|
||||
it("does nothing when the new end is smaller than or equal to the root duration", () => {
|
||||
const source = `<div data-composition-id="main" data-duration="6"></div>`;
|
||||
|
||||
expect(extendRootDurationInSource(source, 5)).toBe(source);
|
||||
expect(extendRootDurationInSource(source, 6)).toBe(source);
|
||||
});
|
||||
|
||||
it("leaves non-root data-duration attributes untouched by the extension", () => {
|
||||
const source = [
|
||||
`<div data-duration="3"></div>`,
|
||||
`<div data-composition-id="main" data-duration="4"></div>`,
|
||||
].join("\n");
|
||||
const patched = extendRootDurationInSource(source, 7);
|
||||
|
||||
expect(patched).toContain(`<div data-duration="3"></div>`);
|
||||
expect(patched).toContain(`<div data-composition-id="main" data-duration="7"></div>`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { formatTimelineAttributeNumber } from "../player/components/timelineEditing";
|
||||
|
||||
export function extendRootDurationInSource(source: string, newEnd: number): string {
|
||||
const rootDurMatch = source.match(
|
||||
/(<[^>]*data-composition-id="[^"]*"[^>]*data-duration=")([^"]*)(")/,
|
||||
);
|
||||
if (rootDurMatch) {
|
||||
const rootDur = parseFloat(rootDurMatch[2]!);
|
||||
if (newEnd > rootDur) {
|
||||
return source.replace(
|
||||
rootDurMatch[0],
|
||||
`${rootDurMatch[1]}${formatTimelineAttributeNumber(newEnd)}${rootDurMatch[3]}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return source;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/** Round to 3 decimal places (millisecond precision for GSAP values). */
|
||||
export function roundTo3(val: number): number {
|
||||
return Math.round(val * 1000) / 1000;
|
||||
}
|
||||
|
||||
/** Round to 2 decimal places (centisecond precision for timeline values). */
|
||||
export function roundToCenti(val: number): number {
|
||||
return Math.round(val * 100) / 100;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// Best-effort access to Web Storage. Reading the `localStorage` /
|
||||
// `sessionStorage` globals can throw (SSR, storage disabled, sandboxed or
|
||||
// partitioned browsing contexts), so callers get `null` instead of an
|
||||
// exception — telemetry must never break Studio.
|
||||
|
||||
export function safeLocalStorage(): Storage | null {
|
||||
try {
|
||||
return typeof localStorage === "undefined" ? null : localStorage;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function safeSessionStorage(): Storage | null {
|
||||
try {
|
||||
return typeof sessionStorage === "undefined" ? null : sessionStorage;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Dark-launch contract: with STUDIO_SDK_CUTOVER_ENABLED=false, EVERY cutover
|
||||
// persist chokepoint must return false so the caller takes the legacy server
|
||||
// path — even when a valid SDK session exists (one always does, for
|
||||
// shadow/selection). This is the contract the prod flag-flip rests on; a future
|
||||
// refactor of the gate guards that silently re-enables cutover on flag-off
|
||||
// turns these red. (sdkCutover.test.ts mocks the flag TRUE; this is its sibling.)
|
||||
vi.mock("../components/editor/manualEditingAvailability", () => ({
|
||||
STUDIO_SDK_CUTOVER_ENABLED: false,
|
||||
STUDIO_SDK_RESOLVER_SHADOW_ENABLED: false,
|
||||
}));
|
||||
vi.mock("./studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
|
||||
|
||||
import { sdkTimingPersist, sdkGsapTweenPersist, sdkDeletePersist } from "./sdkCutover";
|
||||
|
||||
const makeSession = () =>
|
||||
({
|
||||
getElement: () => ({ inlineStyles: {} }),
|
||||
serialize: () => "<html></html>",
|
||||
batch: (fn: () => void) => fn(),
|
||||
setTiming: vi.fn(),
|
||||
dispatch: vi.fn(),
|
||||
}) as never;
|
||||
|
||||
const makeDeps = () =>
|
||||
({
|
||||
editHistory: { recordEdit: vi.fn().mockResolvedValue(undefined) },
|
||||
writeProjectFile: vi.fn().mockResolvedValue(undefined),
|
||||
reloadPreview: vi.fn(),
|
||||
domEditSaveTimestampRef: { current: 0 },
|
||||
}) as never;
|
||||
|
||||
describe("dark-launch gate — STUDIO_SDK_CUTOVER_ENABLED=false ⇒ persist returns false", () => {
|
||||
it("sdkTimingPersist falls back without writing", async () => {
|
||||
const deps = makeDeps();
|
||||
expect(await sdkTimingPersist("hf-a", "/c.html", { start: 1 }, makeSession(), deps)).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
(deps as unknown as { writeProjectFile: ReturnType<typeof vi.fn> }).writeProjectFile,
|
||||
).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sdkGsapTweenPersist (shared GSAP-op chokepoint) falls back", async () => {
|
||||
expect(
|
||||
await sdkGsapTweenPersist(
|
||||
"/c.html",
|
||||
{ kind: "remove", animationId: "a" },
|
||||
makeSession(),
|
||||
makeDeps(),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("sdkDeletePersist falls back", async () => {
|
||||
expect(
|
||||
await sdkDeletePersist("hf-a", "<html></html>", "/c.html", makeSession(), makeDeps()),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,890 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
shouldUseSdkCutover,
|
||||
sdkCutoverPersist,
|
||||
sdkDeletePersist,
|
||||
sdkTimingPersist,
|
||||
sdkGsapTweenPersist,
|
||||
sdkGsapKeyframePersist,
|
||||
} from "./sdkCutover";
|
||||
import { openComposition } from "@hyperframes/sdk";
|
||||
import { createMemoryAdapter } from "@hyperframes/sdk/adapters/memory";
|
||||
import type { PatchOperation } from "./sourcePatcher";
|
||||
import type { MutableRefObject } from "react";
|
||||
|
||||
vi.mock("../components/editor/manualEditingAvailability", () => ({
|
||||
STUDIO_SDK_CUTOVER_ENABLED: true,
|
||||
STUDIO_SDK_RESOLVER_SHADOW_ENABLED: false,
|
||||
}));
|
||||
vi.mock("./studioTelemetry", () => ({
|
||||
trackStudioEvent: vi.fn(),
|
||||
}));
|
||||
|
||||
const styleOp = (property: string, value: string): PatchOperation => ({
|
||||
type: "inline-style",
|
||||
property,
|
||||
value,
|
||||
});
|
||||
|
||||
const textOp = (value: string): PatchOperation => ({
|
||||
type: "text-content",
|
||||
property: "text",
|
||||
value,
|
||||
});
|
||||
|
||||
const attrOp = (property: string, value: string): PatchOperation => ({
|
||||
type: "attribute",
|
||||
property,
|
||||
value,
|
||||
});
|
||||
|
||||
const htmlAttrOp = (property: string, value: string): PatchOperation => ({
|
||||
type: "html-attribute",
|
||||
property,
|
||||
value,
|
||||
});
|
||||
|
||||
describe("shouldUseSdkCutover", () => {
|
||||
it("returns false when flag disabled", () => {
|
||||
expect(shouldUseSdkCutover(false, true, "hf-abc", [styleOp("color", "red")])).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when no session", () => {
|
||||
expect(shouldUseSdkCutover(true, false, "hf-abc", [styleOp("color", "red")])).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when no hfId", () => {
|
||||
expect(shouldUseSdkCutover(true, true, null, [styleOp("color", "red")])).toBe(false);
|
||||
expect(shouldUseSdkCutover(true, true, undefined, [styleOp("color", "red")])).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when ops empty", () => {
|
||||
expect(shouldUseSdkCutover(true, true, "hf-abc", [])).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true for inline-style ops", () => {
|
||||
expect(shouldUseSdkCutover(true, true, "hf-abc", [styleOp("color", "red")])).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for text-content ops", () => {
|
||||
expect(shouldUseSdkCutover(true, true, "hf-abc", [textOp("hello")])).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for attribute ops", () => {
|
||||
expect(shouldUseSdkCutover(true, true, "hf-abc", [attrOp("data-x", "10")])).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for html-attribute ops", () => {
|
||||
expect(shouldUseSdkCutover(true, true, "hf-abc", [htmlAttrOp("class", "foo")])).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for an attribute op that maps to a reserved data-* name", () => {
|
||||
// {type:'attribute', property:'end'} → 'data-end', which the SDK's
|
||||
// validateSetAttribute rejects. Decline the batch so it takes the server
|
||||
// path cleanly instead of throwing inside dispatch and falling back per op.
|
||||
expect(shouldUseSdkCutover(true, true, "hf-abc", [attrOp("end", "2")])).toBe(false);
|
||||
expect(shouldUseSdkCutover(true, true, "hf-abc", [attrOp("data-start", "1")])).toBe(false);
|
||||
});
|
||||
|
||||
it("declines a case-variant reserved attribute (SDK lowercases before checking)", () => {
|
||||
// attribute op "END" → "data-END" → lower → "data-end" (reserved).
|
||||
expect(shouldUseSdkCutover(true, true, "hf-abc", [attrOp("END", "2")])).toBe(false);
|
||||
});
|
||||
|
||||
it("declines an html-attribute op whose raw name is reserved", () => {
|
||||
// html-attribute ops aren't data-prefixed, so a raw reserved name must still
|
||||
// be caught (the SDK throws on it just the same).
|
||||
expect(shouldUseSdkCutover(true, true, "hf-abc", [htmlAttrOp("data-end", "3")])).toBe(false);
|
||||
expect(shouldUseSdkCutover(true, true, "hf-abc", [htmlAttrOp("DATA-START", "1")])).toBe(false);
|
||||
});
|
||||
|
||||
it("declines html-attribute ops with event handler names", () => {
|
||||
expect(shouldUseSdkCutover(true, true, "hf-abc", [htmlAttrOp("onclick", "alert(1)")])).toBe(
|
||||
false,
|
||||
);
|
||||
expect(shouldUseSdkCutover(true, true, "hf-abc", [htmlAttrOp("onload", "fetch()")])).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("declines html-attribute ops with disallowed attribute names", () => {
|
||||
expect(shouldUseSdkCutover(true, true, "hf-abc", [htmlAttrOp("formaction", "/x")])).toBe(false);
|
||||
});
|
||||
|
||||
it("declines html-attribute ops with dangerous URI schemes", () => {
|
||||
expect(
|
||||
shouldUseSdkCutover(true, true, "hf-abc", [htmlAttrOp("href", "javascript:alert(1)")]),
|
||||
).toBe(false);
|
||||
expect(shouldUseSdkCutover(true, true, "hf-abc", [htmlAttrOp("src", "vbscript:run")])).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("declines html-attribute ops with dangerous data URIs", () => {
|
||||
expect(
|
||||
shouldUseSdkCutover(true, true, "hf-abc", [
|
||||
htmlAttrOp("href", "data:text/html,<script>alert(1)</script>"),
|
||||
]),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when ops mix all supported types", () => {
|
||||
expect(
|
||||
shouldUseSdkCutover(true, true, "hf-abc", [
|
||||
styleOp("color", "red"),
|
||||
textOp("hello"),
|
||||
attrOp("x", "1"),
|
||||
htmlAttrOp("class", "foo"),
|
||||
]),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sdkCutoverPersist", () => {
|
||||
const makeRef = <T>(val: T): MutableRefObject<T> => ({ current: val });
|
||||
|
||||
const makeDeps = (overrides: Partial<Parameters<typeof sdkCutoverPersist>[5]> = {}) => ({
|
||||
editHistory: { recordEdit: vi.fn().mockResolvedValue(undefined) },
|
||||
writeProjectFile: vi.fn().mockResolvedValue(undefined),
|
||||
reloadPreview: vi.fn(),
|
||||
domEditSaveTimestampRef: makeRef(0),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeSession = (hasEl = true) =>
|
||||
({
|
||||
getElement: vi.fn().mockReturnValue(hasEl ? { inlineStyles: {} } : null),
|
||||
dispatch: vi.fn(),
|
||||
// Distinct before/after so the no-op guard (after === before → fall back)
|
||||
// treats this as a real change; "after" matches the write assertions.
|
||||
serialize: vi
|
||||
.fn()
|
||||
.mockReturnValueOnce("<html>before</html>")
|
||||
.mockReturnValue("<html></html>"),
|
||||
batch: vi.fn((fn: () => void) => fn()),
|
||||
}) as unknown as Parameters<typeof sdkCutoverPersist>[4];
|
||||
|
||||
it("returns false when session is null", async () => {
|
||||
const deps = makeDeps();
|
||||
const sel = { hfId: "hf-abc" } as never;
|
||||
const result = await sdkCutoverPersist(
|
||||
sel,
|
||||
[styleOp("color", "red")],
|
||||
"before",
|
||||
"/path.html",
|
||||
null,
|
||||
deps,
|
||||
);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when element not found in session", async () => {
|
||||
const deps = makeDeps();
|
||||
const session = makeSession(false);
|
||||
const sel = { hfId: "hf-abc" } as never;
|
||||
const result = await sdkCutoverPersist(
|
||||
sel,
|
||||
[styleOp("color", "red")],
|
||||
"before",
|
||||
"/path.html",
|
||||
session,
|
||||
deps,
|
||||
);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("dispatches setStyle for inline-style ops", async () => {
|
||||
const deps = makeDeps();
|
||||
const session = makeSession(true);
|
||||
const sel = { hfId: "hf-abc" } as never;
|
||||
const result = await sdkCutoverPersist(
|
||||
sel,
|
||||
[styleOp("color", "red"), styleOp("opacity", "0.5")],
|
||||
"before",
|
||||
"/comp.html",
|
||||
session,
|
||||
deps,
|
||||
);
|
||||
expect(result).toBe(true);
|
||||
expect(session!.dispatch).toHaveBeenCalledWith({
|
||||
type: "setStyle",
|
||||
target: "hf-abc",
|
||||
styles: { color: "red", opacity: "0.5" },
|
||||
});
|
||||
expect(deps.writeProjectFile).toHaveBeenCalledWith("/comp.html", "<html></html>");
|
||||
expect(deps.reloadPreview).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("dispatches setText for text-content op", async () => {
|
||||
const deps = makeDeps();
|
||||
const session = makeSession(true);
|
||||
const sel = { hfId: "hf-abc" } as never;
|
||||
const result = await sdkCutoverPersist(
|
||||
sel,
|
||||
[textOp("Hello world")],
|
||||
"before",
|
||||
"/comp.html",
|
||||
session,
|
||||
deps,
|
||||
);
|
||||
expect(result).toBe(true);
|
||||
expect(session!.dispatch).toHaveBeenCalledWith({
|
||||
type: "setText",
|
||||
target: "hf-abc",
|
||||
value: "Hello world",
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ name: "multi-child targets", children: [{ id: "a" }, { id: "b" }] },
|
||||
{ name: "single non-html children", children: [{ id: "a", tag: "svg" }] },
|
||||
])("declines text-content cutover for $name", async ({ children }) => {
|
||||
const deps = makeDeps();
|
||||
const session = makeSession(true);
|
||||
(session!.getElement as ReturnType<typeof vi.fn>).mockReturnValue({ children });
|
||||
const sel = { hfId: "hf-abc" } as never;
|
||||
const result = await sdkCutoverPersist(
|
||||
sel,
|
||||
[textOp("Hello world")],
|
||||
"before",
|
||||
"/comp.html",
|
||||
session,
|
||||
deps,
|
||||
);
|
||||
expect(result).toBe(false);
|
||||
expect(session!.dispatch).not.toHaveBeenCalled();
|
||||
expect(deps.writeProjectFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("dispatches setAttribute for attribute op with data- prefix", async () => {
|
||||
const deps = makeDeps();
|
||||
const session = makeSession(true);
|
||||
const sel = { hfId: "hf-abc" } as never;
|
||||
const result = await sdkCutoverPersist(
|
||||
sel,
|
||||
[attrOp("x", "42")],
|
||||
"before",
|
||||
"/comp.html",
|
||||
session,
|
||||
deps,
|
||||
);
|
||||
expect(result).toBe(true);
|
||||
expect(session!.dispatch).toHaveBeenCalledWith({
|
||||
type: "setAttribute",
|
||||
target: "hf-abc",
|
||||
name: "data-x",
|
||||
value: "42",
|
||||
});
|
||||
});
|
||||
|
||||
it("dispatches setAttribute for html-attribute op", async () => {
|
||||
const deps = makeDeps();
|
||||
const session = makeSession(true);
|
||||
const sel = { hfId: "hf-abc" } as never;
|
||||
const result = await sdkCutoverPersist(
|
||||
sel,
|
||||
[htmlAttrOp("class", "foo bar")],
|
||||
"before",
|
||||
"/comp.html",
|
||||
session,
|
||||
deps,
|
||||
);
|
||||
expect(result).toBe(true);
|
||||
expect(session!.dispatch).toHaveBeenCalledWith({
|
||||
type: "setAttribute",
|
||||
target: "hf-abc",
|
||||
name: "class",
|
||||
value: "foo bar",
|
||||
});
|
||||
});
|
||||
|
||||
it("passes caller label to recordEdit", async () => {
|
||||
const deps = makeDeps();
|
||||
const session = makeSession(true);
|
||||
const sel = { hfId: "hf-abc" } as never;
|
||||
await sdkCutoverPersist(sel, [styleOp("color", "red")], "before", "/comp.html", session, deps, {
|
||||
label: "Resize layer box",
|
||||
});
|
||||
expect(deps.editHistory.recordEdit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ label: "Resize layer box" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("passes caller coalesceKey to recordEdit", async () => {
|
||||
const deps = makeDeps();
|
||||
const session = makeSession(true);
|
||||
const sel = { hfId: "hf-abc" } as never;
|
||||
await sdkCutoverPersist(sel, [styleOp("color", "red")], "before", "/comp.html", session, deps, {
|
||||
coalesceKey: "my-key",
|
||||
});
|
||||
expect(deps.editHistory.recordEdit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ coalesceKey: "my-key" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns false and does not throw on dispatch error", async () => {
|
||||
const deps = makeDeps();
|
||||
const session = makeSession(true);
|
||||
(session!.dispatch as ReturnType<typeof vi.fn>).mockImplementation(() => {
|
||||
throw new Error("dispatch failed");
|
||||
});
|
||||
const sel = { hfId: "hf-abc" } as never;
|
||||
const result = await sdkCutoverPersist(
|
||||
sel,
|
||||
[styleOp("color", "red")],
|
||||
"before",
|
||||
"/comp.html",
|
||||
session,
|
||||
deps,
|
||||
);
|
||||
expect(result).toBe(false);
|
||||
expect(deps.reloadPreview).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("wraps all dispatches in session.batch() for atomic rollback", async () => {
|
||||
const deps = makeDeps();
|
||||
const session = makeSession(true);
|
||||
const sel = { hfId: "hf-abc" } as never;
|
||||
await sdkCutoverPersist(
|
||||
sel,
|
||||
[styleOp("color", "red"), styleOp("opacity", "0.5")],
|
||||
"before",
|
||||
"/comp.html",
|
||||
session,
|
||||
deps,
|
||||
);
|
||||
expect(
|
||||
(session as unknown as { batch: ReturnType<typeof vi.fn> }).batch,
|
||||
).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("returns false when second dispatch throws (batch prevents partial mutation)", async () => {
|
||||
// inline-style ops coalesce into one setStyle dispatch; use style+text to produce two dispatches.
|
||||
const deps = makeDeps();
|
||||
const session = makeSession(true);
|
||||
let callCount = 0;
|
||||
(session!.dispatch as ReturnType<typeof vi.fn>).mockImplementation(() => {
|
||||
callCount++;
|
||||
if (callCount === 2) throw new Error("2nd op failed");
|
||||
});
|
||||
const sel = { hfId: "hf-abc" } as never;
|
||||
const result = await sdkCutoverPersist(
|
||||
sel,
|
||||
[styleOp("color", "red"), textOp("hello")],
|
||||
"before",
|
||||
"/comp.html",
|
||||
session,
|
||||
deps,
|
||||
);
|
||||
expect(result).toBe(false);
|
||||
expect(deps.writeProjectFile).not.toHaveBeenCalled();
|
||||
expect(deps.reloadPreview).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("sdkDeletePersist", () => {
|
||||
const makeRef = <T>(val: T): MutableRefObject<T> => ({ current: val });
|
||||
const makeDeps = () => ({
|
||||
editHistory: { recordEdit: vi.fn().mockResolvedValue(undefined) },
|
||||
writeProjectFile: vi.fn().mockResolvedValue(undefined),
|
||||
reloadPreview: vi.fn(),
|
||||
domEditSaveTimestampRef: makeRef(0),
|
||||
});
|
||||
|
||||
const makeSession = (hasEl = true) =>
|
||||
({
|
||||
getElement: vi.fn().mockReturnValue(hasEl ? { id: "hf-abc" } : null),
|
||||
removeElement: vi.fn(),
|
||||
serialize: vi
|
||||
.fn()
|
||||
.mockReturnValueOnce("<html>before-snap</html>")
|
||||
.mockReturnValue("<html>after</html>"),
|
||||
batch: vi.fn((fn: () => void) => fn()),
|
||||
}) as unknown as Parameters<typeof sdkDeletePersist>[3];
|
||||
|
||||
it("returns false when session is null", async () => {
|
||||
expect(await sdkDeletePersist("hf-abc", "before", "/comp.html", null, makeDeps())).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when element not found in session", async () => {
|
||||
const session = makeSession(false);
|
||||
expect(await sdkDeletePersist("hf-abc", "before", "/comp.html", session, makeDeps())).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("calls removeElement and writes serialized content", async () => {
|
||||
const deps = makeDeps();
|
||||
const session = makeSession(true);
|
||||
const result = await sdkDeletePersist("hf-abc", "before", "/comp.html", session, deps);
|
||||
expect(result).toBe(true);
|
||||
expect(session!.removeElement).toHaveBeenCalledWith("hf-abc");
|
||||
expect(deps.writeProjectFile).toHaveBeenCalledWith("/comp.html", "<html>after</html>");
|
||||
});
|
||||
|
||||
it("records edit history with before/after diff", async () => {
|
||||
const deps = makeDeps();
|
||||
const session = makeSession(true);
|
||||
await sdkDeletePersist("hf-abc", "before-content", "/comp.html", session, deps);
|
||||
expect(deps.editHistory.recordEdit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
label: "Delete element",
|
||||
files: { "/comp.html": { before: "before-content", after: "<html>after</html>" } },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("calls reloadPreview on success", async () => {
|
||||
const deps = makeDeps();
|
||||
const session = makeSession(true);
|
||||
await sdkDeletePersist("hf-abc", "before", "/comp.html", session, deps);
|
||||
expect(deps.reloadPreview).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns false and does not write on removeElement error", async () => {
|
||||
const deps = makeDeps();
|
||||
const session = makeSession(true);
|
||||
(session!.removeElement as ReturnType<typeof vi.fn>).mockImplementation(() => {
|
||||
throw new Error("remove failed");
|
||||
});
|
||||
const result = await sdkDeletePersist("hf-abc", "before", "/comp.html", session, deps);
|
||||
expect(result).toBe(false);
|
||||
expect(deps.writeProjectFile).not.toHaveBeenCalled();
|
||||
expect(deps.reloadPreview).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("sdkTimingPersist", () => {
|
||||
const makeRef = <T>(val: T): MutableRefObject<T> => ({ current: val });
|
||||
const makeDeps = () => ({
|
||||
editHistory: { recordEdit: vi.fn().mockResolvedValue(undefined) },
|
||||
writeProjectFile: vi.fn().mockResolvedValue(undefined),
|
||||
reloadPreview: vi.fn(),
|
||||
domEditSaveTimestampRef: makeRef(0),
|
||||
});
|
||||
|
||||
const makeSession = (hasEl = true) =>
|
||||
({
|
||||
getElement: vi.fn().mockReturnValue(hasEl ? { id: "hf-clip" } : null),
|
||||
setTiming: vi.fn(),
|
||||
serialize: vi
|
||||
.fn()
|
||||
.mockReturnValueOnce("<html>before</html>")
|
||||
.mockReturnValue("<html>after</html>"),
|
||||
batch: vi.fn((fn: () => void) => fn()),
|
||||
}) as unknown as Parameters<typeof sdkTimingPersist>[3];
|
||||
|
||||
it("returns false when session is null", async () => {
|
||||
expect(await sdkTimingPersist("hf-clip", "/comp.html", { start: 1 }, null, makeDeps())).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns false when element not found in session", async () => {
|
||||
const session = makeSession(false);
|
||||
expect(await sdkTimingPersist("hf-clip", "/comp.html", { start: 1 }, session, makeDeps())).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("calls setTiming with provided update and writes serialized content", async () => {
|
||||
const deps = makeDeps();
|
||||
const session = makeSession(true);
|
||||
const result = await sdkTimingPersist(
|
||||
"hf-clip",
|
||||
"/comp.html",
|
||||
{ start: 2, duration: 5, trackIndex: 1 },
|
||||
session,
|
||||
deps,
|
||||
);
|
||||
expect(result).toBe(true);
|
||||
expect(session!.setTiming).toHaveBeenCalledWith("hf-clip", {
|
||||
start: 2,
|
||||
duration: 5,
|
||||
trackIndex: 1,
|
||||
});
|
||||
expect(deps.writeProjectFile).toHaveBeenCalledWith("/comp.html", "<html>after</html>");
|
||||
});
|
||||
|
||||
it("captures before-state before setTiming dispatch", async () => {
|
||||
const deps = makeDeps();
|
||||
const session = makeSession(true);
|
||||
await sdkTimingPersist("hf-clip", "/comp.html", { start: 3 }, session, deps);
|
||||
expect(deps.editHistory.recordEdit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
files: { "/comp.html": { before: "<html>before</html>", after: "<html>after</html>" } },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns false and does not write on setTiming error", async () => {
|
||||
const deps = makeDeps();
|
||||
const session = makeSession(true);
|
||||
(session!.setTiming as ReturnType<typeof vi.fn>).mockImplementation(() => {
|
||||
throw new Error("timing error");
|
||||
});
|
||||
const result = await sdkTimingPersist("hf-clip", "/comp.html", { start: 1 }, session, deps);
|
||||
expect(result).toBe(false);
|
||||
expect(deps.writeProjectFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Finding #12: undo baseline must be the EXACT on-disk bytes (matching the
|
||||
// style/delete paths), not a normalized SDK serialize() re-emit — otherwise
|
||||
// undoing a timing edit reformats the whole file.
|
||||
it("records the on-disk content (not serialize()) as the undo before when a reader is provided", async () => {
|
||||
const deps = {
|
||||
...makeDeps(),
|
||||
readProjectFile: vi.fn().mockResolvedValue("<html>EXACT ON-DISK BYTES</html>"),
|
||||
};
|
||||
const session = makeSession(true);
|
||||
await sdkTimingPersist("hf-clip", "/comp.html", { start: 3 }, session, deps);
|
||||
expect(deps.readProjectFile).toHaveBeenCalledWith("/comp.html");
|
||||
expect(deps.editHistory.recordEdit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
files: {
|
||||
"/comp.html": { before: "<html>EXACT ON-DISK BYTES</html>", after: "<html>after</html>" },
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to serialize() before when the reader throws", async () => {
|
||||
const deps = {
|
||||
...makeDeps(),
|
||||
readProjectFile: vi.fn().mockRejectedValue(new Error("read failed")),
|
||||
};
|
||||
const session = makeSession(true);
|
||||
await sdkTimingPersist("hf-clip", "/comp.html", { start: 3 }, session, deps);
|
||||
expect(deps.editHistory.recordEdit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
files: { "/comp.html": { before: "<html>before</html>", after: "<html>after</html>" } },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sdkGsapTweenPersist — undo baseline (finding #12)", () => {
|
||||
const makeRef = <T>(val: T): MutableRefObject<T> => ({ current: val });
|
||||
const makeSession = () =>
|
||||
({
|
||||
getElement: vi.fn().mockReturnValue({ id: "hf-box" }),
|
||||
setGsapTween: vi.fn(),
|
||||
serialize: vi
|
||||
.fn()
|
||||
.mockReturnValueOnce("<html>serialized-before</html>")
|
||||
.mockReturnValue("<html>after</html>"),
|
||||
batch: vi.fn((fn: () => void) => fn()),
|
||||
}) as unknown as Parameters<typeof sdkGsapTweenPersist>[2];
|
||||
|
||||
it("records the on-disk content as the undo before, not serialize()", async () => {
|
||||
const deps = {
|
||||
editHistory: { recordEdit: vi.fn().mockResolvedValue(undefined) },
|
||||
writeProjectFile: vi.fn().mockResolvedValue(undefined),
|
||||
reloadPreview: vi.fn(),
|
||||
domEditSaveTimestampRef: makeRef(0),
|
||||
readProjectFile: vi.fn().mockResolvedValue("<html>on-disk gsap bytes</html>"),
|
||||
};
|
||||
const session = makeSession();
|
||||
await sdkGsapTweenPersist(
|
||||
"/comp.html",
|
||||
{ kind: "set", animationId: "tw-1", properties: { ease: "power3.in" } },
|
||||
session,
|
||||
deps,
|
||||
);
|
||||
expect(deps.editHistory.recordEdit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
files: {
|
||||
"/comp.html": { before: "<html>on-disk gsap bytes</html>", after: "<html>after</html>" },
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sdkGsapTweenPersist — per-file serialization (finding #8)", () => {
|
||||
const makeRef = <T>(val: T): MutableRefObject<T> => ({ current: val });
|
||||
|
||||
it("routes the read-modify-write through the keyed serializer so same-file flushes can't interleave", async () => {
|
||||
const order: string[] = [];
|
||||
let writeResolve: (() => void) | null = null;
|
||||
const deps = {
|
||||
editHistory: { recordEdit: vi.fn().mockResolvedValue(undefined) },
|
||||
// First write blocks until we release it, so without serialization the
|
||||
// second op's serialize()/dispatch would interleave ahead of it.
|
||||
writeProjectFile: vi.fn().mockImplementation((_p: string, content: string) => {
|
||||
order.push(`write-start:${content}`);
|
||||
if (content === "<html>after-1</html>") {
|
||||
return new Promise<void>((res) => {
|
||||
writeResolve = () => {
|
||||
order.push(`write-done:${content}`);
|
||||
res();
|
||||
};
|
||||
});
|
||||
}
|
||||
order.push(`write-done:${content}`);
|
||||
return Promise.resolve();
|
||||
}),
|
||||
reloadPreview: vi.fn(),
|
||||
domEditSaveTimestampRef: makeRef(0),
|
||||
// A real per-key serializer: tasks under the same key run strictly in order.
|
||||
serialize: (() => {
|
||||
const inFlight = new Map<string, Promise<unknown>>();
|
||||
return <T>(key: string, task: () => Promise<T>): Promise<T> => {
|
||||
const prior = inFlight.get(key) ?? Promise.resolve();
|
||||
const next = prior.then(task, task);
|
||||
inFlight.set(key, next);
|
||||
return next as Promise<T>;
|
||||
};
|
||||
})(),
|
||||
};
|
||||
|
||||
let serializeCall = 0;
|
||||
const session = {
|
||||
getElement: vi.fn().mockReturnValue({ id: "hf-box" }),
|
||||
setGsapTween: vi.fn(() => order.push("dispatch")),
|
||||
serialize: vi.fn(() => {
|
||||
serializeCall++;
|
||||
// before-1, after-1, before-2, after-2
|
||||
return `<html>${serializeCall % 2 === 1 ? "before" : "after"}-${Math.ceil(serializeCall / 2)}</html>`;
|
||||
}),
|
||||
batch: vi.fn((fn: () => void) => fn()),
|
||||
} as unknown as Parameters<typeof sdkGsapTweenPersist>[2];
|
||||
|
||||
const p1 = sdkGsapTweenPersist(
|
||||
"/comp.html",
|
||||
{ kind: "set", animationId: "tw-1", properties: { ease: "a" } },
|
||||
session,
|
||||
deps,
|
||||
);
|
||||
const p2 = sdkGsapTweenPersist(
|
||||
"/comp.html",
|
||||
{ kind: "set", animationId: "tw-1", properties: { ease: "b" } },
|
||||
session,
|
||||
deps,
|
||||
);
|
||||
// Let the first op reach its (blocked) write before releasing it.
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
writeResolve?.();
|
||||
await Promise.all([p1, p2]);
|
||||
|
||||
// The second op's write must NOT start before the first op's write completes.
|
||||
const firstWriteDone = order.indexOf("write-done:<html>after-1</html>");
|
||||
const secondWriteStart = order.indexOf("write-start:<html>after-2</html>");
|
||||
expect(firstWriteDone).toBeGreaterThanOrEqual(0);
|
||||
expect(secondWriteStart).toBeGreaterThan(firstWriteDone);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sdkGsapTweenPersist", () => {
|
||||
const makeRef = <T>(val: T): MutableRefObject<T> => ({ current: val });
|
||||
const makeDeps = () => ({
|
||||
editHistory: { recordEdit: vi.fn().mockResolvedValue(undefined) },
|
||||
writeProjectFile: vi.fn().mockResolvedValue(undefined),
|
||||
reloadPreview: vi.fn(),
|
||||
domEditSaveTimestampRef: makeRef(0),
|
||||
});
|
||||
|
||||
const makeSession = (opts?: { addGsapTween?: string; hasEl?: boolean }) =>
|
||||
({
|
||||
getElement: vi.fn().mockReturnValue(opts?.hasEl !== false ? { id: "hf-box" } : null),
|
||||
addGsapTween: vi.fn().mockReturnValue(opts?.addGsapTween ?? "tw-1"),
|
||||
setGsapTween: vi.fn(),
|
||||
removeGsapTween: vi.fn(),
|
||||
serialize: vi
|
||||
.fn()
|
||||
.mockReturnValueOnce("<html>before</html>")
|
||||
.mockReturnValue("<html>after</html>"),
|
||||
batch: vi.fn((fn: () => void) => fn()),
|
||||
}) as unknown as Parameters<typeof sdkGsapTweenPersist>[2];
|
||||
|
||||
it("returns false when session is null", async () => {
|
||||
expect(
|
||||
await sdkGsapTweenPersist(
|
||||
"/comp.html",
|
||||
{ kind: "remove", animationId: "tw-1" },
|
||||
null,
|
||||
makeDeps(),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("calls addGsapTween and writes for kind=add", async () => {
|
||||
const deps = makeDeps();
|
||||
const session = makeSession();
|
||||
const result = await sdkGsapTweenPersist(
|
||||
"/comp.html",
|
||||
{
|
||||
kind: "add",
|
||||
target: "hf-box",
|
||||
spec: { method: "to", duration: 1, properties: { opacity: 1 } },
|
||||
},
|
||||
session,
|
||||
deps,
|
||||
);
|
||||
expect(result).toBe(true);
|
||||
expect(session!.addGsapTween).toHaveBeenCalledWith(
|
||||
"hf-box",
|
||||
expect.objectContaining({ method: "to" }),
|
||||
);
|
||||
expect(deps.writeProjectFile).toHaveBeenCalledWith("/comp.html", "<html>after</html>");
|
||||
});
|
||||
|
||||
it("returns false for kind=add when element not found", async () => {
|
||||
const deps = makeDeps();
|
||||
const session = makeSession({ hasEl: false });
|
||||
const result = await sdkGsapTweenPersist(
|
||||
"/comp.html",
|
||||
{ kind: "add", target: "hf-box", spec: { method: "to", properties: { x: 100 } } },
|
||||
session,
|
||||
deps,
|
||||
);
|
||||
expect(result).toBe(false);
|
||||
expect(deps.writeProjectFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls setGsapTween and writes for kind=set", async () => {
|
||||
const deps = makeDeps();
|
||||
const session = makeSession();
|
||||
const result = await sdkGsapTweenPersist(
|
||||
"/comp.html",
|
||||
{ kind: "set", animationId: "tw-1", properties: { ease: "power3.in" } },
|
||||
session,
|
||||
deps,
|
||||
);
|
||||
expect(result).toBe(true);
|
||||
expect(session!.setGsapTween).toHaveBeenCalledWith("tw-1", { ease: "power3.in" });
|
||||
expect(deps.reloadPreview).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls removeGsapTween for kind=remove", async () => {
|
||||
const deps = makeDeps();
|
||||
const session = makeSession();
|
||||
const result = await sdkGsapTweenPersist(
|
||||
"/comp.html",
|
||||
{ kind: "remove", animationId: "tw-1" },
|
||||
session,
|
||||
deps,
|
||||
);
|
||||
expect(result).toBe(true);
|
||||
expect(session!.removeGsapTween).toHaveBeenCalledWith("tw-1");
|
||||
});
|
||||
|
||||
it("returns false and does not write on SDK error", async () => {
|
||||
const deps = makeDeps();
|
||||
const session = makeSession();
|
||||
(session!.removeGsapTween as ReturnType<typeof vi.fn>).mockImplementation(() => {
|
||||
throw new Error("gsap error");
|
||||
});
|
||||
const result = await sdkGsapTweenPersist(
|
||||
"/comp.html",
|
||||
{ kind: "remove", animationId: "tw-1" },
|
||||
session,
|
||||
deps,
|
||||
);
|
||||
expect(result).toBe(false);
|
||||
expect(deps.writeProjectFile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("sdkGsapKeyframePersist", () => {
|
||||
const makeRef = <T>(val: T): MutableRefObject<T> => ({ current: val });
|
||||
const makeDeps = () => ({
|
||||
editHistory: { recordEdit: vi.fn().mockResolvedValue(undefined) },
|
||||
writeProjectFile: vi.fn().mockResolvedValue(undefined),
|
||||
reloadPreview: vi.fn(),
|
||||
domEditSaveTimestampRef: makeRef(0),
|
||||
});
|
||||
|
||||
const makeSession = () =>
|
||||
({
|
||||
dispatch: vi.fn(),
|
||||
serialize: vi
|
||||
.fn()
|
||||
.mockReturnValueOnce("<html>before</html>")
|
||||
.mockReturnValue("<html>after</html>"),
|
||||
batch: vi.fn((fn: () => void) => fn()),
|
||||
}) as unknown as Parameters<typeof sdkGsapKeyframePersist>[4];
|
||||
|
||||
it("returns false when session is null", async () => {
|
||||
expect(
|
||||
await sdkGsapKeyframePersist("/comp.html", "tw-1", 50, { opacity: 0.5 }, null, makeDeps()),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("dispatches addGsapKeyframe and writes serialized content", async () => {
|
||||
const deps = makeDeps();
|
||||
const session = makeSession();
|
||||
const result = await sdkGsapKeyframePersist(
|
||||
"/comp.html",
|
||||
"tw-1",
|
||||
50,
|
||||
{ opacity: 0.5 },
|
||||
session,
|
||||
deps,
|
||||
);
|
||||
expect(result).toBe(true);
|
||||
expect(session!.dispatch).toHaveBeenCalledWith({
|
||||
type: "addGsapKeyframe",
|
||||
animationId: "tw-1",
|
||||
position: 50,
|
||||
value: { opacity: 0.5 },
|
||||
});
|
||||
expect(deps.writeProjectFile).toHaveBeenCalledWith("/comp.html", "<html>after</html>");
|
||||
expect(deps.reloadPreview).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns false and does not write on dispatch error", async () => {
|
||||
const deps = makeDeps();
|
||||
const session = makeSession();
|
||||
(session!.dispatch as ReturnType<typeof vi.fn>).mockImplementation(() => {
|
||||
throw new Error("dispatch failed");
|
||||
});
|
||||
const result = await sdkGsapKeyframePersist(
|
||||
"/comp.html",
|
||||
"tw-1",
|
||||
25,
|
||||
{ x: 100 },
|
||||
session,
|
||||
deps,
|
||||
);
|
||||
expect(result).toBe(false);
|
||||
expect(deps.writeProjectFile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("sdkCutoverPersist — GSAP script preservation (integration)", () => {
|
||||
const makeRef = <T>(val: T): MutableRefObject<T> => ({ current: val });
|
||||
const makeDeps = () => ({
|
||||
editHistory: { recordEdit: vi.fn().mockResolvedValue(undefined) },
|
||||
writeProjectFile: vi.fn().mockResolvedValue(undefined),
|
||||
reloadPreview: vi.fn(),
|
||||
domEditSaveTimestampRef: makeRef(0),
|
||||
});
|
||||
|
||||
it("preserves GSAP <script> block and data-position-mode through setStyle dispatch", async () => {
|
||||
const html = `<!DOCTYPE html><html><head></head><body>
|
||||
<div data-hf-id="hf-layer" style="color: blue; opacity: 1"></div>
|
||||
<script data-hf-gsap data-position-mode="relative">
|
||||
gsap.timeline().to('[data-hf-id="hf-layer"]', { duration: 1, x: 100 });
|
||||
</script>
|
||||
</body></html>`;
|
||||
const comp = await openComposition(html, { persist: createMemoryAdapter() });
|
||||
const deps = makeDeps();
|
||||
const sel = { hfId: "hf-layer" } as never;
|
||||
const result = await sdkCutoverPersist(
|
||||
sel,
|
||||
[{ type: "inline-style", property: "color", value: "red" }],
|
||||
html,
|
||||
"/comp.html",
|
||||
comp,
|
||||
deps,
|
||||
);
|
||||
expect(result).toBe(true);
|
||||
const written = (deps.writeProjectFile as ReturnType<typeof vi.fn>).mock
|
||||
.calls[0]?.[1] as string;
|
||||
expect(written).toContain("data-hf-gsap");
|
||||
expect(written).toContain('data-position-mode="relative"');
|
||||
expect(written).toContain("gsap.timeline()");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,553 @@
|
||||
import type { MutableRefObject } from "react";
|
||||
import type { Composition, GsapTweenSpec } from "@hyperframes/sdk";
|
||||
import type { DomEditSelection } from "../components/editor/domEditing";
|
||||
import type { EditHistoryKind } from "./editHistory";
|
||||
import type { PatchOperation } from "./sourcePatcher";
|
||||
import { STUDIO_SDK_CUTOVER_ENABLED } from "../components/editor/manualEditingAvailability";
|
||||
import { trackStudioEvent } from "./studioTelemetry";
|
||||
import { markSelfWrite } from "../hooks/sdkSelfWriteRegistry";
|
||||
import { patchOpsToSdkEditOps } from "./sdkOpMapping";
|
||||
import { recordResolverParity, recordAnimationResolverParity } from "./sdkResolverShadow";
|
||||
import { shouldDeclineTextCutoverForTarget, shouldUseSdkCutover } from "./sdkCutoverEligibility";
|
||||
|
||||
export { shouldUseSdkCutover } from "./sdkCutoverEligibility";
|
||||
|
||||
export interface CutoverDeps {
|
||||
editHistory: {
|
||||
recordEdit: (entry: {
|
||||
label: string;
|
||||
kind: EditHistoryKind;
|
||||
coalesceKey?: string;
|
||||
files: Record<string, { before: string; after: string }>;
|
||||
}) => Promise<void>;
|
||||
};
|
||||
writeProjectFile: (path: string, content: string) => Promise<void>;
|
||||
reloadPreview: () => void;
|
||||
domEditSaveTimestampRef: MutableRefObject<number>;
|
||||
/**
|
||||
* Optional post-write refresh. When provided, it REPLACES the default
|
||||
* reloadPreview() — the GSAP path passes one that soft-reloads (preserving
|
||||
* the playhead) and invalidates the keyframe/gsap panel cache. Receives the
|
||||
* serialized document just written.
|
||||
*/
|
||||
refresh?: (after: string) => void;
|
||||
/**
|
||||
* Path of the composition the SDK session was opened for. The session models
|
||||
* ONLY this file (serialize() emits the whole active composition), so any edit
|
||||
* whose targetPath differs (a sub-composition file) must take the server path
|
||||
* — otherwise we'd write the full active-comp serialization into that file.
|
||||
*/
|
||||
compositionPath?: string | null;
|
||||
/**
|
||||
* Optional per-key task serializer (the same `gsap-file:${file}` serializer the
|
||||
* legacy `commitMutation` uses). When provided, every GSAP-op persist routes its
|
||||
* read-serialize → dispatch → serialize → write through it so two concurrent
|
||||
* same-file flushes can't interleave their read-modify-write and lose an edit.
|
||||
* Absent (e.g. in unit tests) → ops run unserialized as before.
|
||||
*/
|
||||
serialize?: <T>(key: string, task: () => Promise<T>) => Promise<T>;
|
||||
/**
|
||||
* Optional reader for the on-disk content of targetPath. Timing/GSAP persists
|
||||
* use it to capture the EXACT prior bytes as the undo-history `before`, so undo
|
||||
* restores the file verbatim instead of a normalized SDK re-emit (which would
|
||||
* reformat the whole file). The style/delete paths already thread originalContent
|
||||
* in explicitly; this gives timing/GSAP parity without touching every call site.
|
||||
* Absent → falls back to the SDK's pre-edit serialize() (the prior behavior).
|
||||
*/
|
||||
readProjectFile?: (path: string) => Promise<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture the undo-history `before` baseline for timing/GSAP persists: the exact
|
||||
* on-disk bytes when a reader is available (so undo restores them verbatim),
|
||||
* falling back to the SDK's pre-edit serialization when it isn't. Never throws —
|
||||
* a failed read degrades to the serialized fallback rather than aborting the edit.
|
||||
*/
|
||||
async function captureOnDiskBefore(
|
||||
deps: CutoverDeps,
|
||||
targetPath: string,
|
||||
serializedFallback: string,
|
||||
): Promise<string> {
|
||||
if (!deps.readProjectFile) return serializedFallback;
|
||||
try {
|
||||
return await deps.readProjectFile(targetPath);
|
||||
} catch {
|
||||
return serializedFallback;
|
||||
}
|
||||
}
|
||||
|
||||
/** True when targetPath isn't the composition the SDK session models. */
|
||||
function wrongCompositionFile(deps: CutoverDeps, targetPath: string): boolean {
|
||||
return deps.compositionPath != null && targetPath !== deps.compositionPath;
|
||||
}
|
||||
|
||||
interface CutoverOptions {
|
||||
label?: string;
|
||||
coalesceKey?: string;
|
||||
/** Skip the preview reload (mirrors the server path's skipRefresh). */
|
||||
skipRefresh?: boolean;
|
||||
}
|
||||
|
||||
// ponytail: exported for setSlideshowManifest (third caller — island write bypasses
|
||||
// the SDK dispatch path since <script> nodes are not in the element tree).
|
||||
// `after` is serialized once by the caller (which also did the no-op check
|
||||
// against its pre-dispatch snapshot), so this never re-serializes.
|
||||
export async function persistSdkSerialize(
|
||||
after: string,
|
||||
targetPath: string,
|
||||
originalContent: string,
|
||||
deps: CutoverDeps,
|
||||
options?: CutoverOptions,
|
||||
): Promise<void> {
|
||||
deps.domEditSaveTimestampRef.current = Date.now();
|
||||
// Tag this write with the exact content (by hash) so the file-change
|
||||
// reload-suppression can recognize its own echo by IDENTITY, not just a 2 s
|
||||
// clock — an undo write (different bytes, not registered here) then always
|
||||
// reloads instead of being swallowed by the time window.
|
||||
markSelfWrite(targetPath, after);
|
||||
await deps.writeProjectFile(targetPath, after);
|
||||
await deps.editHistory.recordEdit({
|
||||
label: options?.label ?? "Edit layer",
|
||||
kind: "manual",
|
||||
...(options?.coalesceKey ? { coalesceKey: options.coalesceKey } : {}),
|
||||
files: { [targetPath]: { before: originalContent, after } },
|
||||
});
|
||||
if (deps.refresh) deps.refresh(after);
|
||||
else if (!options?.skipRefresh) deps.reloadPreview();
|
||||
}
|
||||
|
||||
export async function sdkCutoverPersist(
|
||||
selection: DomEditSelection,
|
||||
ops: PatchOperation[],
|
||||
originalContent: string,
|
||||
targetPath: string,
|
||||
sdkSession: Composition | null | undefined,
|
||||
deps: CutoverDeps,
|
||||
options?: CutoverOptions,
|
||||
): Promise<boolean> {
|
||||
if (!shouldUseSdkCutover(STUDIO_SDK_CUTOVER_ENABLED, !!sdkSession, selection.hfId, ops))
|
||||
return false;
|
||||
if (!sdkSession) return false;
|
||||
const hfId = selection.hfId;
|
||||
if (!hfId) return false;
|
||||
const target = sdkSession.getElement(hfId);
|
||||
if (!target) return false;
|
||||
if (shouldDeclineTextCutoverForTarget(target, ops)) return false;
|
||||
if (wrongCompositionFile(deps, targetPath)) return false;
|
||||
try {
|
||||
const before = sdkSession.serialize();
|
||||
sdkSession.batch(() => {
|
||||
for (const editOp of patchOpsToSdkEditOps(hfId, ops)) {
|
||||
sdkSession.dispatch(editOp);
|
||||
}
|
||||
});
|
||||
const after = sdkSession.serialize();
|
||||
if (after === before) return false;
|
||||
await persistSdkSerialize(after, targetPath, originalContent, deps, options);
|
||||
trackStudioEvent("sdk_cutover_success", { hfId, opCount: ops.length });
|
||||
return true;
|
||||
} catch (err) {
|
||||
trackStudioEvent("sdk_cutover_fallback", {
|
||||
hfId: selection.hfId ?? null,
|
||||
error: String(err),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function sdkTimingPersist(
|
||||
hfId: string,
|
||||
targetPath: string,
|
||||
timingUpdate: { start?: number; duration?: number; trackIndex?: number },
|
||||
sdkSession: Composition | null | undefined,
|
||||
deps: CutoverDeps,
|
||||
options?: CutoverOptions,
|
||||
): Promise<boolean> {
|
||||
// Resolver tripwire — runs BEFORE the cutover gate (decoupled): records when
|
||||
// the SDK can't resolve a target the server timing path is addressing.
|
||||
const timingSrc = deps.readProjectFile;
|
||||
void recordResolverParity(
|
||||
sdkSession,
|
||||
hfId,
|
||||
"setTiming",
|
||||
timingSrc ? () => timingSrc(targetPath) : undefined,
|
||||
);
|
||||
// Dark-launch gate: without this, timing cutover runs whenever an SDK session
|
||||
// exists (it always does, for shadow/selection) — flipping the flag OFF would
|
||||
// NOT disable it. Gate here so flag-off routes back to the legacy server path.
|
||||
if (!STUDIO_SDK_CUTOVER_ENABLED) return false;
|
||||
if (!sdkSession || !sdkSession.getElement(hfId)) return false;
|
||||
if (wrongCompositionFile(deps, targetPath)) return false;
|
||||
try {
|
||||
const serializedBefore = sdkSession.serialize();
|
||||
sdkSession.batch(() => sdkSession.setTiming(hfId, timingUpdate));
|
||||
const after = sdkSession.serialize();
|
||||
if (after === serializedBefore) return false;
|
||||
// Undo baseline = exact on-disk bytes (matching the style/delete paths), so
|
||||
// undoing a timing edit restores the file verbatim instead of a normalized
|
||||
// full-DOM re-emit. Falls back to serializedBefore when no reader is wired.
|
||||
const undoBefore = await captureOnDiskBefore(deps, targetPath, serializedBefore);
|
||||
await persistSdkSerialize(after, targetPath, undoBefore, deps, options);
|
||||
trackStudioEvent("sdk_cutover_success", { hfId, opCount: 1 });
|
||||
return true;
|
||||
} catch (err) {
|
||||
trackStudioEvent("sdk_cutover_fallback", { hfId, error: String(err) });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function sdkTimingBatchPersist(
|
||||
changes: Array<{
|
||||
hfId: string;
|
||||
timingUpdate: { start?: number; duration?: number; trackIndex?: number };
|
||||
}>,
|
||||
targetPath: string,
|
||||
sdkSession: Composition | null | undefined,
|
||||
deps: CutoverDeps,
|
||||
options?: CutoverOptions,
|
||||
): Promise<boolean> {
|
||||
const timingSrc = deps.readProjectFile;
|
||||
for (const change of changes) {
|
||||
void recordResolverParity(
|
||||
sdkSession,
|
||||
change.hfId,
|
||||
"setTiming",
|
||||
timingSrc ? () => timingSrc(targetPath) : undefined,
|
||||
);
|
||||
}
|
||||
if (!STUDIO_SDK_CUTOVER_ENABLED) return false;
|
||||
if (!sdkSession || wrongCompositionFile(deps, targetPath)) return false;
|
||||
if (changes.some((change) => !sdkSession.getElement(change.hfId))) return false;
|
||||
try {
|
||||
const serializedBefore = sdkSession.serialize();
|
||||
sdkSession.batch(() => {
|
||||
for (const change of changes) sdkSession.setTiming(change.hfId, change.timingUpdate);
|
||||
});
|
||||
const after = sdkSession.serialize();
|
||||
if (after === serializedBefore) return false;
|
||||
const undoBefore = await captureOnDiskBefore(deps, targetPath, serializedBefore);
|
||||
await persistSdkSerialize(after, targetPath, undoBefore, deps, options);
|
||||
trackStudioEvent("sdk_cutover_success", {
|
||||
hfId: changes[0]?.hfId ?? null,
|
||||
opCount: changes.length,
|
||||
});
|
||||
return true;
|
||||
} catch (err) {
|
||||
trackStudioEvent("sdk_cutover_fallback", {
|
||||
hfId: changes[0]?.hfId ?? null,
|
||||
error: String(err),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
type SdkGsapTweenOp =
|
||||
| { kind: "add"; target: string; spec: GsapTweenSpec }
|
||||
| { kind: "set"; animationId: string; properties: Partial<GsapTweenSpec> }
|
||||
| { kind: "remove"; animationId: string };
|
||||
|
||||
export function sdkGsapTweenPersist(
|
||||
targetPath: string,
|
||||
op: SdkGsapTweenOp,
|
||||
sdkSession: Composition | null | undefined,
|
||||
deps: CutoverDeps,
|
||||
options?: CutoverOptions,
|
||||
): Promise<boolean> {
|
||||
// Resolver tripwire — runs BEFORE this function's own cutover gate (decoupled).
|
||||
// add targets an element (element-resolution parity); set/remove target an
|
||||
// animationId (animation-resolution parity). Done here, not via
|
||||
// dispatchGsapOpAndPersist's resolverTarget, because the gate below returns
|
||||
// before that call when cutover is off.
|
||||
if (op.kind === "add") {
|
||||
const gsapSrc = deps.readProjectFile;
|
||||
void recordResolverParity(
|
||||
sdkSession,
|
||||
op.target,
|
||||
"addGsapTween",
|
||||
gsapSrc ? () => gsapSrc(targetPath) : undefined,
|
||||
);
|
||||
} else {
|
||||
recordAnimationResolverParity(
|
||||
sdkSession,
|
||||
op.animationId,
|
||||
op.kind === "set" ? "setGsapTween" : "removeGsapTween",
|
||||
);
|
||||
}
|
||||
// Leading dark-launch gate so flag-off does no SDK touch (getElement) at all —
|
||||
// matches the other three chokepoints' discipline.
|
||||
if (!STUDIO_SDK_CUTOVER_ENABLED) return Promise.resolve(false);
|
||||
if (op.kind === "add" && sdkSession && !sdkSession.getElement(op.target))
|
||||
return Promise.resolve(false);
|
||||
// dispatchGsapOpAndPersist returns false on before===after — that catches stale
|
||||
// animationIds and unsupported shapes (e.g. from-prop on a plain tween), falling
|
||||
// back to the server path. This subsumes explicit existence guards for set/remove.
|
||||
return dispatchGsapOpAndPersist(targetPath, sdkSession, deps, options, (s) => {
|
||||
s.batch(() => {
|
||||
if (op.kind === "add") {
|
||||
s.addGsapTween(op.target, op.spec);
|
||||
} else if (op.kind === "set") {
|
||||
s.setGsapTween(op.animationId, op.properties);
|
||||
} else {
|
||||
s.removeGsapTween(op.animationId);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function dispatchGsapOpAndPersist(
|
||||
targetPath: string,
|
||||
sdkSession: Composition | null | undefined,
|
||||
deps: CutoverDeps,
|
||||
options: CutoverOptions | undefined,
|
||||
dispatch: (s: Composition) => void,
|
||||
resolverTarget?: { animationId: string; opLabel: string },
|
||||
): Promise<boolean> {
|
||||
// Resolver tripwire — runs BEFORE the cutover gate (decoupled): records when
|
||||
// the SDK can't resolve the animationId the server GSAP path is addressing.
|
||||
if (resolverTarget) {
|
||||
recordAnimationResolverParity(sdkSession, resolverTarget.animationId, resolverTarget.opLabel);
|
||||
}
|
||||
// Dark-launch gate (shared chokepoint for every GSAP-op cutover persist):
|
||||
// flag OFF → return false → caller falls back to the legacy server path.
|
||||
if (!STUDIO_SDK_CUTOVER_ENABLED) return false;
|
||||
if (!sdkSession) return false;
|
||||
if (wrongCompositionFile(deps, targetPath)) return false;
|
||||
const session = sdkSession;
|
||||
// Route the whole read-serialize → dispatch → serialize → write through the
|
||||
// per-file serializer (when provided) so overlapping same-file flushes can't
|
||||
// interleave their read-modify-write and drop an edit, matching the legacy
|
||||
// commitMutation path's `gsap-file:${file}` serialization.
|
||||
const run = async (): Promise<boolean> => {
|
||||
try {
|
||||
const serializedBefore = session.serialize();
|
||||
dispatch(session);
|
||||
const after = session.serialize();
|
||||
if (after === serializedBefore) return false;
|
||||
// Undo baseline = exact on-disk bytes (matching the style/delete paths), so
|
||||
// undoing a GSAP edit restores the file verbatim instead of a normalized
|
||||
// full-DOM re-emit. Falls back to serializedBefore when no reader is wired.
|
||||
const undoBefore = await captureOnDiskBefore(deps, targetPath, serializedBefore);
|
||||
await persistSdkSerialize(after, targetPath, undoBefore, deps, options);
|
||||
trackStudioEvent("sdk_cutover_success", { opCount: 1 });
|
||||
return true;
|
||||
} catch (err) {
|
||||
trackStudioEvent("sdk_cutover_fallback", { error: String(err) });
|
||||
return false;
|
||||
}
|
||||
};
|
||||
return deps.serialize ? deps.serialize(`gsap-file:${targetPath}`, run) : run();
|
||||
}
|
||||
|
||||
export function sdkGsapKeyframePersist(
|
||||
targetPath: string,
|
||||
animationId: string,
|
||||
position: number,
|
||||
value: Record<string, unknown>,
|
||||
sdkSession: Composition | null | undefined,
|
||||
deps: CutoverDeps,
|
||||
options?: CutoverOptions,
|
||||
): Promise<boolean> {
|
||||
return dispatchGsapOpAndPersist(
|
||||
targetPath,
|
||||
sdkSession,
|
||||
deps,
|
||||
options,
|
||||
(s) => s.batch(() => s.dispatch({ type: "addGsapKeyframe", animationId, position, value })),
|
||||
{ animationId, opLabel: "addGsapKeyframe" },
|
||||
);
|
||||
}
|
||||
|
||||
export function sdkGsapRemoveKeyframePersist(
|
||||
targetPath: string,
|
||||
animationId: string,
|
||||
percentage: number,
|
||||
sdkSession: Composition | null | undefined,
|
||||
deps: CutoverDeps,
|
||||
options?: CutoverOptions,
|
||||
): Promise<boolean> {
|
||||
return dispatchGsapOpAndPersist(
|
||||
targetPath,
|
||||
sdkSession,
|
||||
deps,
|
||||
options,
|
||||
(s) => s.dispatch({ type: "removeGsapKeyframe", animationId, percentage }),
|
||||
{ animationId, opLabel: "removeGsapKeyframe" },
|
||||
);
|
||||
}
|
||||
|
||||
export function sdkGsapRemovePropertyPersist(
|
||||
targetPath: string,
|
||||
animationId: string,
|
||||
property: string,
|
||||
from: boolean,
|
||||
sdkSession: Composition | null | undefined,
|
||||
deps: CutoverDeps,
|
||||
options?: CutoverOptions,
|
||||
): Promise<boolean> {
|
||||
return dispatchGsapOpAndPersist(
|
||||
targetPath,
|
||||
sdkSession,
|
||||
deps,
|
||||
options,
|
||||
(s) => s.dispatch({ type: "removeGsapProperty", animationId, property, from }),
|
||||
{ animationId, opLabel: "removeGsapProperty" },
|
||||
);
|
||||
}
|
||||
|
||||
export function sdkGsapDeleteAllForSelectorPersist(
|
||||
targetPath: string,
|
||||
selector: string,
|
||||
sdkSession: Composition | null | undefined,
|
||||
deps: CutoverDeps,
|
||||
options?: CutoverOptions,
|
||||
): Promise<boolean> {
|
||||
return dispatchGsapOpAndPersist(targetPath, sdkSession, deps, options, (s) =>
|
||||
s.dispatch({ type: "deleteAllForSelector", selector }),
|
||||
);
|
||||
}
|
||||
|
||||
export function sdkGsapRemoveAllKeyframesPersist(
|
||||
targetPath: string,
|
||||
animationId: string,
|
||||
sdkSession: Composition | null | undefined,
|
||||
deps: CutoverDeps,
|
||||
options?: CutoverOptions,
|
||||
): Promise<boolean> {
|
||||
return dispatchGsapOpAndPersist(
|
||||
targetPath,
|
||||
sdkSession,
|
||||
deps,
|
||||
options,
|
||||
(s) => s.dispatch({ type: "removeAllKeyframes", animationId }),
|
||||
{ animationId, opLabel: "removeAllKeyframes" },
|
||||
);
|
||||
}
|
||||
|
||||
export function sdkGsapConvertToKeyframesPersist(
|
||||
targetPath: string,
|
||||
animationId: string,
|
||||
resolvedFromValues: Record<string, number | string> | undefined,
|
||||
sdkSession: Composition | null | undefined,
|
||||
deps: CutoverDeps,
|
||||
options?: CutoverOptions,
|
||||
): Promise<boolean> {
|
||||
return dispatchGsapOpAndPersist(
|
||||
targetPath,
|
||||
sdkSession,
|
||||
deps,
|
||||
options,
|
||||
(s) => s.dispatch({ type: "convertToKeyframes", animationId, resolvedFromValues }),
|
||||
{ animationId, opLabel: "convertToKeyframes" },
|
||||
);
|
||||
}
|
||||
|
||||
type KeyframeSpec = {
|
||||
percentage: number;
|
||||
properties: Record<string, number | string>;
|
||||
ease?: string;
|
||||
auto?: boolean;
|
||||
};
|
||||
|
||||
type KeyframesPayload = {
|
||||
targetSelector: string;
|
||||
position: number;
|
||||
duration: number;
|
||||
keyframes: KeyframeSpec[];
|
||||
ease?: string;
|
||||
};
|
||||
|
||||
/** Shared inner dispatch for addWithKeyframes / replaceWithKeyframes ops. */
|
||||
function dispatchWithKeyframes(
|
||||
s: Composition,
|
||||
payload: KeyframesPayload,
|
||||
animationId?: string,
|
||||
): void {
|
||||
if (animationId !== undefined) {
|
||||
s.dispatch({ type: "replaceWithKeyframes", animationId, ...payload });
|
||||
} else {
|
||||
s.dispatch({ type: "addWithKeyframes", ...payload });
|
||||
}
|
||||
}
|
||||
|
||||
export function sdkAddWithKeyframesPersist(
|
||||
targetPath: string,
|
||||
targetSelector: string,
|
||||
position: number,
|
||||
duration: number,
|
||||
keyframes: KeyframeSpec[],
|
||||
ease: string | undefined,
|
||||
sdkSession: Composition | null | undefined,
|
||||
deps: CutoverDeps,
|
||||
options?: CutoverOptions,
|
||||
): Promise<boolean> {
|
||||
const payload: KeyframesPayload = {
|
||||
targetSelector,
|
||||
position,
|
||||
duration,
|
||||
keyframes,
|
||||
...(ease ? { ease } : {}),
|
||||
};
|
||||
return dispatchGsapOpAndPersist(targetPath, sdkSession, deps, options, (s) =>
|
||||
dispatchWithKeyframes(s, payload),
|
||||
);
|
||||
}
|
||||
|
||||
export function sdkReplaceWithKeyframesPersist(
|
||||
targetPath: string,
|
||||
animationId: string,
|
||||
targetSelector: string,
|
||||
position: number,
|
||||
duration: number,
|
||||
keyframes: KeyframeSpec[],
|
||||
ease: string | undefined,
|
||||
sdkSession: Composition | null | undefined,
|
||||
deps: CutoverDeps,
|
||||
options?: CutoverOptions,
|
||||
): Promise<boolean> {
|
||||
const payload: KeyframesPayload = {
|
||||
targetSelector,
|
||||
position,
|
||||
duration,
|
||||
keyframes,
|
||||
...(ease ? { ease } : {}),
|
||||
};
|
||||
return dispatchGsapOpAndPersist(
|
||||
targetPath,
|
||||
sdkSession,
|
||||
deps,
|
||||
options,
|
||||
(s) => dispatchWithKeyframes(s, payload, animationId),
|
||||
{ animationId, opLabel: "replaceWithKeyframes" },
|
||||
);
|
||||
}
|
||||
|
||||
export async function sdkDeletePersist(
|
||||
hfId: string,
|
||||
originalContent: string,
|
||||
targetPath: string,
|
||||
sdkSession: Composition | null | undefined,
|
||||
deps: CutoverDeps,
|
||||
): Promise<boolean> {
|
||||
// Resolver tripwire — runs BEFORE the cutover gate (decoupled).
|
||||
void recordResolverParity(sdkSession, hfId, "removeElement", () =>
|
||||
Promise.resolve(originalContent),
|
||||
);
|
||||
// Dark-launch gate: flag OFF → legacy server delete path.
|
||||
if (!STUDIO_SDK_CUTOVER_ENABLED) return false;
|
||||
if (!sdkSession || !sdkSession.getElement(hfId)) return false;
|
||||
if (wrongCompositionFile(deps, targetPath)) return false;
|
||||
try {
|
||||
const before = sdkSession.serialize();
|
||||
sdkSession.batch(() => sdkSession.removeElement(hfId));
|
||||
const after = sdkSession.serialize();
|
||||
if (after === before) return false;
|
||||
await persistSdkSerialize(after, targetPath, originalContent, deps, {
|
||||
label: "Delete element",
|
||||
});
|
||||
trackStudioEvent("sdk_cutover_success", { hfId, opCount: 1 });
|
||||
return true;
|
||||
} catch (err) {
|
||||
trackStudioEvent("sdk_cutover_fallback", { hfId, error: String(err) });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { PatchOperation } from "./sourcePatcher";
|
||||
import { shouldUseSdkCutover } from "./sdkCutoverEligibility";
|
||||
|
||||
const childStyleOp: PatchOperation = {
|
||||
type: "inline-style",
|
||||
property: "color",
|
||||
value: "blue",
|
||||
childSelector: ":scope > span",
|
||||
childIndex: 0,
|
||||
};
|
||||
|
||||
describe("shouldUseSdkCutover child-scoped operations", () => {
|
||||
it("declines child-scoped operations because SDK patch ops target only the parent hfId", () => {
|
||||
expect(shouldUseSdkCutover(true, true, "hf-parent", [childStyleOp])).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Cutover eligibility checks: whether a batch of patch ops is safe to route
|
||||
* through the SDK cutover path instead of the legacy server path. Split out of
|
||||
* sdkCutover.ts (which hit the packages/studio 600-line filesize cap) — this
|
||||
* block has no dependency on the persist/dispatch functions there.
|
||||
*/
|
||||
import type { PatchOperation } from "./sourcePatcher";
|
||||
import { isAllowedHtmlAttribute, isSafeAttributeValue } from "./htmlAttrSafety";
|
||||
|
||||
const CUTOVER_OP_TYPES = new Set<PatchOperation["type"]>([
|
||||
"inline-style",
|
||||
"text-content",
|
||||
"attribute",
|
||||
"html-attribute",
|
||||
]);
|
||||
|
||||
// Mirrors the SDK's RESERVED_ATTRS (mutate.ts): a bare `attribute` op is
|
||||
// force-prefixed `data-`, so e.g. property "end" → "data-end", which the SDK
|
||||
// rejects with a throw. Detect that up front and decline the whole batch so it
|
||||
// takes the server path cleanly, instead of throwing inside the dispatch and
|
||||
// silently falling back per op.
|
||||
// ponytail: small mirror of the SDK set; if the SDK adds a reserved attr, a new
|
||||
// op for it just reverts to the (working) throw→fallback path until synced.
|
||||
const RESERVED_CUTOVER_ATTRS = new Set<string>([
|
||||
"data-hf-id",
|
||||
"data-composition-id",
|
||||
"data-width",
|
||||
"data-height",
|
||||
"data-start",
|
||||
"data-end",
|
||||
"data-track-index",
|
||||
"data-hold-start",
|
||||
"data-hold-end",
|
||||
"data-hold-fill",
|
||||
]);
|
||||
|
||||
function sdkAttrName(op: PatchOperation): string | null {
|
||||
if (op.type === "attribute") {
|
||||
return op.property.startsWith("data-") ? op.property : `data-${op.property}`;
|
||||
}
|
||||
if (op.type === "html-attribute") return op.property;
|
||||
return null;
|
||||
}
|
||||
|
||||
function mapsToReservedAttr(op: PatchOperation): boolean {
|
||||
const name = sdkAttrName(op);
|
||||
// Lowercase to match the SDK's validateSetAttribute (it lowercases before the
|
||||
// reserved check), so "DATA-START" is declined up front too; covers both
|
||||
// `attribute` (prefixed) and `html-attribute` (raw) ops.
|
||||
return name !== null && RESERVED_CUTOVER_ATTRS.has(name.toLowerCase());
|
||||
}
|
||||
|
||||
// ─── html-attribute safety ───────────────────────────────────────────────────
|
||||
|
||||
function hasUnsafeHtmlAttributeOp(ops: PatchOperation[]): boolean {
|
||||
return ops.some(
|
||||
(op) =>
|
||||
op.type === "html-attribute" &&
|
||||
(!isAllowedHtmlAttribute(op.property) ||
|
||||
(op.value !== null && !isSafeAttributeValue(op.property, op.value))),
|
||||
);
|
||||
}
|
||||
|
||||
function hasChildScopedOp(ops: PatchOperation[]): boolean {
|
||||
return ops.some((op) => op.childSelector !== undefined);
|
||||
}
|
||||
|
||||
function hasTextContentOp(ops: PatchOperation[]): boolean {
|
||||
return ops.some((op) => op.type === "text-content");
|
||||
}
|
||||
|
||||
function targetChildren(target: unknown): unknown[] | null {
|
||||
if (!target || typeof target !== "object" || !("children" in target)) return null;
|
||||
const children = target.children;
|
||||
return Array.isArray(children) ? children : null;
|
||||
}
|
||||
|
||||
function elementTag(element: unknown): string | null {
|
||||
if (!element || typeof element !== "object" || !("tag" in element)) return null;
|
||||
const tag = element.tag;
|
||||
return typeof tag === "string" ? tag.toLowerCase() : null;
|
||||
}
|
||||
|
||||
// Tags that are non-HTML namespace elements in a linkedom-parsed HTML body.
|
||||
// Mirrors the engine's `isHTMLElementTarget` (model.ts) which uses `instanceof
|
||||
// HTMLElement` — that runtime check catches the same set, but we can't use it
|
||||
// here because `target` is a plain SDK object, not a DOM Element. If linkedom
|
||||
// (or a future parser) surfaces additional foreign-content elements as
|
||||
// non-HTMLElement, add them here.
|
||||
const NON_HTML_CHILD_TAGS = new Set(["svg", "math"]);
|
||||
|
||||
export function shouldDeclineTextCutoverForTarget(target: unknown, ops: PatchOperation[]): boolean {
|
||||
if (!hasTextContentOp(ops)) return false;
|
||||
const children = targetChildren(target);
|
||||
if (!children) return false;
|
||||
// Legacy patch-element replaces the whole element for multi-child targets and
|
||||
// for single non-HTML children. The SDK text patch stream stores a scalar
|
||||
// inverse, so those shapes cannot be made both byte-identical and undo-safe
|
||||
// here. Let the server path remain authoritative for them.
|
||||
if (children.length > 1) return true;
|
||||
const tag = elementTag(children[0]);
|
||||
return tag !== null && NON_HTML_CHILD_TAGS.has(tag);
|
||||
}
|
||||
|
||||
export function shouldUseSdkCutover(
|
||||
flagEnabled: boolean,
|
||||
hasSession: boolean,
|
||||
hfId: string | null | undefined,
|
||||
ops: PatchOperation[],
|
||||
): boolean {
|
||||
return (
|
||||
flagEnabled &&
|
||||
hasSession &&
|
||||
!!hfId &&
|
||||
ops.length > 0 &&
|
||||
ops.every((o) => CUTOVER_OP_TYPES.has(o.type)) &&
|
||||
// SDK edit ops target only the element hfId; child-scoped patch ops need the server path.
|
||||
!hasChildScopedOp(ops) &&
|
||||
!ops.some(mapsToReservedAttr) &&
|
||||
!hasUnsafeHtmlAttributeOp(ops)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { openComposition } from "@hyperframes/sdk";
|
||||
import { patchElementInHtml } from "../../../studio-server/src/helpers/sourceMutation.js";
|
||||
import type { PatchOperation } from "./sourcePatcher";
|
||||
import { patchOpsToSdkEditOps } from "./sdkOpMapping";
|
||||
|
||||
const shell = (body: string) => `<!DOCTYPE html>
|
||||
<html><head></head><body>${body}</body></html>`;
|
||||
|
||||
async function applySdkDomCutover(source: string, ops: PatchOperation[]): Promise<string> {
|
||||
const session = await openComposition(source, { history: false });
|
||||
session.batch(() => {
|
||||
for (const op of patchOpsToSdkEditOps("hf-target", ops)) {
|
||||
session.dispatch(op);
|
||||
}
|
||||
});
|
||||
return session.serialize();
|
||||
}
|
||||
|
||||
const cases: Array<{ name: string; source: string; ops: PatchOperation[] }> = [
|
||||
{
|
||||
name: "coalesces multi-property inline style including transform and custom props",
|
||||
source: shell(
|
||||
'<div data-hf-id="hf-target" style="opacity: 0.5; inset: 0; transform-origin: 0 0">Old<span data-hf-id="hf-child">Child</span></div>',
|
||||
),
|
||||
ops: [
|
||||
{ type: "inline-style", property: "transform", value: "translateX(10px)" },
|
||||
{ type: "inline-style", property: "transform-origin", value: "50% 50%" },
|
||||
{ type: "inline-style", property: "--x", value: "12px" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "removes one inline style from a multi-property declaration",
|
||||
source: shell(
|
||||
'<div data-hf-id="hf-target" style="opacity: 0.5; inset: 0; transform-origin: 0 0">Old</div>',
|
||||
),
|
||||
ops: [{ type: "inline-style", property: "opacity", value: null }],
|
||||
},
|
||||
{
|
||||
name: "preserves semicolon-bearing CSS values when updating another style",
|
||||
source: shell(
|
||||
'<div data-hf-id="hf-target" style="background: url(data:image/svg+xml;utf8,<svg></svg>); color: red; opacity: 0.5">Old</div>',
|
||||
),
|
||||
ops: [{ type: "inline-style", property: "color", value: "blue" }],
|
||||
},
|
||||
{
|
||||
name: "sets direct text content",
|
||||
source: shell('<div data-hf-id="hf-target">Old</div>'),
|
||||
ops: [{ type: "text-content", property: "text", value: "New" }],
|
||||
},
|
||||
{
|
||||
name: "sets text on the single child target used by the legacy path",
|
||||
source: shell('<button data-hf-id="hf-target"><span data-hf-id="hf-child">Old</span></button>'),
|
||||
ops: [{ type: "text-content", property: "text", value: "New" }],
|
||||
},
|
||||
{
|
||||
name: "sets text on the single child target while preserving parent text",
|
||||
source: shell('<div data-hf-id="hf-target">Lead <span data-hf-id="hf-child">Old</span></div>'),
|
||||
ops: [{ type: "text-content", property: "text", value: "New" }],
|
||||
},
|
||||
{
|
||||
name: "sets data attributes through attribute ops",
|
||||
source: shell('<div data-hf-id="hf-target" data-old="1">Old</div>'),
|
||||
ops: [{ type: "attribute", property: "mode", value: "hero" }],
|
||||
},
|
||||
{
|
||||
name: "removes data attributes through attribute ops",
|
||||
source: shell('<div data-hf-id="hf-target" data-mode="hero">Old</div>'),
|
||||
ops: [{ type: "attribute", property: "mode", value: null }],
|
||||
},
|
||||
{
|
||||
name: "sets allowed html attributes",
|
||||
source: shell('<a data-hf-id="hf-target" href="https://example.com">Old</a>'),
|
||||
ops: [{ type: "html-attribute", property: "aria-label", value: "Primary link" }],
|
||||
},
|
||||
{
|
||||
name: "sets shorthand over existing longhands (inset over top/right/bottom/left)",
|
||||
source: shell(
|
||||
'<div data-hf-id="hf-target" style="top: 10px; right: 10px; bottom: 10px; left: 10px; opacity: 1">Old</div>',
|
||||
),
|
||||
ops: [{ type: "inline-style", property: "inset", value: "0" }],
|
||||
},
|
||||
{
|
||||
name: "sets longhand over existing shorthand (top over inset)",
|
||||
source: shell('<div data-hf-id="hf-target" style="inset: 0; opacity: 1">Old</div>'),
|
||||
ops: [{ type: "inline-style", property: "top", value: "10px" }],
|
||||
},
|
||||
{
|
||||
name: "mixed-type batch: inline-style + text-content in one op list",
|
||||
source: shell('<div data-hf-id="hf-target" style="color: red">Old</div>'),
|
||||
ops: [
|
||||
{ type: "inline-style", property: "color", value: "blue" },
|
||||
{ type: "text-content", property: "text", value: "New" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "mixed-type batch: inline-style + attribute in one op list",
|
||||
source: shell('<div data-hf-id="hf-target" style="opacity: 0.5" data-mode="default">Old</div>'),
|
||||
ops: [
|
||||
{ type: "inline-style", property: "opacity", value: "1" },
|
||||
{ type: "attribute", property: "mode", value: "hero" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
describe("SDK cutover DOM serialization parity", () => {
|
||||
it.each(cases)("$name", async ({ source, ops }) => {
|
||||
const legacy = patchElementInHtml(source, { hfId: "hf-target" }, ops);
|
||||
expect(legacy.matched).toBe(true);
|
||||
|
||||
await expect(applySdkDomCutover(source, ops)).resolves.toBe(legacy.html);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Studio PatchOperation[] → SDK EditOp[] mapping.
|
||||
*
|
||||
* Lives in its own module so both the cutover path (sdkCutover.ts) and the
|
||||
* resolver-shadow tripwire (sdkResolverShadow.ts) can use it without a circular
|
||||
* import between those two.
|
||||
*
|
||||
* Multiple inline-style ops are coalesced into a single setStyle (the SDK
|
||||
* batches style changes naturally). One SDK op is emitted per non-style op.
|
||||
*/
|
||||
|
||||
import type { EditOp } from "@hyperframes/sdk";
|
||||
import type { PatchOperation } from "./sourcePatcher";
|
||||
|
||||
export function patchOpsToSdkEditOps(hfId: string, ops: PatchOperation[]): EditOp[] {
|
||||
const result: EditOp[] = [];
|
||||
const styles: Record<string, string | null> = {};
|
||||
let hasStyles = false;
|
||||
|
||||
for (const op of ops) {
|
||||
if (op.type === "inline-style") {
|
||||
styles[op.property] = op.value;
|
||||
hasStyles = true;
|
||||
} else if (op.type === "text-content") {
|
||||
result.push({ type: "setText", target: hfId, value: op.value ?? "" });
|
||||
} else if (op.type === "attribute") {
|
||||
result.push({
|
||||
type: "setAttribute",
|
||||
target: hfId,
|
||||
name: op.property.startsWith("data-") ? op.property : `data-${op.property}`,
|
||||
value: op.value,
|
||||
});
|
||||
} else if (op.type === "html-attribute") {
|
||||
result.push({ type: "setAttribute", target: hfId, name: op.property, value: op.value });
|
||||
}
|
||||
}
|
||||
|
||||
if (hasStyles) {
|
||||
result.unshift({ type: "setStyle", target: hfId, styles });
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,780 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import {
|
||||
sdkResolverShadowCheck,
|
||||
runResolverShadow,
|
||||
recordResolverParity,
|
||||
recordAnimationResolverParity,
|
||||
evaluateSoakGate,
|
||||
recordAttempt,
|
||||
flushAttemptCounts,
|
||||
__resetAttemptSchedulingForTests,
|
||||
type SdkResolverMismatch,
|
||||
} from "./sdkResolverShadow";
|
||||
import type { PatchOperation } from "./sourcePatcher";
|
||||
import { openComposition } from "@hyperframes/sdk";
|
||||
|
||||
// ─── Telemetry capture ────────────────────────────────────────────────────────
|
||||
|
||||
const trackedEvents: Array<{ event: string; props: Record<string, unknown> }> = [];
|
||||
const flushViaBeacon = vi.fn();
|
||||
vi.mock("./studioTelemetry", () => ({
|
||||
trackStudioEvent: (event: string, props: Record<string, unknown>) =>
|
||||
trackedEvents.push({ event, props }),
|
||||
flushViaBeacon: () => flushViaBeacon(),
|
||||
}));
|
||||
beforeEach(() => {
|
||||
trackedEvents.length = 0;
|
||||
flushViaBeacon.mockClear();
|
||||
});
|
||||
const lastShadow = () =>
|
||||
trackedEvents.filter((e) => e.event === "sdk_resolver_shadow").at(-1)?.props;
|
||||
|
||||
// ─── Flag mock ────────────────────────────────────────────────────────────────
|
||||
|
||||
// manualEditingAvailability reads env at module load time, so we mock the
|
||||
// module to control flag values per test group.
|
||||
// Default false in tests so shadow is opt-in per test (real default is true).
|
||||
const mockFlags = { STUDIO_SDK_RESOLVER_SHADOW_ENABLED: false };
|
||||
vi.mock("../components/editor/manualEditingAvailability", () => ({
|
||||
get STUDIO_SDK_RESOLVER_SHADOW_ENABLED() {
|
||||
return mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED;
|
||||
},
|
||||
get STUDIO_SDK_CUTOVER_ENABLED() {
|
||||
return false;
|
||||
},
|
||||
}));
|
||||
|
||||
// ─── Fixtures ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const BASE_HTML = /* html */ `<!DOCTYPE html>
|
||||
<html><body>
|
||||
<div data-hf-id="hf-box" style="color: red; width: 100px;" data-name="box">Hello</div>
|
||||
</body></html>`;
|
||||
|
||||
// Prevents setStyle from applying so the read-back value differs from expected.
|
||||
// Used in C9 and D11 to simulate a silent SDK value-dispatch bug.
|
||||
async function makePoisonedStyleSession() {
|
||||
const session = await openComposition(BASE_HTML);
|
||||
const origDispatch = session.dispatch.bind(session);
|
||||
session.dispatch = (op) => {
|
||||
if (typeof op === "object" && "type" in op && op.type === "setStyle") return;
|
||||
origDispatch(op);
|
||||
};
|
||||
return session;
|
||||
}
|
||||
|
||||
// ─── A. Flag gating ───────────────────────────────────────────────────────────
|
||||
|
||||
describe("A. Flag gating", () => {
|
||||
it("A1: flag off → no telemetry, SDK path not touched", async () => {
|
||||
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = false;
|
||||
const session = await openComposition(BASE_HTML);
|
||||
const spy = vi.spyOn(session, "getElement");
|
||||
runResolverShadow(session, "hf-box", [
|
||||
{ type: "inline-style", property: "color", value: "blue" },
|
||||
]);
|
||||
expect(trackedEvents).toHaveLength(0);
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("A2: flag on + divergence → emits exactly one telemetry event", async () => {
|
||||
// runResolverShadow emits only on divergence, so force one (poisoned dispatch
|
||||
// → value_mismatch). A parity edit is silent (see B-parity-silent).
|
||||
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
|
||||
const session = await makePoisonedStyleSession();
|
||||
runResolverShadow(session, "hf-box", [
|
||||
{ type: "inline-style", property: "color", value: "blue" },
|
||||
]);
|
||||
expect(trackedEvents.filter((e) => e.event === "sdk_resolver_shadow")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("A2b: flag on + parity → emits nothing (divergence-only)", async () => {
|
||||
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
|
||||
const session = await openComposition(BASE_HTML);
|
||||
runResolverShadow(session, "hf-box", [
|
||||
{ type: "inline-style", property: "color", value: "blue" },
|
||||
]);
|
||||
expect(trackedEvents.filter((e) => e.event === "sdk_resolver_shadow")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("A2c: empty session → ONE tagged session_empty event per session, no attempt", async () => {
|
||||
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
|
||||
flushAttemptCounts(); // drain counts left by earlier tests
|
||||
const session = await openComposition("<!DOCTYPE html><html><body></body></html>");
|
||||
const ops: PatchOperation[] = [{ type: "inline-style", property: "color", value: "blue" }];
|
||||
runResolverShadow(session, "hf-anything", ops);
|
||||
runResolverShadow(session, "hf-other", ops); // repeat edits do not re-emit
|
||||
const events = trackedEvents.filter((e) => e.event === "sdk_resolver_shadow");
|
||||
// The modeling gap stays VISIBLE (silence would blind the tripwire to the
|
||||
// exact class that exposed the template-comp bug) but is distinguishable
|
||||
// and rate-limited to once per session instance.
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]?.props.sessionEmpty).toBe(true);
|
||||
expect(JSON.stringify(events[0]?.props.mismatches)).toContain("session_empty");
|
||||
expect(flushAttemptCounts()).toBeNull(); // can't cut over → not in the denominator
|
||||
});
|
||||
|
||||
it("A3: shadow depends ONLY on shadow flag, not on STUDIO_SDK_CUTOVER_ENABLED", async () => {
|
||||
// The mock always returns STUDIO_SDK_CUTOVER_ENABLED=false. Use a divergence
|
||||
// (poisoned session) so the flag-on case emits; flag-off must stay silent.
|
||||
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = false;
|
||||
const session = await makePoisonedStyleSession();
|
||||
runResolverShadow(session, "hf-box", [{ type: "inline-style", property: "color", value: "x" }]);
|
||||
expect(trackedEvents).toHaveLength(0); // cutover off, shadow off → no event
|
||||
|
||||
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
|
||||
runResolverShadow(session, "hf-box", [{ type: "inline-style", property: "color", value: "x" }]);
|
||||
expect(trackedEvents.filter((e) => e.event === "sdk_resolver_shadow")).toHaveLength(1); // shadow on regardless
|
||||
});
|
||||
|
||||
it("A4: null/undefined hfId is a safe no-op (no event, no throw)", async () => {
|
||||
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
|
||||
const session = await openComposition(BASE_HTML);
|
||||
const ops: PatchOperation[] = [{ type: "inline-style", property: "color", value: "blue" }];
|
||||
expect(() => runResolverShadow(session, null, ops)).not.toThrow();
|
||||
expect(() => runResolverShadow(session, undefined, ops)).not.toThrow();
|
||||
expect(trackedEvents).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── B. Telemetry-only (no side effects on real write) ────────────────────────
|
||||
|
||||
describe("B. Telemetry-only / no side effects", () => {
|
||||
it("B4: no disk write — shadow never calls writeProjectFile", async () => {
|
||||
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
|
||||
const writeProjectFile = vi.fn();
|
||||
const session = await openComposition(BASE_HTML);
|
||||
runResolverShadow(session, "hf-box", [
|
||||
{ type: "inline-style", property: "color", value: "blue" },
|
||||
]);
|
||||
// writeProjectFile is a deps-level function not in scope here; verify by
|
||||
// checking sdkResolverShadowCheck itself never touches it — it's not passed
|
||||
// in at all, so any call would be a TypeError at runtime.
|
||||
expect(writeProjectFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("B5: the LIVE session is restored after the check (cutover before===after stays correct)", async () => {
|
||||
// The session is shared with the cutover path. The shadow dispatches into it
|
||||
// to read values back, then MUST undo those mutations — otherwise the edit is
|
||||
// pre-applied and the following sdkCutoverPersist sees before === after and
|
||||
// silently falls back to the server path.
|
||||
const session = await openComposition(BASE_HTML);
|
||||
expect(session.getElement("hf-box")?.inlineStyles.color).toBe("red");
|
||||
|
||||
const mismatches = sdkResolverShadowCheck(session, "hf-box", [
|
||||
{ type: "inline-style", property: "color", value: "blue" },
|
||||
]);
|
||||
expect(mismatches).toHaveLength(0); // SDK applied blue == expected → parity
|
||||
|
||||
// …but the session is back to its pre-check state, NOT left on "blue".
|
||||
expect(session.getElement("hf-box")?.inlineStyles.color).toBe("red");
|
||||
});
|
||||
|
||||
it("B5b: a real cutover-style serialize diff survives a preceding shadow run", async () => {
|
||||
// End-to-end of the bug: shadow runs, THEN a cutover-style before/dispatch/
|
||||
// after still produces a diff (proving shadow left no residue).
|
||||
const session = await openComposition(BASE_HTML);
|
||||
sdkResolverShadowCheck(session, "hf-box", [
|
||||
{ type: "inline-style", property: "color", value: "blue" },
|
||||
]);
|
||||
const before = session.serialize();
|
||||
session.dispatch({ type: "setStyle", target: "hf-box", styles: { color: "blue" } });
|
||||
const after = session.serialize();
|
||||
expect(after).not.toBe(before); // cutover would write, not fall back
|
||||
});
|
||||
|
||||
it("B6: exception inside shadow never propagates to caller", async () => {
|
||||
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
|
||||
const session = await openComposition(BASE_HTML);
|
||||
session.dispatch = () => {
|
||||
throw new Error("sdk exploded");
|
||||
};
|
||||
const ops: PatchOperation[] = [{ type: "inline-style", property: "color", value: "blue" }];
|
||||
expect(() => runResolverShadow(session, "hf-box", ops)).not.toThrow();
|
||||
// A dispatch_error mismatch is still emitted (via telemetry)
|
||||
const ev = lastShadow();
|
||||
expect(ev).toBeDefined();
|
||||
expect(ev?.mismatchCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── C. Resolver-parity detection ────────────────────────────────────────────
|
||||
|
||||
describe("C. Resolver-parity detection", () => {
|
||||
it("C7: match → mismatchCount 0", async () => {
|
||||
const session = await openComposition(BASE_HTML);
|
||||
const mismatches = sdkResolverShadowCheck(session, "hf-box", [
|
||||
{ type: "inline-style", property: "color", value: "blue" },
|
||||
]);
|
||||
expect(mismatches).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("C8: element_not_found fires when SDK resolver returns null (v0.6.110 class)", () => {
|
||||
// Simulate the regression: SDK session cannot resolve the hfId the server
|
||||
// would address (e.g. scoped-id mismatch, resolver bug).
|
||||
const session = { getElement: () => null, getElements: () => [] } as unknown as Parameters<
|
||||
typeof sdkResolverShadowCheck
|
||||
>[0];
|
||||
const mismatches = sdkResolverShadowCheck(
|
||||
session as unknown as Parameters<typeof sdkResolverShadowCheck>[0],
|
||||
"hf-box",
|
||||
[{ type: "inline-style", property: "color", value: "red" }],
|
||||
);
|
||||
expect(mismatches).toHaveLength(1);
|
||||
expect(mismatches[0]).toMatchObject<SdkResolverMismatch>({
|
||||
kind: "element_not_found",
|
||||
hfId: "hf-box",
|
||||
});
|
||||
});
|
||||
|
||||
it("C8 inverse: no element_not_found when SDK resolves (server also resolves)", async () => {
|
||||
const session = await openComposition(BASE_HTML);
|
||||
const mismatches = sdkResolverShadowCheck(session, "hf-box", [
|
||||
{ type: "inline-style", property: "color", value: "blue" },
|
||||
]);
|
||||
expect(mismatches.some((m) => m.kind === "element_not_found")).toBe(false);
|
||||
});
|
||||
|
||||
it("C9: value_mismatch when dispatch yields different value than expected", async () => {
|
||||
const session = await makePoisonedStyleSession();
|
||||
const mismatches = sdkResolverShadowCheck(session, "hf-box", [
|
||||
{ type: "inline-style", property: "color", value: "blue" },
|
||||
]);
|
||||
expect(mismatches).toHaveLength(1);
|
||||
expect(mismatches[0]).toMatchObject<SdkResolverMismatch>({
|
||||
kind: "value_mismatch",
|
||||
hfId: "hf-box",
|
||||
property: "color",
|
||||
expected: "blue",
|
||||
});
|
||||
});
|
||||
|
||||
it("C8 runtime-node filter: hfId absent from source → suppressed (not a resolver bug)", () => {
|
||||
// The studio resolved a live-DOM element to an hf-id that the SDK session
|
||||
// doesn't contain AND that never appears in the on-disk source — it's a
|
||||
// node a composition <script> created at runtime (e.g. caption spans). Not
|
||||
// a resolver divergence; suppress.
|
||||
const session = { getElement: () => null, getElements: () => [] } as unknown as Parameters<
|
||||
typeof sdkResolverShadowCheck
|
||||
>[0];
|
||||
const source = `<div data-hf-id="hf-static">no runtime id here</div>`;
|
||||
const mismatches = sdkResolverShadowCheck(
|
||||
session,
|
||||
"hf-runtimeonly",
|
||||
[{ type: "inline-style", property: "color", value: "red" }],
|
||||
source,
|
||||
);
|
||||
expect(mismatches).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("C8 runtime-node filter: hfId PRESENT in source but missing from session → still flagged (real bug)", () => {
|
||||
const session = { getElement: () => null, getElements: () => [] } as unknown as Parameters<
|
||||
typeof sdkResolverShadowCheck
|
||||
>[0];
|
||||
const source = `<div data-hf-id="hf-realbug">in source, not in SDK session</div>`;
|
||||
const mismatches = sdkResolverShadowCheck(
|
||||
session,
|
||||
"hf-realbug",
|
||||
[{ type: "inline-style", property: "color", value: "red" }],
|
||||
source,
|
||||
);
|
||||
expect(mismatches).toHaveLength(1);
|
||||
expect(mismatches[0]?.kind).toBe("element_not_found");
|
||||
});
|
||||
|
||||
it("C8 sourceHfIdCount: emitted element_not_found carries source occurrence count", async () => {
|
||||
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
|
||||
// One unrelated element so the session isn't "empty" (empty sessions are
|
||||
// skipped as structural modeling gaps) — it just can't resolve hf-dup.
|
||||
const session = {
|
||||
getElement: () => null,
|
||||
getElements: () => [{ id: "hf-other" }],
|
||||
} as unknown as Composition;
|
||||
// id present twice in source (duplicate-id ambiguity) but absent from session
|
||||
const source = `<div data-hf-id="hf-dup">a</div><div data-hf-id="hf-dup">b</div>`;
|
||||
runResolverShadow(
|
||||
session,
|
||||
"hf-dup",
|
||||
[{ type: "inline-style", property: "color", value: "red" }],
|
||||
source,
|
||||
);
|
||||
expect(lastShadow()?.sourceHfIdCount).toBe(2);
|
||||
});
|
||||
|
||||
it("C8 sourceLooseMatchOnly: hfId matches source only as plain text, not a data-hf-id attribute", async () => {
|
||||
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
|
||||
const session = {
|
||||
getElement: () => null,
|
||||
getElements: () => [{ id: "hf-other" }],
|
||||
} as unknown as Composition;
|
||||
// "hf-widget" appears only inside a class name, never as data-hf-id="hf-widget".
|
||||
const source = `<div class="hf-widget-container">no attribute match here</div>`;
|
||||
runResolverShadow(
|
||||
session,
|
||||
"hf-widget",
|
||||
[{ type: "inline-style", property: "color", value: "red" }],
|
||||
source,
|
||||
);
|
||||
const ev = lastShadow();
|
||||
expect(ev?.sourceHfIdCount).toBe(0);
|
||||
expect(ev?.sourceLooseMatchOnly).toBe(true);
|
||||
});
|
||||
|
||||
it("C10: unmappable op type produces no mismatch (excluded, not flagged)", async () => {
|
||||
const session = await openComposition(BASE_HTML);
|
||||
// "unknown-op" is not in MAPPED_OP_TYPES, so it must be silently excluded.
|
||||
const ops = [{ type: "unknown-op", property: "x", value: "y" }] as unknown as PatchOperation[];
|
||||
const mismatches = sdkResolverShadowCheck(session, "hf-box", ops);
|
||||
expect(mismatches).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── D. Redaction ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe("D. Redaction", () => {
|
||||
it("D11: telemetry payload carries kind/hfId/count but NOT raw style value or text", async () => {
|
||||
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
|
||||
const session = await makePoisonedStyleSession();
|
||||
const sensitiveValue = "rgba(255, 0, 0, 0.5)";
|
||||
runResolverShadow(session, "hf-box", [
|
||||
{ type: "inline-style", property: "color", value: sensitiveValue },
|
||||
]);
|
||||
const ev = lastShadow();
|
||||
expect(ev).toBeDefined();
|
||||
expect(ev?.mismatchCount).toBe(1);
|
||||
// The raw sensitive value must NOT appear in the serialized mismatches
|
||||
const serialized = JSON.stringify(ev?.mismatches ?? "");
|
||||
expect(serialized).not.toContain(sensitiveValue);
|
||||
// But the kind and hfId must be present
|
||||
expect(serialized).toContain("value_mismatch");
|
||||
expect(serialized).toContain("hf-box");
|
||||
});
|
||||
|
||||
it("D11: text-content value is fully redacted (replaced with length marker)", async () => {
|
||||
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
|
||||
const session = await openComposition(BASE_HTML);
|
||||
const origDispatch = session.dispatch.bind(session);
|
||||
// Prevent setText from applying so text value differs
|
||||
session.dispatch = (op) => {
|
||||
if (typeof op === "object" && "type" in op && op.type === "setText") return;
|
||||
origDispatch(op);
|
||||
};
|
||||
const secretText = "confidential user content";
|
||||
runResolverShadow(session, "hf-box", [
|
||||
{ type: "text-content", property: "text", value: secretText },
|
||||
]);
|
||||
const ev = lastShadow();
|
||||
const serialized = JSON.stringify(ev?.mismatches ?? "");
|
||||
expect(serialized).not.toContain(secretText);
|
||||
expect(serialized).toContain("[redacted len=");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── E. Soak gate ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe("E. Soak gate", () => {
|
||||
it("E12: zero divergences → parity-proven", () => {
|
||||
expect(evaluateSoakGate(0)).toBe("parity-proven");
|
||||
});
|
||||
|
||||
it("E12: one divergence → divergence-detected", () => {
|
||||
expect(evaluateSoakGate(1)).toBe("divergence-detected");
|
||||
});
|
||||
|
||||
it("E12: many divergences → divergence-detected", () => {
|
||||
expect(evaluateSoakGate(100)).toBe("divergence-detected");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── F. recordResolverParity (extended coverage: timing / delete / gsap-add) ──
|
||||
|
||||
describe("F. recordResolverParity", () => {
|
||||
it("emits element_not_found when the SDK cannot resolve the target", async () => {
|
||||
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
|
||||
const session = await openComposition(BASE_HTML);
|
||||
await recordResolverParity(session, "hf-missing", "setTiming");
|
||||
const ev = lastShadow();
|
||||
expect(ev?.mismatchCount).toBe(1);
|
||||
expect(ev?.opLabel).toBe("setTiming");
|
||||
expect(JSON.stringify(ev?.mismatches)).toContain("element_not_found");
|
||||
});
|
||||
|
||||
it("emits nothing when the target resolves (parity)", async () => {
|
||||
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
|
||||
const session = await openComposition(BASE_HTML);
|
||||
await recordResolverParity(session, "hf-box", "removeElement");
|
||||
expect(trackedEvents.filter((e) => e.event === "sdk_resolver_shadow")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("is a no-op (no SDK touch) when the flag is off", async () => {
|
||||
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = false;
|
||||
const session = await openComposition(BASE_HTML);
|
||||
const spy = vi.spyOn(session, "getElement");
|
||||
await recordResolverParity(session, "hf-missing", "setTiming");
|
||||
expect(trackedEvents).toHaveLength(0);
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("never mutates the session (read-only resolver check)", async () => {
|
||||
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
|
||||
const session = await openComposition(BASE_HTML);
|
||||
await recordResolverParity(session, "hf-box", "setTiming");
|
||||
expect(session.getElement("hf-box")?.inlineStyles.color).toBe("red"); // unchanged
|
||||
});
|
||||
|
||||
it("suppresses the emit when the hfId is absent from source (runtime node)", async () => {
|
||||
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
|
||||
const session = await openComposition(BASE_HTML);
|
||||
await recordResolverParity(session, "hf-runtime", "setTiming", () =>
|
||||
Promise.resolve('<div data-hf-id="hf-other"></div>'),
|
||||
);
|
||||
expect(trackedEvents.filter((e) => e.event === "sdk_resolver_shadow")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("emits with sourceHfIdCount=1 when the hfId IS in source but missing from the session", async () => {
|
||||
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
|
||||
const session = await openComposition(BASE_HTML);
|
||||
await recordResolverParity(session, "hf-ghost", "setTiming", () =>
|
||||
Promise.resolve('<div data-hf-id="hf-ghost"></div>'),
|
||||
);
|
||||
const ev = lastShadow();
|
||||
expect(ev?.mismatchCount).toBe(1);
|
||||
expect(ev?.sourceHfIdCount).toBe(1);
|
||||
});
|
||||
|
||||
it("reports sourceHfIdCount=2 for a duplicate-id source (ambiguity)", async () => {
|
||||
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
|
||||
const session = await openComposition(BASE_HTML);
|
||||
await recordResolverParity(session, "hf-dup", "setTiming", () =>
|
||||
Promise.resolve('<a data-hf-id="hf-dup"></a><b data-hf-id="hf-dup"></b>'),
|
||||
);
|
||||
expect(lastShadow()?.sourceHfIdCount).toBe(2);
|
||||
});
|
||||
|
||||
it("emits without sourceHfIdCount when no reader is supplied (status quo)", async () => {
|
||||
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
|
||||
const session = await openComposition(BASE_HTML);
|
||||
await recordResolverParity(session, "hf-missing", "setTiming");
|
||||
const ev = lastShadow();
|
||||
expect(ev?.mismatchCount).toBe(1);
|
||||
expect(ev?.sourceHfIdCount).toBeUndefined();
|
||||
});
|
||||
|
||||
it("fails open: a readSource error still emits (no suppression), tagged sourceReadFailed", async () => {
|
||||
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
|
||||
const session = await openComposition(BASE_HTML);
|
||||
await recordResolverParity(session, "hf-missing", "setTiming", () =>
|
||||
Promise.reject(new Error("read failed")),
|
||||
);
|
||||
const ev = lastShadow();
|
||||
expect(ev?.mismatchCount).toBe(1);
|
||||
expect(ev?.sourceHfIdCount).toBeUndefined();
|
||||
// Distinguishes "reader threw" from "no reader wired" — every wild emission
|
||||
// of the setTiming class had an absent sourceHfIdCount and the two cases
|
||||
// were indistinguishable in telemetry.
|
||||
expect(ev?.sourceReadFailed).toBe(true);
|
||||
});
|
||||
|
||||
it("does not tag sourceReadFailed when no reader is supplied", async () => {
|
||||
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
|
||||
const session = await openComposition(BASE_HTML);
|
||||
await recordResolverParity(session, "hf-missing", "setTiming");
|
||||
expect(lastShadow()?.sourceReadFailed).toBeUndefined();
|
||||
});
|
||||
|
||||
it("empty session → ONE tagged session_empty event, no attempt, no element_not_found", async () => {
|
||||
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
|
||||
flushAttemptCounts(); // drain any counts left by earlier tests
|
||||
const session = await openComposition("<!DOCTYPE html><html><body></body></html>");
|
||||
await recordResolverParity(session, "hf-anything", "setTiming");
|
||||
await recordResolverParity(session, "hf-other", "setTiming"); // no re-emit
|
||||
const events = trackedEvents.filter((e) => e.event === "sdk_resolver_shadow");
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]?.props.sessionEmpty).toBe(true);
|
||||
expect(JSON.stringify(events[0]?.props.mismatches)).toContain("session_empty");
|
||||
expect(JSON.stringify(events[0]?.props.mismatches)).not.toContain("element_not_found");
|
||||
expect(flushAttemptCounts()).toBeNull(); // can't cut over → not in the denominator
|
||||
});
|
||||
|
||||
it("tags sourceLooseMatchOnly when hfId matches source only as plain text, not a data-hf-id attribute", async () => {
|
||||
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
|
||||
const session = await openComposition(BASE_HTML);
|
||||
// "hf-widget" appears only inside a class name, never as data-hf-id="hf-widget".
|
||||
await recordResolverParity(session, "hf-widget", "setTiming", () =>
|
||||
Promise.resolve('<div class="hf-widget-container"></div>'),
|
||||
);
|
||||
const ev = lastShadow();
|
||||
expect(ev?.mismatchCount).toBe(1);
|
||||
expect(ev?.sourceHfIdCount).toBe(0);
|
||||
expect(ev?.sourceLooseMatchOnly).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── G. recordAnimationResolverParity (GSAP animationId ops) ──────────────────
|
||||
|
||||
const GSAP_HTML = /* html */ `<!DOCTYPE html>
|
||||
<html><body>
|
||||
<div data-hf-id="hf-box" style="color: red">Hello</div>
|
||||
<script>var tl = gsap.timeline({ paused: true }); tl.to("[data-hf-id=\\"hf-box\\"]", { x: 100, duration: 1 }, 0);</script>
|
||||
</body></html>`;
|
||||
|
||||
const GSAP_UNMATCHED_SELECTOR_HTML = /* html */ `<!DOCTYPE html>
|
||||
<html><body>
|
||||
<div data-hf-id="hf-box" style="color: red">Hello</div>
|
||||
<script>var tl = gsap.timeline({ paused: true }); tl.to("#coral-band", { x: 100, duration: 1 }, 3);</script>
|
||||
</body></html>`;
|
||||
|
||||
describe("G. recordAnimationResolverParity", () => {
|
||||
it("emits animation_not_found when the SDK cannot resolve the animationId", async () => {
|
||||
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
|
||||
const session = await openComposition(GSAP_HTML);
|
||||
recordAnimationResolverParity(session, "no-such-anim", "setGsapTween");
|
||||
const ev = lastShadow();
|
||||
expect(ev?.mismatchCount).toBe(1);
|
||||
expect(ev?.opLabel).toBe("setGsapTween");
|
||||
expect(JSON.stringify(ev?.mismatches)).toContain("animation_not_found");
|
||||
});
|
||||
|
||||
it("emits nothing when the animationId resolves to a located animation", async () => {
|
||||
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
|
||||
const session = await openComposition(GSAP_HTML);
|
||||
const realId = session.getElements().flatMap((e) => [...e.animationIds])[0] ?? "";
|
||||
expect(realId).not.toBe(""); // fixture has a tween on hf-box
|
||||
recordAnimationResolverParity(session, realId, "removeGsapTween");
|
||||
expect(trackedEvents.filter((e) => e.event === "sdk_resolver_shadow")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("is a no-op when the flag is off", async () => {
|
||||
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = false;
|
||||
const session = await openComposition(GSAP_HTML);
|
||||
recordAnimationResolverParity(session, "no-such-anim", "setGsapTween");
|
||||
expect(trackedEvents).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("emits nothing when the animationId only resolves via getAllAnimationIds (no live DOM match) — repro of the v0.7.31 false-positive", async () => {
|
||||
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
|
||||
const session = await openComposition(GSAP_UNMATCHED_SELECTOR_HTML);
|
||||
const unmatchedId = [...session.getAllAnimationIds()][0] ?? "";
|
||||
expect(unmatchedId).not.toBe("");
|
||||
// Confirms the bug this fixes: the id is NOT attached to any element.
|
||||
expect(session.getElements().some((el) => el.animationIds.includes(unmatchedId))).toBe(false);
|
||||
recordAnimationResolverParity(session, unmatchedId, "removeAllKeyframes");
|
||||
expect(trackedEvents.filter((e) => e.event === "sdk_resolver_shadow")).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── H. Inlined sub-composition: bare leaf id resolves (regression) ───────────
|
||||
|
||||
// PostHog showed ~445 false `element_not_found` events, all on a bare leaf id
|
||||
// (hf-0ytc / #subscribe-btn) inside an inlined sub-composition. The studio reads
|
||||
// the bare data-hf-id off the DOM and the cutover dispatch resolves it via
|
||||
// resolveScoped (which locates the leaf inside the host subtree). But the shadow
|
||||
// resolved via Composition.getElement, which is canonical-only for a bare id and
|
||||
// returns null for a scoped element — so it flagged a divergence the real
|
||||
// dispatch path would not hit. The shadow now mirrors dispatch via resolveSnapshot.
|
||||
describe("H. inlined sub-composition leaf", () => {
|
||||
// host carries data-composition-file → new scope; leaf's scopedId is
|
||||
// "hf-host/hf-leaf" but its raw data-hf-id (what the studio reads) is bare.
|
||||
const INLINED_HTML = /* html */ `<!DOCTYPE html>
|
||||
<html><body>
|
||||
<div data-hf-id="hf-root" data-hf-root>
|
||||
<div data-hf-id="hf-host" data-composition-file="sub.html">
|
||||
<div data-hf-id="hf-leaf" style="color: red">Subscribe</div>
|
||||
</div>
|
||||
</div>
|
||||
</body></html>`;
|
||||
|
||||
it("getElement(bareLeaf) is null (canonical-only) — the trap the shadow used to hit", async () => {
|
||||
const session = await openComposition(INLINED_HTML);
|
||||
expect(session.getElement("hf-leaf")).toBeNull();
|
||||
expect(session.getElement("hf-host/hf-leaf")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("recordResolverParity emits NOTHING for a bare leaf inside a sub-comp", async () => {
|
||||
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
|
||||
const session = await openComposition(INLINED_HTML);
|
||||
await recordResolverParity(session, "hf-leaf", "setTiming");
|
||||
expect(trackedEvents.filter((e) => e.event === "sdk_resolver_shadow")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("sdkResolverShadowCheck does not flag element_not_found for a bare leaf in a sub-comp", async () => {
|
||||
const session = await openComposition(INLINED_HTML);
|
||||
const mismatches = sdkResolverShadowCheck(session, "hf-leaf", [
|
||||
{ type: "inline-style", property: "color", value: "blue" },
|
||||
]);
|
||||
expect(mismatches.some((m) => m.kind === "element_not_found")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── I. Attempt counter (denominator for the soak gate) ───────────────────────
|
||||
|
||||
describe("I. recordAttempt / flushAttemptCounts", () => {
|
||||
beforeEach(() => {
|
||||
// Drain any counts left over from a prior test so each test starts clean.
|
||||
flushAttemptCounts();
|
||||
});
|
||||
|
||||
it("flushAttemptCounts returns null when nothing has been recorded", () => {
|
||||
expect(flushAttemptCounts()).toBeNull();
|
||||
});
|
||||
|
||||
it("increments the counter for a given op label", () => {
|
||||
recordAttempt("setTiming");
|
||||
expect(flushAttemptCounts()).toEqual({ setTiming: 1 });
|
||||
});
|
||||
|
||||
it("accumulates multiple calls with the same label", () => {
|
||||
recordAttempt("setTiming");
|
||||
recordAttempt("setTiming");
|
||||
recordAttempt("setTiming");
|
||||
expect(flushAttemptCounts()).toEqual({ setTiming: 3 });
|
||||
});
|
||||
|
||||
it("tracks different labels independently", () => {
|
||||
recordAttempt("setTiming");
|
||||
recordAttempt("addGsapTween");
|
||||
recordAttempt("setTiming");
|
||||
expect(flushAttemptCounts()).toEqual({ setTiming: 2, addGsapTween: 1 });
|
||||
});
|
||||
|
||||
it("resets to empty after a flush", () => {
|
||||
recordAttempt("setTiming");
|
||||
flushAttemptCounts();
|
||||
expect(flushAttemptCounts()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("I. attempt counting inside the three emit functions", () => {
|
||||
beforeEach(() => {
|
||||
flushAttemptCounts();
|
||||
});
|
||||
|
||||
it("runResolverShadow counts an attempt on the parity (silent) path", async () => {
|
||||
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
|
||||
const session = await openComposition(BASE_HTML);
|
||||
runResolverShadow(session, "hf-box", [
|
||||
{ type: "inline-style", property: "color", value: "blue" },
|
||||
]);
|
||||
expect(flushAttemptCounts()).toEqual({ "dom-edit": 1 });
|
||||
expect(trackedEvents.filter((e) => e.event === "sdk_resolver_shadow")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("runResolverShadow counts an attempt on the divergence (emits) path too", async () => {
|
||||
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
|
||||
const session = await openComposition(BASE_HTML);
|
||||
runResolverShadow(session, "hf-missing", [
|
||||
{ type: "inline-style", property: "color", value: "blue" },
|
||||
]);
|
||||
expect(flushAttemptCounts()).toEqual({ "dom-edit": 1 });
|
||||
expect(trackedEvents.filter((e) => e.event === "sdk_resolver_shadow")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("recordResolverParity counts an attempt on the parity (silent) path", async () => {
|
||||
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
|
||||
const session = await openComposition(BASE_HTML);
|
||||
await recordResolverParity(session, "hf-box", "setTiming");
|
||||
expect(flushAttemptCounts()).toEqual({ setTiming: 1 });
|
||||
});
|
||||
|
||||
it("recordResolverParity counts an attempt on the divergence (emits) path too", async () => {
|
||||
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
|
||||
const session = await openComposition(BASE_HTML);
|
||||
await recordResolverParity(session, "hf-missing", "setTiming");
|
||||
expect(flushAttemptCounts()).toEqual({ setTiming: 1 });
|
||||
});
|
||||
|
||||
it("recordAnimationResolverParity counts an attempt on the parity (silent) path", async () => {
|
||||
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
|
||||
const session = await openComposition(GSAP_HTML);
|
||||
const realId = session.getElements().flatMap((e) => [...e.animationIds])[0] ?? "";
|
||||
expect(realId).not.toBe(""); // fixture has a tween on hf-box, see block G above
|
||||
recordAnimationResolverParity(session, realId, "removeGsapTween");
|
||||
expect(flushAttemptCounts()).toEqual({ removeGsapTween: 1 });
|
||||
expect(trackedEvents.filter((e) => e.event === "sdk_resolver_shadow")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("recordAnimationResolverParity counts an attempt on the divergence (emits) path too", async () => {
|
||||
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
|
||||
const session = await openComposition(GSAP_HTML);
|
||||
recordAnimationResolverParity(session, "no-such-anim", "setGsapTween");
|
||||
expect(flushAttemptCounts()).toEqual({ setGsapTween: 1 });
|
||||
expect(trackedEvents.filter((e) => e.event === "sdk_resolver_shadow")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("counts accumulate across multiple different chokepoints in one rollup", async () => {
|
||||
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
|
||||
const session = await openComposition(BASE_HTML);
|
||||
await recordResolverParity(session, "hf-box", "setTiming");
|
||||
await recordResolverParity(session, "hf-box", "setTiming");
|
||||
recordAnimationResolverParity(session, "no-such-anim", "setGsapTween");
|
||||
expect(flushAttemptCounts()).toEqual({ setTiming: 2, setGsapTween: 1 });
|
||||
});
|
||||
|
||||
it("does not count an attempt when the flag is off", async () => {
|
||||
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = false;
|
||||
const session = await openComposition(BASE_HTML);
|
||||
runResolverShadow(session, "hf-box", [
|
||||
{ type: "inline-style", property: "color", value: "blue" },
|
||||
]);
|
||||
await recordResolverParity(session, "hf-box", "setTiming");
|
||||
recordAnimationResolverParity(session, "no-such-anim", "setGsapTween");
|
||||
expect(flushAttemptCounts()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("I. production rollup wiring", () => {
|
||||
beforeEach(() => {
|
||||
flushAttemptCounts();
|
||||
__resetAttemptSchedulingForTests();
|
||||
vi.useFakeTimers({ toFake: ["setTimeout", "setInterval", "clearInterval", "clearTimeout"] });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
__resetAttemptSchedulingForTests();
|
||||
vi.useRealTimers();
|
||||
Object.defineProperty(document, "visibilityState", { value: "visible", configurable: true });
|
||||
});
|
||||
|
||||
it("does not emit a rollup event when nothing was recorded", () => {
|
||||
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
|
||||
vi.advanceTimersByTime(5 * 60_000);
|
||||
expect(trackedEvents.filter((e) => e.event === "sdk_resolver_shadow_attempt")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("emits a sdk_resolver_shadow_attempt rollup event every 5 minutes after the first attempt", async () => {
|
||||
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
|
||||
const session = await openComposition(BASE_HTML);
|
||||
await recordResolverParity(session, "hf-box", "setTiming");
|
||||
vi.advanceTimersByTime(5 * 60_000);
|
||||
const rollups = trackedEvents.filter((e) => e.event === "sdk_resolver_shadow_attempt");
|
||||
expect(rollups).toHaveLength(1);
|
||||
expect(rollups[0].props.counts).toBe(JSON.stringify({ setTiming: 1 }));
|
||||
});
|
||||
|
||||
it("flushes a rollup and forces a beacon delivery on visibilitychange -> hidden", async () => {
|
||||
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
|
||||
const session = await openComposition(BASE_HTML);
|
||||
await recordResolverParity(session, "hf-box", "setTiming");
|
||||
Object.defineProperty(document, "visibilityState", { value: "hidden", configurable: true });
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
const rollups = trackedEvents.filter((e) => e.event === "sdk_resolver_shadow_attempt");
|
||||
expect(rollups).toHaveLength(1);
|
||||
expect(rollups[0].props.counts).toBe(JSON.stringify({ setTiming: 1 }));
|
||||
// Delivery must not depend on studioTelemetry's own visibilitychange listener
|
||||
// winning a race — this module forces its own beacon flush.
|
||||
expect(flushViaBeacon).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not register a duplicate visibilitychange listener after a scheduling reset", async () => {
|
||||
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
|
||||
const session = await openComposition(BASE_HTML);
|
||||
await recordResolverParity(session, "hf-box", "setTiming");
|
||||
__resetAttemptSchedulingForTests();
|
||||
await recordResolverParity(session, "hf-box", "setTiming"); // re-arms scheduling, incl. listener
|
||||
Object.defineProperty(document, "visibilityState", { value: "hidden", configurable: true });
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
// If the reset had leaked the old listener, this would fire twice.
|
||||
expect(flushViaBeacon).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,546 @@
|
||||
/**
|
||||
* SDK resolver-parity tripwire (telemetry-only).
|
||||
*
|
||||
* Checks whether the SDK session resolves the same element id the server
|
||||
* patch path would target, then optionally verifies value parity after an
|
||||
* in-memory dispatch. Emits `sdk_resolver_shadow` on any divergence.
|
||||
*
|
||||
* Headline signal: `element_not_found` — the resolver divergence class that
|
||||
* caused the v0.6.110 regression. The writer-parity suite (#1533) cannot see
|
||||
* this class; this tripwire exists specifically to catch it.
|
||||
*
|
||||
* Decoupled from `STUDIO_SDK_CUTOVER_ENABLED`. Gated by its own flag
|
||||
* `STUDIO_SDK_RESOLVER_SHADOW_ENABLED` (default ON during the soak — collect
|
||||
* wild telemetry; flip off / remove once resolver parity is proven).
|
||||
* Telemetry-only — never writes to disk, never affects the user-visible edit.
|
||||
*/
|
||||
|
||||
import type { Composition, JsonPatchOp } from "@hyperframes/sdk";
|
||||
import type { PatchOperation } from "./sourcePatcher";
|
||||
import { STUDIO_SDK_RESOLVER_SHADOW_ENABLED } from "../components/editor/manualEditingAvailability";
|
||||
import { patchOpsToSdkEditOps } from "./sdkOpMapping";
|
||||
import { trackStudioEvent, flushViaBeacon } from "./studioTelemetry";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface SdkResolverMismatch {
|
||||
kind:
|
||||
| "element_not_found"
|
||||
| "value_mismatch"
|
||||
| "dispatch_error"
|
||||
| "animation_not_found"
|
||||
| "session_empty";
|
||||
hfId?: string;
|
||||
animationId?: string;
|
||||
property?: string;
|
||||
expected?: string | null;
|
||||
actual?: string | null | undefined;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// ─── Op helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
// Drop studio-internal data-hf-* markers the SDK model doesn't represent.
|
||||
function isShadowableOp(op: PatchOperation): boolean {
|
||||
const name =
|
||||
op.type === "attribute"
|
||||
? op.property.startsWith("data-")
|
||||
? op.property
|
||||
: `data-${op.property}`
|
||||
: op.type === "html-attribute"
|
||||
? op.property
|
||||
: null;
|
||||
return name === null || !name.startsWith("data-hf-");
|
||||
}
|
||||
|
||||
const MAPPED_OP_TYPES = new Set(["inline-style", "text-content", "attribute", "html-attribute"]);
|
||||
|
||||
// ─── Read-back helpers ────────────────────────────────────────────────────────
|
||||
|
||||
function kebabToCamel(prop: string): string {
|
||||
return prop.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase());
|
||||
}
|
||||
|
||||
function normalizeText(v: string | null | undefined): string | null {
|
||||
if (v == null) return null;
|
||||
const t = v.trim();
|
||||
return t === "" ? null : t;
|
||||
}
|
||||
|
||||
type FlatEl = NonNullable<ReturnType<Composition["getElement"]>>;
|
||||
type AttrMap = Record<string, string | null>;
|
||||
|
||||
/**
|
||||
* Resolve an hf-id to its snapshot the SAME way the SDK dispatch path does
|
||||
* (engine/model.ts resolveScoped), NOT via Composition.getElement.
|
||||
*
|
||||
* getElement is canonical-only for a bare id by design — it deliberately will
|
||||
* not resolve a bare id to a non-canonical (sub-composition) element, so that
|
||||
* removeElement(bareId) and getElement(bareId) agree on the same instance
|
||||
* (session.subcomp.test "ambiguous bare id" suite). But the cutover persist
|
||||
* path dispatches the studio's bare data-hf-id, and dispatch resolves it via
|
||||
* resolveScoped, which locates the leaf anywhere (canonical preferred, else
|
||||
* first match). So getElement under-resolves a bare leaf that lives inside an
|
||||
* inlined sub-composition (scopedId "host/leaf") — exactly the false
|
||||
* `element_not_found` this tripwire was emitting for inlined compositions.
|
||||
*
|
||||
* Mirror resolveScoped here: exact scoped-path match, then canonical bare
|
||||
* match, then first bare match — the resolvability dispatch actually has.
|
||||
*/
|
||||
// Count static `data-hf-id="<id>"` occurrences (both quote styles) in source.
|
||||
// Substring split, not regex — no escaping, and the id never contains a quote.
|
||||
function countHfIdInSource(source: string, id: string): number {
|
||||
return (
|
||||
source.split(`data-hf-id="${id}"`).length - 1 + (source.split(`data-hf-id='${id}'`).length - 1)
|
||||
);
|
||||
}
|
||||
|
||||
function resolveSnapshot(session: Composition, id: string): FlatEl | null {
|
||||
const els = session.getElements();
|
||||
const exact = els.find((el) => el.scopedId === id);
|
||||
if (exact) return exact;
|
||||
const matches = els.filter((el) => el.id === id);
|
||||
return matches.find((el) => el.scopedId === el.id) ?? matches[0] ?? null;
|
||||
}
|
||||
|
||||
function checkStyleOp(
|
||||
op: PatchOperation,
|
||||
el: FlatEl,
|
||||
): { expected: string | null; actual: string | null } {
|
||||
return {
|
||||
expected: op.value ?? null,
|
||||
actual: el.inlineStyles[kebabToCamel(op.property)] ?? el.inlineStyles[op.property] ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function checkTextOp(
|
||||
op: PatchOperation,
|
||||
el: FlatEl,
|
||||
): { expected: string | null; actual: string | null } {
|
||||
return { expected: normalizeText(op.value), actual: normalizeText(el.text) };
|
||||
}
|
||||
|
||||
function checkAttrOp(
|
||||
op: PatchOperation,
|
||||
el: FlatEl,
|
||||
): { property: string; expected: string | null; actual: string | null } {
|
||||
const property =
|
||||
op.type === "attribute"
|
||||
? op.property.startsWith("data-")
|
||||
? op.property
|
||||
: `data-${op.property}`
|
||||
: op.property;
|
||||
return {
|
||||
property,
|
||||
expected: op.value ?? null,
|
||||
actual: (el.attributes as AttrMap)[property] ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function checkOpValue(op: PatchOperation, el: FlatEl, hfId: string): SdkResolverMismatch | null {
|
||||
let property: string;
|
||||
let expected: string | null;
|
||||
let actual: string | null;
|
||||
|
||||
if (op.type === "inline-style") {
|
||||
property = op.property;
|
||||
({ expected, actual } = checkStyleOp(op, el));
|
||||
} else if (op.type === "text-content") {
|
||||
property = "text";
|
||||
({ expected, actual } = checkTextOp(op, el));
|
||||
} else if (op.type === "attribute" || op.type === "html-attribute") {
|
||||
({ property, expected, actual } = checkAttrOp(op, el));
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (actual === expected) return null;
|
||||
return { kind: "value_mismatch", hfId, property, expected, actual };
|
||||
}
|
||||
|
||||
// ─── Core check (pure — testable without flag) ────────────────────────────────
|
||||
|
||||
/**
|
||||
* Run the resolver shadow check against an already-open SDK session.
|
||||
*
|
||||
* Returns an array of mismatches (empty = parity). The value-parity check
|
||||
* dispatches the ops into the session to read the result back, then UNDOES
|
||||
* those mutations via the captured inverse patches before returning — the
|
||||
* session ends exactly as it started. This is essential: the session is shared
|
||||
* with the cutover path, and a residual shadow mutation would make the
|
||||
* subsequent sdkCutoverPersist see before === after and silently fall back to
|
||||
* the server path. Telemetry-only; the server path stays authoritative on disk.
|
||||
*
|
||||
* Exported for unit tests; call `runResolverShadow` at call sites.
|
||||
*/
|
||||
export function sdkResolverShadowCheck(
|
||||
session: Composition,
|
||||
hfId: string,
|
||||
ops: PatchOperation[],
|
||||
sourceContent?: string,
|
||||
): SdkResolverMismatch[] {
|
||||
if (!resolveSnapshot(session, hfId)) {
|
||||
// Runtime-node filter: an hf-id absent from the on-disk source the SDK
|
||||
// parsed was never in the static DOM — it belongs to an element a
|
||||
// composition <script> creates at runtime (e.g. caption word/group spans),
|
||||
// which the SDK session cannot model by design. That is NOT a resolver bug,
|
||||
// so suppress it. An hf-id PRESENT in source but missing from the session IS
|
||||
// a genuine resolver divergence (the v0.6.110 class) — keep emitting that.
|
||||
// ponytail: substring match; biases toward keeping signal on a loose hit.
|
||||
if (sourceContent !== undefined && !sourceContent.includes(hfId)) return [];
|
||||
// Loose match here vs. countHfIdInSource's strict data-hf-id="..." match in the
|
||||
// caller (runResolverShadow) means an emitted event can carry sourceHfIdCount: 0 —
|
||||
// see the comment on that field in runResolverShadow for what 0 means in that case.
|
||||
return [{ kind: "element_not_found", hfId }];
|
||||
}
|
||||
|
||||
const shadowable = ops.filter(isShadowableOp);
|
||||
if (shadowable.length === 0) return [];
|
||||
|
||||
// Silently skip op batches containing unmapped types — not a resolver bug.
|
||||
if (shadowable.some((op) => !MAPPED_OP_TYPES.has(op.type))) return [];
|
||||
|
||||
// Capture the inverse of the shadow dispatch so we can restore the session.
|
||||
// `batch` fires a single PatchEvent whose `inversePatches` are already in
|
||||
// reverse-apply order (session.ts reverses inside buildPatchEvent), so
|
||||
// applyPatches(inverse) undoes the dispatch with no further reordering. If a
|
||||
// future SDK refactor ever coalesces batch into a composite with no per-op
|
||||
// inverse, this restore breaks — keep batch emitting inverse patches.
|
||||
const inverse: JsonPatchOp[] = [];
|
||||
const stopCapture = session.on("patch", (e) => inverse.push(...e.inversePatches));
|
||||
// restore() runs in `finally` so the patch listener is always removed and the
|
||||
// session is always undone — even if checkOpValue throws between dispatch and
|
||||
// return. A residual mutation or leaked listener on the shared session is the
|
||||
// exact cutover-coupling failure mode this module exists to avoid.
|
||||
try {
|
||||
try {
|
||||
const editOps = patchOpsToSdkEditOps(hfId, shadowable);
|
||||
session.batch(() => {
|
||||
for (const op of editOps) session.dispatch(op);
|
||||
});
|
||||
} catch (err) {
|
||||
return [{ kind: "dispatch_error", hfId, error: String(err) }];
|
||||
}
|
||||
|
||||
const el = resolveSnapshot(session, hfId);
|
||||
if (!el) return [{ kind: "element_not_found", hfId }];
|
||||
|
||||
return shadowable
|
||||
.map((op) => checkOpValue(op, el, hfId))
|
||||
.filter((m): m is SdkResolverMismatch => m !== null);
|
||||
} finally {
|
||||
stopCapture();
|
||||
if (inverse.length > 0) session.applyPatches(inverse);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Attempt counter (denominator for the soak gate) ──────────────────────────
|
||||
//
|
||||
// The three emit functions below only fire a PostHog event on divergence —
|
||||
// parity is silent, by design, to avoid firing on every edit. That leaves no
|
||||
// way to compute a rate (divergences / attempts): we can count failures but
|
||||
// never attempts. This counter tracks attempts in memory and rolls them up
|
||||
// into ONE low-frequency event instead of firing per-attempt, which would
|
||||
// recreate the exact chattiness problem the divergence-only design avoids.
|
||||
|
||||
const attemptCounts: Record<string, number> = {};
|
||||
|
||||
/**
|
||||
* Record that the resolver-shadow tripwire ran for `opLabel`, regardless of
|
||||
* outcome (parity or divergence). No flag check of its own — only ever called
|
||||
* from inside the three emit functions below, after their own
|
||||
* STUDIO_SDK_RESOLVER_SHADOW_ENABLED guard, so it's already flag-gated.
|
||||
*/
|
||||
export function recordAttempt(opLabel: string): void {
|
||||
attemptCounts[opLabel] = (attemptCounts[opLabel] ?? 0) + 1;
|
||||
ensureAttemptFlushScheduled();
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the accumulated attempt counts since the last flush (or `null` if
|
||||
* nothing has been recorded — no point emitting an empty rollup), and reset
|
||||
* the counter to empty.
|
||||
*/
|
||||
export function flushAttemptCounts(): Record<string, number> | null {
|
||||
const keys = Object.keys(attemptCounts);
|
||||
if (keys.length === 0) return null;
|
||||
const snapshot: Record<string, number> = {};
|
||||
for (const key of keys) {
|
||||
snapshot[key] = attemptCounts[key];
|
||||
delete attemptCounts[key];
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
const ATTEMPT_FLUSH_INTERVAL_MS = 5 * 60_000;
|
||||
let attemptFlushTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let attemptVisibilityHandler: (() => void) | null = null;
|
||||
|
||||
function flushAndEmitAttempts(): void {
|
||||
const counts = flushAttemptCounts();
|
||||
if (counts === null) return;
|
||||
trackStudioEvent("sdk_resolver_shadow_attempt", { counts: JSON.stringify(counts) });
|
||||
}
|
||||
|
||||
// Lazily starts the rollup timer + visibilitychange listener on the FIRST
|
||||
// attempt in a session — mirrors studioTelemetry.ts's own lazy flushTimer
|
||||
// start, so a session that never exercises the tripwire never runs a
|
||||
// background timer.
|
||||
function ensureAttemptFlushScheduled(): void {
|
||||
if (!attemptFlushTimer) {
|
||||
attemptFlushTimer = setInterval(flushAndEmitAttempts, ATTEMPT_FLUSH_INTERVAL_MS);
|
||||
}
|
||||
if (!attemptVisibilityHandler && typeof document !== "undefined") {
|
||||
attemptVisibilityHandler = () => {
|
||||
if (document.visibilityState !== "hidden") return;
|
||||
flushAndEmitAttempts();
|
||||
// studioTelemetry.ts registers its own visibilitychange listener (on
|
||||
// window, at module load) that drains its queue via sendBeacon. Listener
|
||||
// execution order between that handler and this one (on document,
|
||||
// registered lazily) is not something to rely on — whichever runs
|
||||
// first could otherwise beacon-flush before or after this rollup lands
|
||||
// in the queue. Forcing a beacon flush here makes delivery of this
|
||||
// rollup event correct regardless of that order.
|
||||
flushViaBeacon();
|
||||
};
|
||||
document.addEventListener("visibilitychange", attemptVisibilityHandler);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test-only: clears the lazy timer/listener singleton state so tests can
|
||||
* verify the "starts on first attempt" behavior in isolation, without an
|
||||
* earlier test's real-timer interval (or visibilitychange listener) silently
|
||||
* surviving into a later test. Does NOT touch attemptCounts — only the
|
||||
* scheduling state. Not part of the public module contract; only imported
|
||||
* from sdkResolverShadow.test.ts.
|
||||
*/
|
||||
export function __resetAttemptSchedulingForTests(): void {
|
||||
if (attemptFlushTimer) clearInterval(attemptFlushTimer);
|
||||
attemptFlushTimer = null;
|
||||
if (attemptVisibilityHandler && typeof document !== "undefined") {
|
||||
document.removeEventListener("visibilitychange", attemptVisibilityHandler);
|
||||
}
|
||||
attemptVisibilityHandler = null;
|
||||
}
|
||||
|
||||
// ─── Telemetry ────────────────────────────────────────────────────────────────
|
||||
|
||||
// Redact all user-content values before telemetry: style values and text both
|
||||
// carry user data. Keep only the length so we can detect truncation without
|
||||
// leaking the actual bytes.
|
||||
function redactValue(value: string | null | undefined): string | null | undefined {
|
||||
if (value == null) return value;
|
||||
return `[redacted len=${value.length}]`;
|
||||
}
|
||||
|
||||
function redactMismatches(mismatches: SdkResolverMismatch[]): SdkResolverMismatch[] {
|
||||
return mismatches.map((m) => ({
|
||||
...m,
|
||||
expected: redactValue(m.expected),
|
||||
actual: redactValue(m.actual),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the resolver shadow and emit `sdk_resolver_shadow` telemetry.
|
||||
* No-op when `STUDIO_SDK_RESOLVER_SHADOW_ENABLED` is false.
|
||||
* Never throws — any exception inside the shadow is swallowed.
|
||||
*
|
||||
* Side-effect-free on the live session: sdkResolverShadowCheck dispatches into
|
||||
* the session to read values back, then undoes those mutations before returning
|
||||
* (see below). The session is shared with the cutover path, so it MUST end the
|
||||
* call exactly as it started.
|
||||
*/
|
||||
// Sessions whose empty-session modeling gap has already been reported — one
|
||||
// event per session instance, not one per edit (the per-edit storm is noise;
|
||||
// the EXISTENCE of the gap is the signal).
|
||||
const emptySessionReported = new WeakSet<Composition>();
|
||||
|
||||
/**
|
||||
* An empty session structurally cannot resolve ANY id — a modeling gap (empty
|
||||
* file, comp shape the SDK can't parse into elements), not a resolver
|
||||
* divergence, and it can't cut over either, so it stays out of the attempt
|
||||
* denominator. But silence would blind the tripwire to exactly the class that
|
||||
* exposed the template-comp bug — so emit ONE distinguishable `session_empty`
|
||||
* event per session, then skip. Returns true when the caller should skip.
|
||||
*/
|
||||
function reportEmptySession(session: Composition, opLabel: string): boolean {
|
||||
if (session.getElements().length !== 0) return false;
|
||||
if (!emptySessionReported.has(session)) {
|
||||
emptySessionReported.add(session);
|
||||
trackStudioEvent("sdk_resolver_shadow", {
|
||||
opLabel,
|
||||
sessionEmpty: true,
|
||||
sessionElementCount: 0,
|
||||
mismatchCount: 1,
|
||||
mismatches: JSON.stringify([{ kind: "session_empty" } satisfies SdkResolverMismatch]),
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function runResolverShadow(
|
||||
session: Composition,
|
||||
hfId: string | null | undefined,
|
||||
ops: PatchOperation[],
|
||||
sourceContent?: string,
|
||||
): void {
|
||||
if (!STUDIO_SDK_RESOLVER_SHADOW_ENABLED) return;
|
||||
if (!hfId) return;
|
||||
try {
|
||||
if (reportEmptySession(session, "dom-edit")) return;
|
||||
recordAttempt("dom-edit");
|
||||
const mismatches = sdkResolverShadowCheck(session, hfId, ops, sourceContent);
|
||||
// Emit only on divergence — parity is silent, matching recordResolverParity
|
||||
// and recordAnimationResolverParity. Otherwise this fires a PostHog event on
|
||||
// every style/text/attr edit (the editor's chattiest path) at default-ON.
|
||||
if (mismatches.length === 0) return;
|
||||
const isElementNotFound = mismatches.some((m) => m.kind === "element_not_found");
|
||||
const strictCount =
|
||||
isElementNotFound && sourceContent !== undefined
|
||||
? countHfIdInSource(sourceContent, hfId)
|
||||
: undefined;
|
||||
trackStudioEvent("sdk_resolver_shadow", {
|
||||
hfId,
|
||||
// sessionElementCount > 0 + element_not_found = runtime-only element;
|
||||
// sessionElementCount === 0 = session is empty/broken (actionable).
|
||||
sessionElementCount: session.getElements().length,
|
||||
// Count of data-hf-id="<id>" occurrences in source for an emitted
|
||||
// element_not_found. >1 = duplicate ids → resolver picked the wrong
|
||||
// instance; =1 = single static node the SDK parse dropped (foreign-content
|
||||
// exclusion / sub-comp inlining gap); =0 = the runtime-node filter above
|
||||
// uses a loose substring match (biased toward keeping signal) while this
|
||||
// count uses a strict attribute match — see sourceLooseMatchOnly below.
|
||||
...(strictCount !== undefined ? { sourceHfIdCount: strictCount } : {}),
|
||||
// Loose suppression check matched (kept this event) but the strict
|
||||
// attribute count came back 0 — see the sourceHfIdCount comment above.
|
||||
...(strictCount === 0 ? { sourceLooseMatchOnly: true } : {}),
|
||||
mismatchCount: mismatches.length,
|
||||
mismatches: JSON.stringify(redactMismatches(mismatches)),
|
||||
});
|
||||
} catch {
|
||||
// never propagate from the shadow path
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record element-resolution parity for an element-targeted op WITHOUT
|
||||
* dispatching. Read-only: emits a single `element_not_found` event when the SDK
|
||||
* can't resolve a target the server path is addressing. This extends the
|
||||
* tripwire beyond the DOM-edit path (runResolverShadow) to the other
|
||||
* element-targeted cutover chokepoints — timing, delete, GSAP-tween add — for
|
||||
* the headline resolver signal, without the cost/mutation of a value check.
|
||||
*
|
||||
* No-op when the shadow flag is off; never throws; never mutates the session.
|
||||
*/
|
||||
export async function recordResolverParity(
|
||||
session: Composition | null | undefined,
|
||||
hfId: string | null | undefined,
|
||||
opLabel: string,
|
||||
readSource?: () => Promise<string | undefined>,
|
||||
): Promise<void> {
|
||||
if (!STUDIO_SDK_RESOLVER_SHADOW_ENABLED) return;
|
||||
if (!session || !hfId) return;
|
||||
try {
|
||||
if (reportEmptySession(session, opLabel)) return;
|
||||
recordAttempt(opLabel);
|
||||
if (resolveSnapshot(session, hfId)) return; // resolves — parity, nothing to record
|
||||
// Capture BEFORE any await: this call is fire-and-forget (`void recordResolverParity(...)`)
|
||||
// and the caller runs its own session mutation synchronously right after this call
|
||||
// returns. getElements() caches and that cache is invalidated on dispatch, so reading
|
||||
// the count after an await would silently reflect POST-edit state, not the pre-edit
|
||||
// state this field exists to diagnose.
|
||||
const sessionElementCount = session.getElements().length;
|
||||
// Cheap check passed above, so the source read only runs on a real divergence.
|
||||
let source: string | undefined;
|
||||
let sourceReadFailed = false;
|
||||
if (readSource) {
|
||||
try {
|
||||
source = await readSource();
|
||||
} catch {
|
||||
source = undefined; // fail-open: a read error must not drop a real divergence
|
||||
sourceReadFailed = true;
|
||||
}
|
||||
}
|
||||
// Runtime-generated node the static parse can't model — suppress (mirrors the dom-edit path).
|
||||
if (source !== undefined && !source.includes(hfId)) return;
|
||||
const strictCount = source !== undefined ? countHfIdInSource(source, hfId) : undefined;
|
||||
trackStudioEvent("sdk_resolver_shadow", {
|
||||
hfId,
|
||||
opLabel,
|
||||
sessionElementCount,
|
||||
// sourceHfIdCount: strict data-hf-id="..." attribute count. Can be 0 even
|
||||
// on an emitted (non-suppressed) event — the suppression check above is a
|
||||
// loose substring match (biased toward keeping signal); see the longer
|
||||
// comment on this field in runResolverShadow for the full explanation.
|
||||
...(strictCount !== undefined ? { sourceHfIdCount: strictCount } : {}),
|
||||
// Loose suppression check matched (kept this event) but the strict
|
||||
// attribute count came back 0 — hfId appeared as plain text (class name,
|
||||
// comment, script string) but never as a data-hf-id="..." attribute.
|
||||
// Lets telemetry consumers filter this cohort without parsing the
|
||||
// sourceHfIdCount comment above.
|
||||
...(strictCount === 0 ? { sourceLooseMatchOnly: true } : {}),
|
||||
// The reader was wired but threw — distinguishes "read failed, emitted
|
||||
// fail-open without the suppression/count checks" from "no reader wired".
|
||||
...(sourceReadFailed ? { sourceReadFailed: true } : {}),
|
||||
mismatchCount: 1,
|
||||
mismatches: JSON.stringify([
|
||||
{ kind: "element_not_found", hfId } satisfies SdkResolverMismatch,
|
||||
]),
|
||||
});
|
||||
} catch {
|
||||
// never propagate from the shadow path
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record animation-resolution parity for an animationId-targeted GSAP op WITHOUT
|
||||
* dispatching. Read-only: emits `animation_not_found` when the SDK can't resolve
|
||||
* the animationId the server GSAP path is addressing — the GSAP-edit-surface
|
||||
* analogue of element_not_found. The SDK's resolvable animation ids are the
|
||||
* located ids attached to elements (buildAnimationIdMap) OR any id parsed from
|
||||
* the script regardless of DOM match (getAllAnimationIds) — a target absent
|
||||
* from both is a resolver divergence.
|
||||
*
|
||||
* No-op when the shadow flag is off; never throws; never mutates the session.
|
||||
*/
|
||||
export function recordAnimationResolverParity(
|
||||
session: Composition | null | undefined,
|
||||
animationId: string,
|
||||
opLabel: string,
|
||||
): void {
|
||||
if (!STUDIO_SDK_RESOLVER_SHADOW_ENABLED) return;
|
||||
if (!session || !animationId) return;
|
||||
try {
|
||||
recordAttempt(opLabel);
|
||||
const elements = session.getElements();
|
||||
const resolves =
|
||||
elements.some((el) => el.animationIds.includes(animationId)) ||
|
||||
session.getAllAnimationIds().has(animationId);
|
||||
if (resolves) return; // SDK locates the animation — parity
|
||||
trackStudioEvent("sdk_resolver_shadow", {
|
||||
animationId,
|
||||
opLabel,
|
||||
sessionElementCount: elements.length,
|
||||
mismatchCount: 1,
|
||||
mismatches: JSON.stringify([
|
||||
{ kind: "animation_not_found", animationId } satisfies SdkResolverMismatch,
|
||||
]),
|
||||
});
|
||||
} catch {
|
||||
// never propagate from the shadow path
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Soak gate ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Evaluate the soak-gate exit criterion.
|
||||
*
|
||||
* A clean soak window has zero `element_not_found` divergences. When that
|
||||
* condition holds, resolver parity is proven and the flag can be retired.
|
||||
*/
|
||||
export function evaluateSoakGate(divergenceCount: number): "parity-proven" | "divergence-detected" {
|
||||
return divergenceCount === 0 ? "parity-proven" : "divergence-detected";
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { buildSlideshowIslandHtml } from "./setSlideshowManifest";
|
||||
import { parseSlideshowManifest } from "@hyperframes/core/slideshow";
|
||||
import type { CutoverDeps } from "./sdkCutover";
|
||||
|
||||
// Fix 3: vi.mock must be at module top level so Vitest can hoist them.
|
||||
vi.mock("../components/editor/manualEditingAvailability", () => ({
|
||||
STUDIO_SDK_CUTOVER_ENABLED: true,
|
||||
STUDIO_SDK_RESOLVER_SHADOW_ENABLED: false,
|
||||
}));
|
||||
vi.mock("./studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
|
||||
|
||||
describe("buildSlideshowIslandHtml", () => {
|
||||
it("serializes a manifest into a script island", () => {
|
||||
const html = buildSlideshowIslandHtml({ slides: [{ sceneId: "a" }] });
|
||||
expect(html).toContain('type="application/hyperframes-slideshow+json"');
|
||||
expect(html).toContain('"sceneId": "a"');
|
||||
});
|
||||
|
||||
it("stamps version 1, preserving an existing version", () => {
|
||||
expect(buildSlideshowIslandHtml({ slides: [] })).toContain('"version": 1');
|
||||
expect(buildSlideshowIslandHtml({ version: 2, slides: [] })).toContain('"version": 2');
|
||||
});
|
||||
|
||||
it("round-trips through parseSlideshowManifest", () => {
|
||||
const html = `<html><body>${buildSlideshowIslandHtml({ slides: [{ sceneId: "x" }] })}</body></html>`;
|
||||
const parsed = parseSlideshowManifest(html);
|
||||
expect(parsed?.slides[0]?.sceneId).toBe("x");
|
||||
});
|
||||
|
||||
it("wraps the JSON in a script tag with no extra nesting", () => {
|
||||
const html = buildSlideshowIslandHtml({ slides: [] });
|
||||
expect(html.startsWith("<script")).toBe(true);
|
||||
expect(html.trimEnd().endsWith("</script>")).toBe(true);
|
||||
});
|
||||
|
||||
// Fix 1: </script> breakout test
|
||||
it("does NOT embed a literal </script> inside the JSON body", () => {
|
||||
const manifest = { slides: [{ sceneId: "s1", notes: "</script><b>x</b>" }] };
|
||||
const html = buildSlideshowIslandHtml(manifest);
|
||||
// The only closing </script> should be the real one at the very end.
|
||||
// Strip that trailing tag and confirm no </script> remains.
|
||||
const withoutClosingTag = html.slice(0, html.lastIndexOf("</script>"));
|
||||
expect(withoutClosingTag).not.toContain("</script>");
|
||||
});
|
||||
|
||||
it("round-trips a manifest containing </script> in notes via parseSlideshowManifest", () => {
|
||||
const notes = "</script><b>x</b>";
|
||||
const manifest = { slides: [{ sceneId: "s1", notes }] };
|
||||
const html = `<html><body>${buildSlideshowIslandHtml(manifest)}</body></html>`;
|
||||
const parsed = parseSlideshowManifest(html);
|
||||
expect(parsed?.slides[0]).toMatchObject({ sceneId: "s1", notes });
|
||||
});
|
||||
});
|
||||
|
||||
describe("persistSlideshowManifest — op construction", () => {
|
||||
function makeDeps(writeProjectFile: ReturnType<typeof vi.fn>): CutoverDeps {
|
||||
return {
|
||||
editHistory: { recordEdit: vi.fn().mockResolvedValue(undefined) },
|
||||
writeProjectFile,
|
||||
reloadPreview: vi.fn(),
|
||||
domEditSaveTimestampRef: { current: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
it("writes the serialized manifest when the island already exists", async () => {
|
||||
const { persistSlideshowManifest } = await import("./setSlideshowManifest");
|
||||
|
||||
const manifest = { slides: [{ sceneId: "scene-1" }] };
|
||||
const island = buildSlideshowIslandHtml(manifest);
|
||||
const originalHtml = `<html><head></head><body>${island}</body></html>`;
|
||||
|
||||
const writeProjectFile = vi.fn().mockResolvedValue(undefined);
|
||||
const deps = makeDeps(writeProjectFile);
|
||||
const recordEdit = deps.editHistory.recordEdit as ReturnType<typeof vi.fn>;
|
||||
|
||||
const mockSession = { serialize: vi.fn().mockReturnValue(originalHtml) };
|
||||
|
||||
await persistSlideshowManifest({
|
||||
manifest: { slides: [{ sceneId: "scene-2" }] },
|
||||
sdkSession: mockSession as never,
|
||||
originalContent: originalHtml,
|
||||
targetPath: "/proj/comp.html",
|
||||
deps,
|
||||
});
|
||||
|
||||
expect(writeProjectFile).toHaveBeenCalledOnce();
|
||||
const written: string = writeProjectFile.mock.calls[0]?.[1] as string;
|
||||
expect(written).toContain('"sceneId": "scene-2"');
|
||||
expect(recordEdit).toHaveBeenCalledWith(expect.objectContaining({ label: "Edit slideshow" }));
|
||||
});
|
||||
|
||||
it("inserts the island when none exists in the serialized HTML", async () => {
|
||||
const { persistSlideshowManifest } = await import("./setSlideshowManifest");
|
||||
|
||||
const baseHtml = "<html><head></head><body></body></html>";
|
||||
const writeProjectFile = vi.fn().mockResolvedValue(undefined);
|
||||
const deps = makeDeps(writeProjectFile);
|
||||
const mockSession = { serialize: vi.fn().mockReturnValue(baseHtml) };
|
||||
|
||||
await persistSlideshowManifest({
|
||||
manifest: { slides: [{ sceneId: "new-scene" }] },
|
||||
sdkSession: mockSession as never,
|
||||
originalContent: baseHtml,
|
||||
targetPath: "/proj/comp.html",
|
||||
deps,
|
||||
});
|
||||
|
||||
expect(writeProjectFile).toHaveBeenCalledOnce();
|
||||
const written: string = writeProjectFile.mock.calls[0]?.[1] as string;
|
||||
expect(written).toContain('"sceneId": "new-scene"');
|
||||
expect(written).toContain('type="application/hyperframes-slideshow+json"');
|
||||
});
|
||||
|
||||
// Fix 2: two stale islands should collapse to exactly one after persist
|
||||
it("collapses two stale islands into exactly one after persist", async () => {
|
||||
const { persistSlideshowManifest } = await import("./setSlideshowManifest");
|
||||
|
||||
const staleIsland1 = buildSlideshowIslandHtml({ slides: [{ sceneId: "old-1" }] });
|
||||
const staleIsland2 = buildSlideshowIslandHtml({ slides: [{ sceneId: "old-2" }] });
|
||||
const twoIslandHtml = `<html><head></head><body>${staleIsland1}${staleIsland2}</body></html>`;
|
||||
|
||||
const writeProjectFile = vi.fn().mockResolvedValue(undefined);
|
||||
const deps = makeDeps(writeProjectFile);
|
||||
const mockSession = { serialize: vi.fn().mockReturnValue(twoIslandHtml) };
|
||||
|
||||
await persistSlideshowManifest({
|
||||
manifest: { slides: [{ sceneId: "fresh" }] },
|
||||
sdkSession: mockSession as never,
|
||||
originalContent: twoIslandHtml,
|
||||
targetPath: "/proj/comp.html",
|
||||
deps,
|
||||
});
|
||||
|
||||
expect(writeProjectFile).toHaveBeenCalledOnce();
|
||||
const written: string = writeProjectFile.mock.calls[0]?.[1] as string;
|
||||
|
||||
// Count occurrences of the island script open tag
|
||||
const islandCount = (written.match(/type="application\/hyperframes-slideshow\+json"/g) ?? [])
|
||||
.length;
|
||||
expect(islandCount).toBe(1);
|
||||
expect(written).toContain('"sceneId": "fresh"');
|
||||
expect(written).not.toContain('"sceneId": "old-1"');
|
||||
expect(written).not.toContain('"sceneId": "old-2"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* setSlideshowManifest — Studio persist helper for the slideshow JSON island.
|
||||
*
|
||||
* The island is a `<script type="application/hyperframes-slideshow+json">` node
|
||||
* embedded in the composition HTML. Because <script> nodes are not tracked by
|
||||
* the SDK element tree (they have no hf-id), we cannot use a `setText` dispatch
|
||||
* op. Instead we:
|
||||
* 1. Call `sdkSession.serialize()` to get the current HTML.
|
||||
* 2. Replace or insert the island with the new manifest JSON.
|
||||
* 3. Write via `persistSdkSerialize` — the same low-level writer used by the
|
||||
* other SDK-cutover paths (sdkDeletePersist, sdkTimingPersist, etc.).
|
||||
*
|
||||
* Inserting when absent: if no island exists in the serialized HTML we insert
|
||||
* one before `</body>` (or, if no </body>, append to the end of the document).
|
||||
* This means callers do NOT need to pre-scaffold the island; Task 11 / the panel
|
||||
* can call persistSlideshowManifest on a fresh composition.
|
||||
*/
|
||||
|
||||
import type { SlideshowManifest } from "@hyperframes/core/slideshow";
|
||||
import {
|
||||
SLIDESHOW_ISLAND_TYPE,
|
||||
SLIDESHOW_MANIFEST_VERSION,
|
||||
parseSlideshowManifest,
|
||||
slideshowIslandRegex,
|
||||
} from "@hyperframes/core/slideshow";
|
||||
import type { Composition } from "@hyperframes/sdk";
|
||||
import type { CutoverDeps } from "./sdkCutover";
|
||||
import { persistSdkSerialize } from "./sdkCutover";
|
||||
|
||||
// Matches ALL <script type="application/hyperframes-slideshow+json"> ... </script>
|
||||
// blocks (global + case-insensitive) so we can strip every stale island in one pass.
|
||||
const ISLAND_RE = slideshowIslandRegex("gi");
|
||||
|
||||
export function buildSlideshowIslandHtml(manifest: SlideshowManifest): string {
|
||||
// Stamp the schema version (preserve an existing one) so future schema
|
||||
// changes can detect and migrate older islands.
|
||||
const versioned: SlideshowManifest = {
|
||||
version: manifest.version ?? SLIDESHOW_MANIFEST_VERSION,
|
||||
...manifest,
|
||||
};
|
||||
// Escape `<` and `>` so that a manifest field containing `</script>` cannot
|
||||
// break out of the script tag. JSON.parse round-trips </> unchanged.
|
||||
const json = JSON.stringify(versioned, null, 2).replace(/</g, "\\u003c").replace(/>/g, "\\u003e");
|
||||
return `<script type="${SLIDESHOW_ISLAND_TYPE}">\n${json}\n</script>`;
|
||||
}
|
||||
|
||||
export interface PersistSlideshowArgs {
|
||||
manifest: SlideshowManifest;
|
||||
/** Live SDK Composition session — used only to read the current serialized HTML. */
|
||||
sdkSession: Pick<Composition, "serialize">;
|
||||
/** Exact on-disk bytes for the undo-history `before` baseline. */
|
||||
originalContent: string;
|
||||
targetPath: string;
|
||||
deps: CutoverDeps;
|
||||
/** Optional label override (default: "Edit slideshow"). */
|
||||
label?: string;
|
||||
/**
|
||||
* When provided, threads a coalesceKey into recordEdit so rapid writes
|
||||
* (e.g. per-keystroke notes changes) collapse to a single undo entry.
|
||||
*/
|
||||
coalesceKey?: string;
|
||||
}
|
||||
|
||||
export async function persistSlideshowManifest(args: PersistSlideshowArgs): Promise<void> {
|
||||
const { manifest, sdkSession, originalContent, targetPath, deps, label, coalesceKey } = args;
|
||||
|
||||
const islandHtml = buildSlideshowIslandHtml(manifest);
|
||||
|
||||
// Write-time validation: confirm the island we just built round-trips to a
|
||||
// valid manifest before touching disk, so a malformed edit can't corrupt the
|
||||
// composition. parseSlideshowManifest throws on a structurally-invalid island.
|
||||
try {
|
||||
if (!parseSlideshowManifest(islandHtml)) {
|
||||
throw new Error("built island did not parse back to a manifest");
|
||||
}
|
||||
} catch (err) {
|
||||
throw new Error(`refusing to persist invalid slideshow manifest: ${(err as Error).message}`);
|
||||
}
|
||||
|
||||
const current = sdkSession.serialize();
|
||||
|
||||
// Strip ALL existing islands (handles the case where two stale islands
|
||||
// accumulated) then insert exactly one fresh island.
|
||||
const stripped = current.replace(ISLAND_RE, "");
|
||||
|
||||
let after: string;
|
||||
const bodyClose = stripped.lastIndexOf("</body>");
|
||||
if (bodyClose !== -1) {
|
||||
after = stripped.slice(0, bodyClose) + islandHtml + "\n" + stripped.slice(bodyClose);
|
||||
} else {
|
||||
after = stripped + "\n" + islandHtml;
|
||||
}
|
||||
|
||||
// No-op gate: if the rewritten HTML is byte-identical to the current serialized
|
||||
// HTML, skip the write — avoids a spurious disk write and a no-op undo entry.
|
||||
if (after === current) return;
|
||||
|
||||
await persistSdkSerialize(after, targetPath, originalContent, deps, {
|
||||
label: label ?? "Edit slideshow",
|
||||
...(coalesceKey ? { coalesceKey } : {}),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,615 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
applyPatch,
|
||||
applyPatchByTarget,
|
||||
readAttributeByTarget,
|
||||
readTagSnippetByTarget,
|
||||
type PatchOperation,
|
||||
} from "./sourcePatcher";
|
||||
|
||||
describe("applyPatchByTarget", () => {
|
||||
it("updates a composition host by data-composition-id selector", () => {
|
||||
const html = `<div data-composition-id="intro" data-start="0" data-track-index="1"></div>`;
|
||||
const op: PatchOperation = { type: "attribute", property: "start", value: "2.5" };
|
||||
|
||||
expect(applyPatchByTarget(html, { selector: '[data-composition-id="intro"]' }, op)).toContain(
|
||||
'data-start="2.5"',
|
||||
);
|
||||
});
|
||||
|
||||
it("updates a class-based layer when the clip has no DOM id", () => {
|
||||
const html = `<div class="headline clip" data-start="0" data-track-index="1"></div>`;
|
||||
const op: PatchOperation = { type: "attribute", property: "track-index", value: "3" };
|
||||
|
||||
expect(applyPatchByTarget(html, { selector: ".headline" }, op)).toContain(
|
||||
'data-track-index="3"',
|
||||
);
|
||||
});
|
||||
|
||||
it("removes a boolean data attribute by selector", () => {
|
||||
const html = `<div class="headline clip" data-start="0" data-hidden></div>`;
|
||||
const op: PatchOperation = { type: "attribute", property: "hidden", value: null };
|
||||
|
||||
expect(applyPatchByTarget(html, { selector: ".headline" }, op)).toBe(
|
||||
`<div class="headline clip" data-start="0"></div>`,
|
||||
);
|
||||
});
|
||||
|
||||
it("updates inline z-index by selector when the clip has no DOM id", () => {
|
||||
const html = `<div class="headline clip" style="position: absolute; opacity: 1" data-start="0"></div>`;
|
||||
const op: PatchOperation = { type: "inline-style", property: "z-index", value: "3" };
|
||||
|
||||
expect(applyPatchByTarget(html, { selector: ".headline" }, op)).toContain(
|
||||
'style="position: absolute; opacity: 1; z-index: 3"',
|
||||
);
|
||||
});
|
||||
|
||||
it("adds inline style to a self-closing void element without malforming it", () => {
|
||||
const html = `<img id="gif-img" class="clip" data-start="1" src="earth.gif" alt="earth" />`;
|
||||
const op: PatchOperation = { type: "inline-style", property: "z-index", value: "3" };
|
||||
|
||||
const result = applyPatch(html, "gif-img", op);
|
||||
expect(result).toBe(
|
||||
`<img id="gif-img" class="clip" data-start="1" src="earth.gif" alt="earth" style="z-index: 3" />`,
|
||||
);
|
||||
expect(result).not.toContain("/ style");
|
||||
});
|
||||
|
||||
it("adds inline style to a self-closing void element matched by selector", () => {
|
||||
const html = `<img class="clip hero" data-start="0" src="bg.png" alt="" />`;
|
||||
const op: PatchOperation = { type: "inline-style", property: "opacity", value: "0.5" };
|
||||
|
||||
const result = applyPatchByTarget(html, { selector: ".hero" }, op);
|
||||
expect(result).toBe(
|
||||
`<img class="clip hero" data-start="0" src="bg.png" alt="" style="opacity: 0.5" />`,
|
||||
);
|
||||
expect(result).not.toContain("/ style");
|
||||
});
|
||||
|
||||
it("patches inline move styles by target", () => {
|
||||
const html = `<div id="card" style="position: absolute; left: 108px; top: 112px"></div>`;
|
||||
|
||||
const withLeft = applyPatchByTarget(
|
||||
html,
|
||||
{ id: "card" },
|
||||
{ type: "inline-style", property: "left", value: "160px" },
|
||||
);
|
||||
const withTop = applyPatchByTarget(
|
||||
withLeft,
|
||||
{ id: "card" },
|
||||
{ type: "inline-style", property: "top", value: "140px" },
|
||||
);
|
||||
|
||||
expect(withTop).toContain('style="position: absolute; left: 160px; top: 140px"');
|
||||
});
|
||||
|
||||
it("patches inline resize styles by target", () => {
|
||||
const html = `<div id="card" style="position: absolute; width: 380px; height: 196px"></div>`;
|
||||
|
||||
const withWidth = applyPatchByTarget(
|
||||
html,
|
||||
{ id: "card" },
|
||||
{ type: "inline-style", property: "width", value: "420px" },
|
||||
);
|
||||
const withHeight = applyPatchByTarget(
|
||||
withWidth,
|
||||
{ id: "card" },
|
||||
{ type: "inline-style", property: "height", value: "220px" },
|
||||
);
|
||||
|
||||
expect(withHeight).toContain('style="position: absolute; width: 420px; height: 220px"');
|
||||
});
|
||||
|
||||
it("escapes quoted CSS urls inside double-quoted style attributes", () => {
|
||||
const html = `<div id="card" style="position: absolute; opacity: 1"></div>`;
|
||||
|
||||
const withBackground = applyPatchByTarget(
|
||||
html,
|
||||
{ id: "card" },
|
||||
{
|
||||
type: "inline-style",
|
||||
property: "background-image",
|
||||
value: `url("../ChatGPT Image Apr 22, 2026.png")`,
|
||||
},
|
||||
);
|
||||
const withRadius = applyPatchByTarget(
|
||||
withBackground,
|
||||
{ id: "card" },
|
||||
{ type: "inline-style", property: "border-radius", value: "12px" },
|
||||
);
|
||||
|
||||
expect(withRadius).toContain(
|
||||
"background-image: url("../ChatGPT Image Apr 22, 2026.png")",
|
||||
);
|
||||
expect(withRadius).toContain("border-radius: 12px");
|
||||
});
|
||||
|
||||
it("updates media timing attributes by selector", () => {
|
||||
const html = `<video class="hero clip" data-start="0.2" data-duration="1.4" data-media-start="0.4"></video>`;
|
||||
|
||||
const withDuration = applyPatchByTarget(
|
||||
html,
|
||||
{ selector: ".hero" },
|
||||
{
|
||||
type: "attribute",
|
||||
property: "duration",
|
||||
value: "1.1",
|
||||
},
|
||||
);
|
||||
const withMediaStart = applyPatchByTarget(
|
||||
withDuration,
|
||||
{ selector: ".hero" },
|
||||
{
|
||||
type: "attribute",
|
||||
property: "media-start",
|
||||
value: "0.7",
|
||||
},
|
||||
);
|
||||
|
||||
expect(withMediaStart).toContain('data-duration="1.1"');
|
||||
expect(withMediaStart).toContain('data-media-start="0.7"');
|
||||
});
|
||||
|
||||
it("reads media timing attributes by selector", () => {
|
||||
const html = `<div class="hero clip" data-start="0.2" data-duration="1.4" data-media-start="0.4"></div>`;
|
||||
|
||||
expect(readAttributeByTarget(html, { selector: ".hero" }, "media-start")).toBe("0.4");
|
||||
expect(readAttributeByTarget(html, { selector: ".hero" }, "duration")).toBe("1.4");
|
||||
});
|
||||
|
||||
it("reads the matched tag snippet by target", () => {
|
||||
const html = `<section id="hero" class="card clip" style="left: 120px; top: 180px"></section>`;
|
||||
|
||||
expect(readTagSnippetByTarget(html, { id: "hero" })).toBe(
|
||||
`<section id="hero" class="card clip" style="left: 120px; top: 180px"`,
|
||||
);
|
||||
});
|
||||
|
||||
it("patches and reads single-quoted attributes and styles", () => {
|
||||
const html =
|
||||
"<section id='hero' class='card clip' data-start='0.2' style='left: 120px; top: 180px'></section>";
|
||||
|
||||
const moved = applyPatchByTarget(
|
||||
html,
|
||||
{ id: "hero" },
|
||||
{ type: "inline-style", property: "left", value: "160px" },
|
||||
);
|
||||
const updated = applyPatchByTarget(
|
||||
moved,
|
||||
{ id: "hero" },
|
||||
{ type: "attribute", property: "start", value: "0.4" },
|
||||
);
|
||||
|
||||
expect(updated).toContain(`style='left: 160px; top: 180px'`);
|
||||
expect(updated).toContain(`data-start="0.4"`);
|
||||
expect(readAttributeByTarget(updated, { id: "hero" }, "start")).toBe("0.4");
|
||||
});
|
||||
|
||||
it("replaces the full text body of a nested element by id", () => {
|
||||
const html =
|
||||
'<div id="panel"><strong>Headline</strong><span>Supporting copy</span></div><p>Outside</p>';
|
||||
|
||||
const patched = applyPatch(html, "panel", {
|
||||
type: "text-content",
|
||||
property: "text",
|
||||
value: "<strong>New headline</strong><span>New supporting copy</span>",
|
||||
});
|
||||
|
||||
expect(patched).toContain(
|
||||
'<div id="panel"><strong>New headline</strong><span>New supporting copy</span></div>',
|
||||
);
|
||||
expect(patched).toContain("<p>Outside</p>");
|
||||
});
|
||||
|
||||
it("does not stop at the first child closing tag when patching nested text", () => {
|
||||
const html =
|
||||
'<section id="card"><div><strong>Headline</strong></div><div>Copy</div></section><p>Outside</p>';
|
||||
|
||||
const patched = applyPatchByTarget(
|
||||
html,
|
||||
{ id: "card" },
|
||||
{
|
||||
type: "text-content",
|
||||
property: "text",
|
||||
value: "<strong>New headline</strong>",
|
||||
},
|
||||
);
|
||||
|
||||
expect(patched).toBe(
|
||||
'<section id="card"><strong>New headline</strong></section><p>Outside</p>',
|
||||
);
|
||||
});
|
||||
|
||||
it("patches the correct duplicate selector occurrence", () => {
|
||||
const html = [
|
||||
`<div class="headline clip" data-start="0"></div>`,
|
||||
`<div class="headline clip" data-start="1"></div>`,
|
||||
].join("");
|
||||
|
||||
const patched = applyPatchByTarget(
|
||||
html,
|
||||
{ selector: ".headline", selectorIndex: 1 },
|
||||
{
|
||||
type: "attribute",
|
||||
property: "start",
|
||||
value: "2.5",
|
||||
},
|
||||
);
|
||||
|
||||
expect(patched).toContain(`<div class="headline clip" data-start="0"></div>`);
|
||||
expect(patched).toContain(`<div class="headline clip" data-start="2.5"></div>`);
|
||||
});
|
||||
|
||||
it("escapes JSON attribute values containing double-quotes and round-trips them", () => {
|
||||
const html = `<div id="card" data-start="0"></div>`;
|
||||
const motionJson = JSON.stringify({ preset: "fadeIn", start: 0, duration: 1.5 });
|
||||
|
||||
const patched = applyPatch(html, "card", {
|
||||
type: "attribute",
|
||||
property: "data-hf-studio-motion",
|
||||
value: motionJson,
|
||||
});
|
||||
|
||||
// The raw HTML must NOT contain unescaped quotes inside the attribute
|
||||
expect(patched).not.toMatch(/data-hf-studio-motion="[^"]*"[^"]*"/);
|
||||
// Entities should be present
|
||||
expect(patched).toContain(""");
|
||||
|
||||
// Reading the attribute back should return the original JSON
|
||||
const readBack = readAttributeByTarget(patched, { id: "card" }, "data-hf-studio-motion");
|
||||
expect(readBack).toBe(motionJson);
|
||||
});
|
||||
|
||||
it("escapes and round-trips data-hf-studio-motion-original-transform with quotes", () => {
|
||||
const html = `<div id="hero" data-start="0"></div>`;
|
||||
const transform = `rotate(15deg) translate("50px", "100px")`;
|
||||
|
||||
const patched = applyPatchByTarget(
|
||||
html,
|
||||
{ id: "hero" },
|
||||
{
|
||||
type: "attribute",
|
||||
property: "data-hf-studio-motion-original-transform",
|
||||
value: transform,
|
||||
},
|
||||
);
|
||||
|
||||
// No broken attribute boundary
|
||||
expect(patched).not.toMatch(/data-hf-studio-motion-original-transform="[^"]*"[^"]*"/);
|
||||
|
||||
const readBack = readAttributeByTarget(
|
||||
patched,
|
||||
{ id: "hero" },
|
||||
"data-hf-studio-motion-original-transform",
|
||||
);
|
||||
expect(readBack).toBe(transform);
|
||||
});
|
||||
|
||||
it("escapes ampersands and angle brackets in attribute values", () => {
|
||||
const html = `<div id="el" data-start="0"></div>`;
|
||||
const value = `a&b<c>d"e`;
|
||||
|
||||
const patched = applyPatch(html, "el", {
|
||||
type: "attribute",
|
||||
property: "data-custom",
|
||||
value,
|
||||
});
|
||||
|
||||
expect(patched).toContain("a&b<c>d"e");
|
||||
|
||||
const readBack = readAttributeByTarget(patched, { id: "el" }, "data-custom");
|
||||
expect(readBack).toBe(value);
|
||||
});
|
||||
|
||||
it("updates an already-escaped attribute value to a new escaped value", () => {
|
||||
const html = `<div id="card" data-start="0"></div>`;
|
||||
const first = JSON.stringify({ preset: "fadeIn" });
|
||||
const second = JSON.stringify({ preset: "slideUp", easing: "ease-out" });
|
||||
|
||||
const patched1 = applyPatch(html, "card", {
|
||||
type: "attribute",
|
||||
property: "data-hf-studio-motion",
|
||||
value: first,
|
||||
});
|
||||
const patched2 = applyPatch(patched1, "card", {
|
||||
type: "attribute",
|
||||
property: "data-hf-studio-motion",
|
||||
value: second,
|
||||
});
|
||||
|
||||
const readBack = readAttributeByTarget(patched2, { id: "card" }, "data-hf-studio-motion");
|
||||
expect(readBack).toBe(second);
|
||||
});
|
||||
});
|
||||
|
||||
describe("motion attribute round-trip via sourcePatcher", () => {
|
||||
it("round-trips data-hf-studio-motion JSON through patch and read", () => {
|
||||
const html = `<div id="hero" style="position: absolute">Hero</div>`;
|
||||
const motion = {
|
||||
start: 0.5,
|
||||
duration: 1,
|
||||
ease: "power3.out",
|
||||
from: { opacity: 0, y: 40 },
|
||||
to: { opacity: 1, y: 0 },
|
||||
};
|
||||
const motionJson = JSON.stringify(motion);
|
||||
|
||||
const patched = applyPatchByTarget(
|
||||
html,
|
||||
{ id: "hero" },
|
||||
{ type: "attribute", property: "data-hf-studio-motion", value: motionJson },
|
||||
);
|
||||
|
||||
const readBack = readAttributeByTarget(patched, { id: "hero" }, "data-hf-studio-motion");
|
||||
expect(readBack).toBeDefined();
|
||||
expect(JSON.parse(readBack!)).toEqual(motion);
|
||||
});
|
||||
|
||||
it("round-trips motion with customEase containing SVG path data", () => {
|
||||
const html = `<div id="card" class="clip" data-start="0" data-duration="10">Card</div>`;
|
||||
const motion = {
|
||||
start: 0.25,
|
||||
duration: 0.8,
|
||||
ease: "studio-card-bounce",
|
||||
customEase: { id: "studio-card-bounce", data: "M0,0 C0.18,0.9 0.32,1 1,1" },
|
||||
from: { y: 44, autoAlpha: 0 },
|
||||
to: { y: 0, autoAlpha: 1 },
|
||||
};
|
||||
const motionJson = JSON.stringify(motion);
|
||||
|
||||
const patched = applyPatchByTarget(
|
||||
html,
|
||||
{ id: "card" },
|
||||
{ type: "attribute", property: "data-hf-studio-motion", value: motionJson },
|
||||
);
|
||||
|
||||
const readBack = readAttributeByTarget(patched, { id: "card" }, "data-hf-studio-motion");
|
||||
expect(readBack).toBeDefined();
|
||||
expect(JSON.parse(readBack!)).toEqual(motion);
|
||||
});
|
||||
|
||||
it("round-trips all four motion attributes (motion + three originals)", () => {
|
||||
const html = `<div id="hero" style="transform: rotate(5deg); opacity: 0.8; visibility: visible">Hero</div>`;
|
||||
const motion = {
|
||||
start: 0,
|
||||
duration: 0.6,
|
||||
ease: "power2.out",
|
||||
from: { autoAlpha: 0, y: 32 },
|
||||
// fallow-ignore-next-line code-duplication
|
||||
to: { autoAlpha: 1, y: 0 },
|
||||
};
|
||||
|
||||
let result = html;
|
||||
result = applyPatchByTarget(
|
||||
result,
|
||||
{ id: "hero" },
|
||||
{ type: "attribute", property: "data-hf-studio-motion", value: JSON.stringify(motion) },
|
||||
);
|
||||
result = applyPatchByTarget(
|
||||
result,
|
||||
{ id: "hero" },
|
||||
{
|
||||
type: "attribute",
|
||||
property: "data-hf-studio-motion-original-transform",
|
||||
value: "rotate(5deg)",
|
||||
},
|
||||
);
|
||||
result = applyPatchByTarget(
|
||||
result,
|
||||
{ id: "hero" },
|
||||
{ type: "attribute", property: "data-hf-studio-motion-original-opacity", value: "0.8" },
|
||||
);
|
||||
result = applyPatchByTarget(
|
||||
result,
|
||||
{ id: "hero" },
|
||||
{
|
||||
type: "attribute",
|
||||
property: "data-hf-studio-motion-original-visibility",
|
||||
value: "visible",
|
||||
},
|
||||
);
|
||||
|
||||
expect(
|
||||
JSON.parse(readAttributeByTarget(result, { id: "hero" }, "data-hf-studio-motion")!),
|
||||
).toEqual(motion);
|
||||
expect(
|
||||
readAttributeByTarget(result, { id: "hero" }, "data-hf-studio-motion-original-transform"),
|
||||
).toBe("rotate(5deg)");
|
||||
expect(
|
||||
readAttributeByTarget(result, { id: "hero" }, "data-hf-studio-motion-original-opacity"),
|
||||
).toBe("0.8");
|
||||
expect(
|
||||
readAttributeByTarget(result, { id: "hero" }, "data-hf-studio-motion-original-visibility"),
|
||||
).toBe("visible");
|
||||
});
|
||||
|
||||
it("removes all four motion attributes when clearing", () => {
|
||||
const html = `<div id="hero" style="position: absolute">Hero</div>`;
|
||||
const motion = {
|
||||
start: 0,
|
||||
duration: 1,
|
||||
ease: "none",
|
||||
from: { opacity: 0 },
|
||||
// fallow-ignore-next-line code-duplication
|
||||
to: { opacity: 1 },
|
||||
};
|
||||
|
||||
let result = html;
|
||||
result = applyPatchByTarget(
|
||||
result,
|
||||
{ id: "hero" },
|
||||
{ type: "attribute", property: "data-hf-studio-motion", value: JSON.stringify(motion) },
|
||||
);
|
||||
result = applyPatchByTarget(
|
||||
result,
|
||||
{ id: "hero" },
|
||||
{ type: "attribute", property: "data-hf-studio-motion-original-transform", value: "" },
|
||||
);
|
||||
result = applyPatchByTarget(
|
||||
result,
|
||||
{ id: "hero" },
|
||||
{ type: "attribute", property: "data-hf-studio-motion-original-opacity", value: "1" },
|
||||
);
|
||||
result = applyPatchByTarget(
|
||||
result,
|
||||
{ id: "hero" },
|
||||
{ type: "attribute", property: "data-hf-studio-motion-original-visibility", value: "" },
|
||||
);
|
||||
|
||||
// Verify all four attributes exist
|
||||
expect(readAttributeByTarget(result, { id: "hero" }, "data-hf-studio-motion")).toBeDefined();
|
||||
expect(
|
||||
readAttributeByTarget(result, { id: "hero" }, "data-hf-studio-motion-original-transform"),
|
||||
).toBeDefined();
|
||||
expect(
|
||||
readAttributeByTarget(result, { id: "hero" }, "data-hf-studio-motion-original-opacity"),
|
||||
).toBeDefined();
|
||||
expect(
|
||||
readAttributeByTarget(result, { id: "hero" }, "data-hf-studio-motion-original-visibility"),
|
||||
).toBeDefined();
|
||||
|
||||
// Remove all four
|
||||
result = applyPatchByTarget(
|
||||
result,
|
||||
{ id: "hero" },
|
||||
{ type: "attribute", property: "data-hf-studio-motion", value: null },
|
||||
);
|
||||
result = applyPatchByTarget(
|
||||
result,
|
||||
{ id: "hero" },
|
||||
{ type: "attribute", property: "data-hf-studio-motion-original-transform", value: null },
|
||||
);
|
||||
result = applyPatchByTarget(
|
||||
result,
|
||||
{ id: "hero" },
|
||||
{ type: "attribute", property: "data-hf-studio-motion-original-opacity", value: null },
|
||||
);
|
||||
result = applyPatchByTarget(
|
||||
result,
|
||||
{ id: "hero" },
|
||||
{ type: "attribute", property: "data-hf-studio-motion-original-visibility", value: null },
|
||||
);
|
||||
|
||||
expect(readAttributeByTarget(result, { id: "hero" }, "data-hf-studio-motion")).toBeUndefined();
|
||||
expect(
|
||||
readAttributeByTarget(result, { id: "hero" }, "data-hf-studio-motion-original-transform"),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
readAttributeByTarget(result, { id: "hero" }, "data-hf-studio-motion-original-opacity"),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
readAttributeByTarget(result, { id: "hero" }, "data-hf-studio-motion-original-visibility"),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("round-trips motion via selector when element has no id", () => {
|
||||
const html = `<div class="headline clip" style="position: absolute">Title</div>`;
|
||||
const motion = {
|
||||
start: 0.3,
|
||||
duration: 0.5,
|
||||
ease: "sine.out",
|
||||
from: { scale: 0.88, autoAlpha: 0 },
|
||||
to: { scale: 1, autoAlpha: 1 },
|
||||
};
|
||||
|
||||
const patched = applyPatchByTarget(
|
||||
html,
|
||||
{ selector: ".headline" },
|
||||
{ type: "attribute", property: "data-hf-studio-motion", value: JSON.stringify(motion) },
|
||||
);
|
||||
|
||||
const readBack = readAttributeByTarget(
|
||||
patched,
|
||||
{ selector: ".headline" },
|
||||
"data-hf-studio-motion",
|
||||
);
|
||||
expect(readBack).toBeDefined();
|
||||
expect(JSON.parse(readBack!)).toEqual(motion);
|
||||
});
|
||||
});
|
||||
|
||||
// T3 — id-based targeting (R1).
|
||||
describe("T3 — hfId targeting (spec for R1)", () => {
|
||||
it("updates inline style by data-hf-id", () => {
|
||||
const html = `<h1 data-hf-id="hf-x7k2" style="color: red">Hello</h1>`;
|
||||
const result = applyPatchByTarget(
|
||||
html,
|
||||
{ hfId: "hf-x7k2" },
|
||||
{
|
||||
type: "inline-style",
|
||||
property: "color",
|
||||
value: "blue",
|
||||
},
|
||||
);
|
||||
expect(result).toContain("color: blue");
|
||||
expect(result).toContain('data-hf-id="hf-x7k2"');
|
||||
});
|
||||
|
||||
it("updates text content by data-hf-id", () => {
|
||||
const html = `<p data-hf-id="hf-a1b2">Old text</p>`;
|
||||
const result = applyPatchByTarget(
|
||||
html,
|
||||
{ hfId: "hf-a1b2" },
|
||||
{
|
||||
type: "text-content",
|
||||
property: "",
|
||||
value: "New text",
|
||||
},
|
||||
);
|
||||
expect(result).toContain(">New text<");
|
||||
});
|
||||
|
||||
it("updates attribute by data-hf-id", () => {
|
||||
const html = `<div data-hf-id="hf-c3d4" data-start="0"></div>`;
|
||||
const result = applyPatchByTarget(
|
||||
html,
|
||||
{ hfId: "hf-c3d4" },
|
||||
{
|
||||
type: "attribute",
|
||||
property: "start",
|
||||
value: "2.5",
|
||||
},
|
||||
);
|
||||
expect(result).toContain('data-start="2.5"');
|
||||
});
|
||||
|
||||
it("data-hf-id attribute is preserved after a style patch", () => {
|
||||
const html = `<h1 data-hf-id="hf-x7k2" style="color: red">Hello</h1>`;
|
||||
const patched = applyPatchByTarget(
|
||||
html,
|
||||
{ hfId: "hf-x7k2" },
|
||||
{
|
||||
type: "inline-style",
|
||||
property: "color",
|
||||
value: "blue",
|
||||
},
|
||||
);
|
||||
expect(readAttributeByTarget(patched, { hfId: "hf-x7k2" }, "data-hf-id")).toBe("hf-x7k2");
|
||||
});
|
||||
|
||||
it("hfId lookup falls through to selector when hfId not found", () => {
|
||||
const html = `<h1 class="headline" style="color: red">Hello</h1>`;
|
||||
const result = applyPatchByTarget(
|
||||
html,
|
||||
{ hfId: "hf-missing", selector: ".headline" },
|
||||
{ type: "inline-style", property: "color", value: "blue" },
|
||||
);
|
||||
expect(result).toContain("color: blue");
|
||||
});
|
||||
|
||||
it("hfId match is authoritative — selector is not used as a narrowing filter", () => {
|
||||
// hfId matches h1; selector points at h2. hfId wins — patch lands on h1, h2 untouched.
|
||||
const html = `<h1 data-hf-id="hf-x7k2" class="a">A</h1><h2 class="b">B</h2>`;
|
||||
const result = applyPatchByTarget(
|
||||
html,
|
||||
{ hfId: "hf-x7k2", selector: ".b" },
|
||||
{ type: "inline-style", property: "color", value: "blue" },
|
||||
);
|
||||
expect(result).toContain('data-hf-id="hf-x7k2"');
|
||||
const h1End = result.indexOf("</h1>");
|
||||
const bluePos = result.indexOf("color: blue");
|
||||
expect(bluePos).toBeGreaterThan(-1);
|
||||
expect(bluePos).toBeLessThan(h1End);
|
||||
expect(result).toContain('<h2 class="b">B</h2>');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,556 @@
|
||||
/**
|
||||
* Source Patcher — Maps visual property edits back to source HTML files.
|
||||
* Handles inline style updates, attribute changes, and text content.
|
||||
*/
|
||||
|
||||
function escapeRegex(s: string): string {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function escapeStyleAttributeValue(value: string, quote: string): string {
|
||||
return quote === '"' ? value.replace(/"/g, """) : value.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
/** Escape a string for safe use inside a double-quoted HTML attribute. */
|
||||
function escapeHtmlAttribute(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/"/g, """)
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
/** Reverse escapeHtmlAttribute so callers get the original value. */
|
||||
function unescapeHtmlAttribute(value: string): string {
|
||||
return value
|
||||
.replace(/"/g, '"')
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/&/g, "&");
|
||||
}
|
||||
|
||||
function splitInlineStyleDeclarations(style: string): string[] {
|
||||
const declarations: string[] = [];
|
||||
let current = "";
|
||||
let quote: string | null = null;
|
||||
let entity = false;
|
||||
let parenDepth = 0;
|
||||
|
||||
for (const char of style) {
|
||||
if (entity) {
|
||||
current += char;
|
||||
if (char === ";") entity = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "&") {
|
||||
entity = true;
|
||||
current += char;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (quote) {
|
||||
current += char;
|
||||
if (char === quote) quote = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '"' || char === "'") {
|
||||
quote = char;
|
||||
current += char;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "(") {
|
||||
parenDepth += 1;
|
||||
current += char;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === ")") {
|
||||
parenDepth = Math.max(0, parenDepth - 1);
|
||||
current += char;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === ";" && parenDepth === 0) {
|
||||
declarations.push(current);
|
||||
current = "";
|
||||
continue;
|
||||
}
|
||||
|
||||
current += char;
|
||||
}
|
||||
|
||||
if (current) declarations.push(current);
|
||||
return declarations;
|
||||
}
|
||||
|
||||
export interface PatchOperation {
|
||||
type: "inline-style" | "attribute" | "text-content" | "html-attribute";
|
||||
property: string;
|
||||
value: string | null;
|
||||
childSelector?: string;
|
||||
childIndex?: number;
|
||||
}
|
||||
|
||||
// Runtime validation for hfId lives in findTagByTarget → execDataAttrPattern (CSS attr-value
|
||||
// escape). This type is documentation only; the server's MutationTarget mirrors this shape.
|
||||
export interface PatchTarget {
|
||||
id?: string | null;
|
||||
hfId?: string;
|
||||
selector?: string;
|
||||
selectorIndex?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find which source file contains an element by its ID.
|
||||
*/
|
||||
export function resolveSourceFile(
|
||||
elementId: string | null,
|
||||
selector: string,
|
||||
files: Record<string, string>,
|
||||
): string | null {
|
||||
if (!elementId && !selector) return null;
|
||||
|
||||
// Strategy 1: Search by id attribute
|
||||
if (elementId) {
|
||||
for (const [path, content] of Object.entries(files)) {
|
||||
if (content.includes(`id="${elementId}"`) || content.includes(`id='${elementId}'`)) {
|
||||
return path;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 2: Search by data-composition-id from the selector
|
||||
const compIdMatch = selector.match(/data-composition-id="([^"]+)"/);
|
||||
if (compIdMatch) {
|
||||
const compId = compIdMatch[1];
|
||||
for (const [path, content] of Object.entries(files)) {
|
||||
if (content.includes(`data-composition-id="${compId}"`)) {
|
||||
return path;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 3: Search by class from the selector
|
||||
const classMatch = selector.match(/^\.([a-zA-Z0-9_-]+)/);
|
||||
if (classMatch) {
|
||||
const cls = classMatch[1];
|
||||
for (const [path, content] of Object.entries(files)) {
|
||||
if (
|
||||
content.includes(`class="${cls}"`) ||
|
||||
content.includes(`class="${cls} `) ||
|
||||
content.includes(` ${cls}"`)
|
||||
) {
|
||||
return path;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: index.html
|
||||
if ("index.html" in files) return "index.html";
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a style property change to an element's inline style in the HTML source.
|
||||
*/
|
||||
function patchInlineStyle(
|
||||
html: string,
|
||||
elementId: string,
|
||||
prop: string,
|
||||
value: string | null,
|
||||
): string {
|
||||
// Find the element tag with this id
|
||||
const idPattern = new RegExp(`(<[^>]*\\bid=(["'])${escapeRegex(elementId)}\\2[^>]*)>`, "i");
|
||||
const match = idPattern.exec(html);
|
||||
if (!match) return html;
|
||||
|
||||
const tag = match[1];
|
||||
return patchInlineStyleInTag(html, tag, prop, value);
|
||||
}
|
||||
|
||||
function patchInlineStyleInTag(
|
||||
html: string,
|
||||
tag: string,
|
||||
prop: string,
|
||||
value: string | null,
|
||||
): string {
|
||||
if (!tag) return html;
|
||||
|
||||
// Check if there's an existing style attribute
|
||||
const styleMatch = /\bstyle=(["'])([\s\S]*?)\1/.exec(tag);
|
||||
if (styleMatch) {
|
||||
const existingStyle = styleMatch[2];
|
||||
const quote = styleMatch[1];
|
||||
// Parse existing properties
|
||||
const props = new Map<string, string>();
|
||||
for (const part of splitInlineStyleDeclarations(existingStyle)) {
|
||||
const colon = part.indexOf(":");
|
||||
if (colon < 0) continue;
|
||||
const key = part.slice(0, colon).trim();
|
||||
const val = part.slice(colon + 1).trim();
|
||||
if (key) props.set(key, val);
|
||||
}
|
||||
// Update/add or remove the property
|
||||
if (value === null) {
|
||||
props.delete(prop);
|
||||
} else {
|
||||
props.set(prop, value);
|
||||
}
|
||||
// Rebuild style string; keep style="" if empty (harmless)
|
||||
const newStyle = Array.from(props.entries())
|
||||
.map(([k, v]) => `${k}: ${escapeStyleAttributeValue(v, quote)}`)
|
||||
.join("; ");
|
||||
const newTag = tag.replace(styleMatch[0], `style=${quote}${newStyle}${quote}`);
|
||||
return html.replace(tag, newTag);
|
||||
} else {
|
||||
// No existing style attribute
|
||||
if (value === null) return html; // nothing to remove
|
||||
const selfClosing = /\s*\/$/.test(tag);
|
||||
const base = selfClosing ? tag.replace(/\s*\/$/, "") : tag;
|
||||
const newTag = `${base} style="${prop}: ${escapeStyleAttributeValue(value, '"')}"${selfClosing ? " /" : ""}`;
|
||||
return html.replace(tag, newTag);
|
||||
}
|
||||
}
|
||||
|
||||
function patchInlineStyleByTarget(
|
||||
html: string,
|
||||
target: PatchTarget,
|
||||
prop: string,
|
||||
value: string | null,
|
||||
): string {
|
||||
const match = findTagByTarget(html, target);
|
||||
if (!match) return html;
|
||||
const newTag = patchInlineStyleInTag(match.tag, match.tag, prop, value);
|
||||
return replaceTagAtMatch(html, match, newTag);
|
||||
}
|
||||
|
||||
interface TagMatch {
|
||||
tag: string;
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
function replaceTagAtMatch(html: string, match: TagMatch, newTag: string): string {
|
||||
return `${html.slice(0, match.start)}${newTag}${html.slice(match.end)}`;
|
||||
}
|
||||
|
||||
function execDataAttrPattern(html: string, attr: string, value: string): TagMatch | null {
|
||||
const pattern = new RegExp(`(<[^>]*\\b${attr}=(["'])${escapeRegex(value)}\\2[^>]*)>`, "i");
|
||||
const match = pattern.exec(html);
|
||||
if (match?.index == null) return null;
|
||||
return { tag: match[1], start: match.index, end: match.index + match[1].length };
|
||||
}
|
||||
|
||||
function findTagByClass(html: string, target: PatchTarget): TagMatch | null {
|
||||
const classMatch = target.selector?.match(/^\.([a-zA-Z0-9_-]+)$/);
|
||||
if (!classMatch) return null;
|
||||
const cls = classMatch[1];
|
||||
const pattern = new RegExp(
|
||||
`(<[^>]*\\bclass=(["'])[^"']*\\b${escapeRegex(cls)}\\b[^"']*\\2[^>]*)>`,
|
||||
"gi",
|
||||
);
|
||||
const selectorIndex = target.selectorIndex ?? 0;
|
||||
let match: RegExpExecArray | null;
|
||||
let currentIndex = 0;
|
||||
while ((match = pattern.exec(html)) !== null) {
|
||||
if (currentIndex === selectorIndex && match.index != null) {
|
||||
return {
|
||||
tag: match[1],
|
||||
start: match.index,
|
||||
end: match.index + match[1].length,
|
||||
};
|
||||
}
|
||||
currentIndex += 1;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function findTagByTarget(html: string, target: PatchTarget): TagMatch | null {
|
||||
if (target.hfId) {
|
||||
const result = execDataAttrPattern(html, "data-hf-id", target.hfId);
|
||||
if (result) return result;
|
||||
}
|
||||
|
||||
if (target.id) {
|
||||
const result = execDataAttrPattern(html, "id", target.id);
|
||||
if (result) return result;
|
||||
}
|
||||
|
||||
if (!target.selector) return null;
|
||||
|
||||
const compositionIdMatch = target.selector.match(/^\[data-composition-id="([^"]+)"\]$/);
|
||||
if (compositionIdMatch) {
|
||||
const result = execDataAttrPattern(html, "data-composition-id", compositionIdMatch[1]);
|
||||
if (result) return result;
|
||||
}
|
||||
|
||||
return findTagByClass(html, target);
|
||||
}
|
||||
|
||||
export function readAttributeByTarget(
|
||||
html: string,
|
||||
target: PatchTarget,
|
||||
attr: string,
|
||||
): string | undefined {
|
||||
const match = findTagByTarget(html, target);
|
||||
if (!match) return undefined;
|
||||
|
||||
const fullAttr = attr.startsWith("data-") ? attr : `data-${attr}`;
|
||||
const valueMatch = new RegExp(`\\b${fullAttr}=(["'])([^"']*)\\1`).exec(match.tag);
|
||||
return valueMatch?.[2] != null ? unescapeHtmlAttribute(valueMatch[2]) : undefined;
|
||||
}
|
||||
|
||||
export function readTagSnippetByTarget(html: string, target: PatchTarget): string | undefined {
|
||||
const match = findTagByTarget(html, target);
|
||||
return match?.tag;
|
||||
}
|
||||
|
||||
function patchAttributeByTarget(
|
||||
html: string,
|
||||
target: PatchTarget,
|
||||
attr: string,
|
||||
value: string | null,
|
||||
): string {
|
||||
const match = findTagByTarget(html, target);
|
||||
if (!match) return html;
|
||||
|
||||
const fullAttr = attr.startsWith("data-") ? attr : `data-${attr}`;
|
||||
const attrPattern = new RegExp(`\\b${escapeRegex(fullAttr)}=(["'])([^"']*)\\1`);
|
||||
const tag = match.tag;
|
||||
|
||||
if (value === null) {
|
||||
// Remove the attribute if present
|
||||
const boolAttrPattern = new RegExp(`\\b${escapeRegex(fullAttr)}(?:=(["'])[^"']*\\1)?`);
|
||||
if (!boolAttrPattern.test(tag)) return html;
|
||||
const removePattern = new RegExp(`\\s+${escapeRegex(fullAttr)}(?:=(["'])[^"']*\\1)?`);
|
||||
const newTag = tag.replace(removePattern, "");
|
||||
return replaceTagAtMatch(html, match, newTag);
|
||||
}
|
||||
|
||||
const escaped = escapeHtmlAttribute(value);
|
||||
if (attrPattern.test(tag)) {
|
||||
const newTag = tag.replace(attrPattern, `${fullAttr}="${escaped}"`);
|
||||
return replaceTagAtMatch(html, match, newTag);
|
||||
}
|
||||
|
||||
const newTag = tag + ` ${fullAttr}="${escaped}"`;
|
||||
return replaceTagAtMatch(html, match, newTag);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply an attribute change to an element in the HTML source.
|
||||
*/
|
||||
function patchAttribute(
|
||||
html: string,
|
||||
elementId: string,
|
||||
attr: string,
|
||||
value: string | null,
|
||||
): string {
|
||||
const idPattern = new RegExp(`(<[^>]*\\bid=(["'])${escapeRegex(elementId)}\\2[^>]*)>`, "i");
|
||||
const match = idPattern.exec(html);
|
||||
if (!match) return html;
|
||||
|
||||
const tag = match[1];
|
||||
const fullAttr = attr.startsWith("data-") ? attr : `data-${attr}`;
|
||||
const attrPattern = new RegExp(`\\b${escapeRegex(fullAttr)}=(["'])([^"']*)\\1`);
|
||||
|
||||
if (value === null) {
|
||||
const boolAttrPattern = new RegExp(`\\b${escapeRegex(fullAttr)}(?:=(["'])[^"']*\\1)?`);
|
||||
if (!boolAttrPattern.test(tag)) return html;
|
||||
const removePattern = new RegExp(`\\s+${escapeRegex(fullAttr)}(?:=(["'])[^"']*\\1)?`);
|
||||
const newTag = tag.replace(removePattern, "");
|
||||
return html.replace(tag, newTag);
|
||||
}
|
||||
|
||||
const escaped = escapeHtmlAttribute(value);
|
||||
if (attrPattern.test(tag)) {
|
||||
// Update existing attribute
|
||||
const newTag = tag.replace(attrPattern, `${fullAttr}="${escaped}"`);
|
||||
return html.replace(tag, newTag);
|
||||
} else {
|
||||
// Add new attribute
|
||||
const newTag = tag + ` ${fullAttr}="${escaped}"`;
|
||||
return html.replace(tag, newTag);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a text content change to an element.
|
||||
*/
|
||||
function patchTextContent(html: string, elementId: string, value: string): string {
|
||||
const openTagPattern = new RegExp(
|
||||
`(<([a-z0-9-]+)[^>]*\\bid=(["'])${escapeRegex(elementId)}\\3[^>]*>)`,
|
||||
"i",
|
||||
);
|
||||
const match = openTagPattern.exec(html);
|
||||
if (!match || match.index == null) return html;
|
||||
|
||||
const openingTag = match[1];
|
||||
const tagName = match[2];
|
||||
const contentStart = match.index + openingTag.length;
|
||||
const closingIndex = findMatchingClosingTagIndex(html, tagName, contentStart);
|
||||
if (closingIndex < 0) return html;
|
||||
|
||||
return `${html.slice(0, contentStart)}${value}${html.slice(closingIndex)}`;
|
||||
}
|
||||
|
||||
function findMatchingClosingTagIndex(html: string, tagName: string, contentStart: number): number {
|
||||
const tagPattern = new RegExp(`</?${escapeRegex(tagName)}\\b[^>]*>`, "gi");
|
||||
tagPattern.lastIndex = contentStart;
|
||||
let depth = 1;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = tagPattern.exec(html)) !== null) {
|
||||
const tag = match[0];
|
||||
if (tag.startsWith("</")) {
|
||||
depth -= 1;
|
||||
if (depth === 0) return match.index;
|
||||
continue;
|
||||
}
|
||||
if (!tag.endsWith("/>")) depth += 1;
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
const HTML_BOOLEAN_ATTRIBUTES = new Set([
|
||||
"loop",
|
||||
"muted",
|
||||
"autoplay",
|
||||
"playsinline",
|
||||
"controls",
|
||||
"default",
|
||||
"defer",
|
||||
"disabled",
|
||||
"hidden",
|
||||
"nomodule",
|
||||
"open",
|
||||
"readonly",
|
||||
"required",
|
||||
"reversed",
|
||||
"selected",
|
||||
]);
|
||||
|
||||
function patchHtmlAttributeInTag(
|
||||
html: string,
|
||||
tag: string,
|
||||
attr: string,
|
||||
value: string | null,
|
||||
): string {
|
||||
if (!tag) return html;
|
||||
|
||||
const isBoolean = HTML_BOOLEAN_ATTRIBUTES.has(attr);
|
||||
|
||||
if (isBoolean) {
|
||||
const escapedAttr = escapeRegex(attr);
|
||||
const hasBoolAttr = new RegExp(`(?:^|\\s)${escapedAttr}(?:\\s|=|$)`).test(tag);
|
||||
|
||||
if (value === null || value === "" || value === "false") {
|
||||
if (!hasBoolAttr) return html;
|
||||
const removePattern = new RegExp(`\\s+${escapedAttr}(?:=(["'])[^"']*\\1)?`);
|
||||
const newTag = tag.replace(removePattern, "");
|
||||
return html.replace(tag, newTag);
|
||||
}
|
||||
if (hasBoolAttr) return html;
|
||||
const newTag = tag + ` ${attr}`;
|
||||
return html.replace(tag, newTag);
|
||||
}
|
||||
|
||||
const attrPattern = new RegExp(`\\b${escapeRegex(attr)}=(["'])([^"']*)\\1`);
|
||||
if (value === null) {
|
||||
if (!attrPattern.test(tag)) return html;
|
||||
const removePattern = new RegExp(`\\s+${escapeRegex(attr)}=(["'])[^"']*\\1`);
|
||||
const newTag = tag.replace(removePattern, "");
|
||||
return html.replace(tag, newTag);
|
||||
}
|
||||
|
||||
const escaped = escapeHtmlAttribute(value);
|
||||
if (attrPattern.test(tag)) {
|
||||
const newTag = tag.replace(attrPattern, `${attr}="${escaped}"`);
|
||||
return html.replace(tag, newTag);
|
||||
}
|
||||
|
||||
const newTag = tag + ` ${attr}="${escaped}"`;
|
||||
return html.replace(tag, newTag);
|
||||
}
|
||||
|
||||
function patchHtmlAttribute(
|
||||
html: string,
|
||||
elementId: string,
|
||||
attr: string,
|
||||
value: string | null,
|
||||
): string {
|
||||
const idPattern = new RegExp(`(<[^>]*\\bid=(["'])${escapeRegex(elementId)}\\2[^>]*)>`, "i");
|
||||
const match = idPattern.exec(html);
|
||||
if (!match) return html;
|
||||
return patchHtmlAttributeInTag(html, match[1], attr, value);
|
||||
}
|
||||
|
||||
function patchHtmlAttributeByTarget(
|
||||
html: string,
|
||||
target: PatchTarget,
|
||||
attr: string,
|
||||
value: string | null,
|
||||
): string {
|
||||
const match = findTagByTarget(html, target);
|
||||
if (!match) return html;
|
||||
const newTag = patchHtmlAttributeInTag(match.tag, match.tag, attr, value);
|
||||
return replaceTagAtMatch(html, match, newTag);
|
||||
}
|
||||
|
||||
function patchTextContentByTarget(html: string, target: PatchTarget, value: string): string {
|
||||
const match = findTagByTarget(html, target);
|
||||
if (!match) return html;
|
||||
|
||||
const tagNameMatch = /^<([a-z0-9-]+)/i.exec(match.tag);
|
||||
const tagName = tagNameMatch?.[1];
|
||||
if (!tagName) return html;
|
||||
|
||||
const closingIndex = findMatchingClosingTagIndex(html, tagName, match.end + 1);
|
||||
if (closingIndex < 0) return html;
|
||||
|
||||
return `${html.slice(0, match.end + 1)}${value}${html.slice(closingIndex)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a patch operation to an HTML source file.
|
||||
*/
|
||||
export function applyPatch(html: string, elementId: string, op: PatchOperation): string {
|
||||
switch (op.type) {
|
||||
case "inline-style":
|
||||
return patchInlineStyle(html, elementId, op.property, op.value);
|
||||
case "attribute":
|
||||
return patchAttribute(html, elementId, op.property, op.value);
|
||||
case "html-attribute":
|
||||
return patchHtmlAttribute(html, elementId, op.property, op.value);
|
||||
case "text-content":
|
||||
return op.value !== null ? patchTextContent(html, elementId, op.value) : html;
|
||||
default:
|
||||
return html;
|
||||
}
|
||||
}
|
||||
|
||||
export function applyPatchByTarget(html: string, target: PatchTarget, op: PatchOperation): string {
|
||||
if (target.id) {
|
||||
const patchedById = applyPatch(html, target.id, op);
|
||||
if (patchedById !== html || !target.selector) {
|
||||
return patchedById;
|
||||
}
|
||||
}
|
||||
|
||||
switch (op.type) {
|
||||
case "inline-style":
|
||||
return patchInlineStyleByTarget(html, target, op.property, op.value);
|
||||
case "attribute":
|
||||
return patchAttributeByTarget(html, target, op.property, op.value);
|
||||
case "html-attribute":
|
||||
return patchHtmlAttributeByTarget(html, target, op.property, op.value);
|
||||
case "text-content":
|
||||
return op.value !== null ? patchTextContentByTarget(html, target, op.value) : html;
|
||||
default:
|
||||
return html;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { saveProjectFilesWithHistory } from "./studioFileHistory";
|
||||
|
||||
describe("saveProjectFilesWithHistory", () => {
|
||||
it("reads before content, writes after content, and records a history entry", async () => {
|
||||
const reads: Record<string, string> = { "index.html": "before" };
|
||||
const writes: Record<string, string> = {};
|
||||
const recordEdit = vi.fn();
|
||||
|
||||
await saveProjectFilesWithHistory({
|
||||
projectId: "project-1",
|
||||
label: "Move layer",
|
||||
kind: "manual",
|
||||
files: { "index.html": "after" },
|
||||
readFile: async (path) => reads[path],
|
||||
writeFile: async (path, content) => {
|
||||
writes[path] = content;
|
||||
},
|
||||
recordEdit,
|
||||
});
|
||||
|
||||
expect(writes).toEqual({ "index.html": "after" });
|
||||
expect(recordEdit).toHaveBeenCalledWith({
|
||||
label: "Move layer",
|
||||
kind: "manual",
|
||||
coalesceKey: undefined,
|
||||
files: { "index.html": { before: "before", after: "after" } },
|
||||
});
|
||||
});
|
||||
|
||||
it("skips writes and history for unchanged content", async () => {
|
||||
const writeFile = vi.fn();
|
||||
const recordEdit = vi.fn();
|
||||
|
||||
const changedPaths = await saveProjectFilesWithHistory({
|
||||
projectId: "project-1",
|
||||
label: "Edit layer",
|
||||
kind: "manual",
|
||||
files: { "index.html": "same" },
|
||||
readFile: async () => "same",
|
||||
writeFile,
|
||||
recordEdit,
|
||||
});
|
||||
|
||||
expect(changedPaths).toEqual([]);
|
||||
expect(writeFile).not.toHaveBeenCalled();
|
||||
expect(recordEdit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rolls back files already written when a later file write fails", async () => {
|
||||
const reads: Record<string, string> = {
|
||||
"index.html": "index-before",
|
||||
"scene.html": "scene-before",
|
||||
};
|
||||
const writes: Array<[string, string]> = [];
|
||||
const recordEdit = vi.fn();
|
||||
|
||||
await expect(
|
||||
saveProjectFilesWithHistory({
|
||||
projectId: "project-1",
|
||||
label: "Move layer",
|
||||
kind: "manual",
|
||||
files: {
|
||||
"index.html": "index-after",
|
||||
"scene.html": "scene-after",
|
||||
},
|
||||
readFile: async (path) => reads[path],
|
||||
writeFile: async (path, content) => {
|
||||
writes.push([path, content]);
|
||||
if (path === "scene.html") {
|
||||
throw new Error("disk full");
|
||||
}
|
||||
},
|
||||
recordEdit,
|
||||
}),
|
||||
).rejects.toThrow("disk full");
|
||||
|
||||
expect(writes).toEqual([
|
||||
["index.html", "index-after"],
|
||||
["scene.html", "scene-after"],
|
||||
["index.html", "index-before"],
|
||||
]);
|
||||
expect(recordEdit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rolls back written files when the injected history recorder throws", async () => {
|
||||
const reads: Record<string, string> = {
|
||||
"index.html": "index-before",
|
||||
"scene.html": "scene-before",
|
||||
};
|
||||
const writes: Array<[string, string]> = [];
|
||||
|
||||
await expect(
|
||||
saveProjectFilesWithHistory({
|
||||
projectId: "project-1",
|
||||
label: "Move layer",
|
||||
kind: "manual",
|
||||
files: {
|
||||
"index.html": "index-after",
|
||||
"scene.html": "scene-after",
|
||||
},
|
||||
readFile: async (path) => reads[path],
|
||||
writeFile: async (path, content) => {
|
||||
writes.push([path, content]);
|
||||
},
|
||||
recordEdit: async () => {
|
||||
throw new Error("history unavailable");
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow("history unavailable");
|
||||
|
||||
expect(writes).toEqual([
|
||||
["index.html", "index-after"],
|
||||
["scene.html", "scene-after"],
|
||||
["scene.html", "scene-before"],
|
||||
["index.html", "index-before"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports rollback failure with the original write failure", async () => {
|
||||
const reads: Record<string, string> = {
|
||||
"index.html": "index-before",
|
||||
"scene.html": "scene-before",
|
||||
};
|
||||
const writes: Array<[string, string]> = [];
|
||||
|
||||
await expect(
|
||||
saveProjectFilesWithHistory({
|
||||
projectId: "project-1",
|
||||
label: "Move layer",
|
||||
kind: "manual",
|
||||
files: {
|
||||
"index.html": "index-after",
|
||||
"scene.html": "scene-after",
|
||||
},
|
||||
readFile: async (path) => reads[path],
|
||||
writeFile: async (path, content) => {
|
||||
writes.push([path, content]);
|
||||
if (path === "scene.html" && content === "scene-after") {
|
||||
throw new Error("write denied");
|
||||
}
|
||||
if (path === "index.html" && content === "index-before") {
|
||||
throw new Error("rollback denied");
|
||||
}
|
||||
},
|
||||
recordEdit: vi.fn(),
|
||||
}),
|
||||
).rejects.toThrow("rollback did not complete");
|
||||
|
||||
expect(writes).toEqual([
|
||||
["index.html", "index-after"],
|
||||
["scene.html", "scene-after"],
|
||||
["index.html", "index-before"],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { EditHistoryKind } from "./editHistory";
|
||||
|
||||
interface SaveProjectFilesWithHistoryInput {
|
||||
projectId: string;
|
||||
label: string;
|
||||
kind: EditHistoryKind;
|
||||
coalesceKey?: string;
|
||||
files: Record<string, string>;
|
||||
readFile: (path: string) => Promise<string>;
|
||||
writeFile: (path: string, content: string) => Promise<void>;
|
||||
recordEdit: (entry: {
|
||||
label: string;
|
||||
kind: EditHistoryKind;
|
||||
coalesceKey?: string;
|
||||
files: Record<string, { before: string; after: string }>;
|
||||
}) => Promise<void>;
|
||||
}
|
||||
|
||||
export async function saveProjectFilesWithHistory({
|
||||
label,
|
||||
kind,
|
||||
coalesceKey,
|
||||
files,
|
||||
readFile,
|
||||
writeFile,
|
||||
recordEdit,
|
||||
}: SaveProjectFilesWithHistoryInput): Promise<string[]> {
|
||||
const snapshots: Record<string, { before: string; after: string }> = {};
|
||||
for (const [path, after] of Object.entries(files)) {
|
||||
const before = await readFile(path);
|
||||
if (before !== after) {
|
||||
snapshots[path] = { before, after };
|
||||
}
|
||||
}
|
||||
|
||||
const changedPaths = Object.keys(snapshots);
|
||||
if (changedPaths.length === 0) return [];
|
||||
|
||||
const writtenPaths: string[] = [];
|
||||
try {
|
||||
for (const path of changedPaths) {
|
||||
await writeFile(path, snapshots[path].after);
|
||||
writtenPaths.push(path);
|
||||
}
|
||||
|
||||
await recordEdit({ label, kind, coalesceKey, files: snapshots });
|
||||
} catch (error) {
|
||||
try {
|
||||
for (const path of writtenPaths.reverse()) {
|
||||
await writeFile(path, snapshots[path].before);
|
||||
}
|
||||
} catch (rollbackError) {
|
||||
throw new AggregateError(
|
||||
[error, rollbackError],
|
||||
"Failed to save project files and rollback did not complete",
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return changedPaths;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { googleFontStylesheetUrl } from "../components/editor/fontCatalog";
|
||||
import { importedFontFaceCss, type ImportedFontAsset } from "../components/editor/fontAssets";
|
||||
import { toRelativeProjectAssetPath } from "./studioHelpers";
|
||||
|
||||
const GENERIC_FONT_FAMILIES = new Set([
|
||||
"inherit",
|
||||
"initial",
|
||||
"revert",
|
||||
"revert-layer",
|
||||
"serif",
|
||||
"sans-serif",
|
||||
"monospace",
|
||||
"cursive",
|
||||
"fantasy",
|
||||
"system-ui",
|
||||
"ui-sans-serif",
|
||||
"ui-serif",
|
||||
"ui-monospace",
|
||||
"ui-rounded",
|
||||
"emoji",
|
||||
"math",
|
||||
"fangsong",
|
||||
]);
|
||||
|
||||
function primaryFontFamilyFromCss(value: string): string {
|
||||
const first = value.split(",")[0] ?? "";
|
||||
return first.trim().replace(/^["']|["']$/g, "");
|
||||
}
|
||||
|
||||
export function primaryFontFamilyValue(value: string): string {
|
||||
return (
|
||||
value
|
||||
.split(",")[0]
|
||||
?.trim()
|
||||
.replace(/^["']|["']$/g, "")
|
||||
.trim() ?? ""
|
||||
);
|
||||
}
|
||||
|
||||
export function injectPreviewGoogleFont(doc: Document, fontFamilyValue: string): void {
|
||||
const family = primaryFontFamilyFromCss(fontFamilyValue);
|
||||
if (!family || GENERIC_FONT_FAMILIES.has(family.toLowerCase())) return;
|
||||
|
||||
const id = `studio-preview-google-font-${family.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`;
|
||||
if (doc.getElementById(id)) return;
|
||||
|
||||
const link = doc.createElement("link");
|
||||
link.id = id;
|
||||
link.rel = "stylesheet";
|
||||
link.href = googleFontStylesheetUrl(family);
|
||||
doc.head.appendChild(link);
|
||||
}
|
||||
|
||||
export function injectPreviewImportedFont(doc: Document, asset: ImportedFontAsset): void {
|
||||
const id = `studio-imported-font-${asset.family.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`;
|
||||
if (doc.getElementById(id)) return;
|
||||
const style = doc.createElement("style");
|
||||
style.id = id;
|
||||
style.textContent = importedFontFaceCss(asset);
|
||||
doc.head.appendChild(style);
|
||||
}
|
||||
|
||||
export function ensureImportedFontFace(
|
||||
html: string,
|
||||
asset: ImportedFontAsset,
|
||||
sourceFile: string,
|
||||
): string {
|
||||
const css = importedFontFaceCss(asset, toRelativeProjectAssetPath(sourceFile, asset.path));
|
||||
if (html.includes(css)) return html;
|
||||
|
||||
const styleRe = /<style\b[^>]*data-hf-studio-fonts=(["'])true\1[^>]*>([\s\S]*?)<\/style>/i;
|
||||
const styleMatch = styleRe.exec(html);
|
||||
if (styleMatch) {
|
||||
const nextCss = `${styleMatch[2].trim()}\n${css}`.trim();
|
||||
return html.replace(styleMatch[0], `<style data-hf-studio-fonts="true">\n${nextCss}\n</style>`);
|
||||
}
|
||||
|
||||
const styleTag = `<style data-hf-studio-fonts="true">\n${css}\n</style>`;
|
||||
if (/<\/head>/i.test(html)) {
|
||||
return html.replace(/<\/head>/i, ` ${styleTag}\n </head>`);
|
||||
}
|
||||
return `${styleTag}\n${html}`;
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
findMatchingTimelineElementId,
|
||||
findTimelineIdByAncestor,
|
||||
resolveTimelineIdForSelection,
|
||||
resolveTimelineSelectionSeekTime,
|
||||
} from "./studioHelpers";
|
||||
|
||||
describe("resolveTimelineSelectionSeekTime", () => {
|
||||
it("keeps the current time when it is already inside the clip range", () => {
|
||||
expect(resolveTimelineSelectionSeekTime(3, { start: 0, duration: 5 })).toBe(3);
|
||||
});
|
||||
|
||||
it("clamps to the clip start when current time is before the clip", () => {
|
||||
expect(resolveTimelineSelectionSeekTime(1, { start: 4, duration: 3 })).toBe(4);
|
||||
});
|
||||
|
||||
it("clamps to the clip end when current time is after the clip", () => {
|
||||
expect(resolveTimelineSelectionSeekTime(10, { start: 4, duration: 3 })).toBe(7);
|
||||
});
|
||||
|
||||
it("falls back to the clip start for invalid current time", () => {
|
||||
expect(resolveTimelineSelectionSeekTime(Number.NaN, { start: 2, duration: 5 })).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findMatchingTimelineElementId", () => {
|
||||
const el = (over: Record<string, unknown>) =>
|
||||
({ id: "x", start: 0, duration: 1, track: 0, tag: "div", ...over }) as never;
|
||||
|
||||
it("matches a top-level element by domId + sourceFile", () => {
|
||||
const els = [el({ id: "s1", domId: "s1", sourceFile: "index.html" })];
|
||||
expect(findMatchingTimelineElementId({ id: "s1", sourceFile: "index.html" }, els)).toBe("s1");
|
||||
});
|
||||
|
||||
it("returns a qualified id for a sub-comp child with no matching timeline element", () => {
|
||||
const els = [el({ id: "s3", domId: "s3", sourceFile: "index.html" })];
|
||||
expect(
|
||||
findMatchingTimelineElementId(
|
||||
{ id: "stat-3", sourceFile: "compositions/stats-panel.html" },
|
||||
els,
|
||||
),
|
||||
).toBe("compositions/stats-panel.html#stat-3");
|
||||
});
|
||||
|
||||
it("returns null for an unmatched element in index.html", () => {
|
||||
expect(findMatchingTimelineElementId({ id: "ghost", sourceFile: "index.html" }, [])).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findTimelineIdByAncestor", () => {
|
||||
const el = (over: Record<string, unknown>) =>
|
||||
({ id: "x", start: 0, duration: 1, track: 0, tag: "div", ...over }) as never;
|
||||
|
||||
it("resolves a static descendant (.num) to its nearest clip ancestor", () => {
|
||||
// #stat1 (a clip) > .num (selected, not a clip)
|
||||
const stat1 = document.createElement("div");
|
||||
stat1.id = "stat1";
|
||||
const num = document.createElement("div");
|
||||
num.className = "num";
|
||||
stat1.appendChild(num);
|
||||
|
||||
const els = [el({ id: "stat1", domId: "stat1", key: "index.html#stat1" })];
|
||||
expect(findTimelineIdByAncestor(num, els, "index.html")).toBe("index.html#stat1");
|
||||
});
|
||||
|
||||
it("returns null when no ancestor is a clip", () => {
|
||||
const wrap = document.createElement("div");
|
||||
const child = document.createElement("span");
|
||||
wrap.appendChild(child);
|
||||
expect(findTimelineIdByAncestor(child, [], "index.html")).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveTimelineIdForSelection", () => {
|
||||
const el = (over: Record<string, unknown>) =>
|
||||
({ id: "x", start: 0, duration: 1, track: 0, tag: "div", ...over }) as never;
|
||||
|
||||
it("resolves an ancestor clip against activeCompPath when the selection has no sourceFile", () => {
|
||||
// #card (a clip in a sub-composition) > .leaf (selected, not itself a clip)
|
||||
const card = document.createElement("div");
|
||||
card.id = "card";
|
||||
const leaf = document.createElement("span");
|
||||
leaf.className = "leaf";
|
||||
card.appendChild(leaf);
|
||||
|
||||
const els = [
|
||||
el({
|
||||
id: "card",
|
||||
domId: "card",
|
||||
key: "comps/panel.html#card",
|
||||
sourceFile: "comps/panel.html",
|
||||
}),
|
||||
];
|
||||
const selection = { element: leaf } as never;
|
||||
|
||||
// Falling back to the active comp matches; the old index.html-only fallback would miss.
|
||||
expect(resolveTimelineIdForSelection(selection, els, "comps/panel.html")).toBe(
|
||||
"comps/panel.html#card",
|
||||
);
|
||||
expect(resolveTimelineIdForSelection(selection, els, null)).toBe(null);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,310 @@
|
||||
import type { TimelineElement } from "../player/store/playerStore";
|
||||
import type { DomEditSelection } from "../components/editor/domEditing";
|
||||
import type { TimelineAssetKind } from "./timelineAssetDrop";
|
||||
import { roundToCenti } from "./rounding";
|
||||
|
||||
export interface EditingFile {
|
||||
path: string;
|
||||
content: string | null;
|
||||
}
|
||||
|
||||
export interface AppToast {
|
||||
message: string;
|
||||
tone: "error" | "info";
|
||||
}
|
||||
|
||||
export type RightPanelTab =
|
||||
| "layers"
|
||||
| "design"
|
||||
| "renders"
|
||||
| "block-params"
|
||||
| "slideshow"
|
||||
| "variables";
|
||||
export type RightInspectorPane = "layers" | "design";
|
||||
|
||||
export interface RightInspectorPanes {
|
||||
layers: boolean;
|
||||
design: boolean;
|
||||
}
|
||||
|
||||
export interface AgentModalAnchorPoint {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export function getTimelineElementLabel(element: TimelineElement): string {
|
||||
return element.label || element.id || element.tag;
|
||||
}
|
||||
|
||||
function normalizeProjectAssetPath(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
const maybeUrl = /^[a-z]+:\/\//i.test(trimmed) ? new URL(trimmed).pathname : trimmed;
|
||||
return decodeURIComponent(maybeUrl)
|
||||
.replace(/\\/g, "/")
|
||||
.replace(/^\.?\//, "");
|
||||
}
|
||||
|
||||
export function toRelativeProjectAssetPath(sourceFile: string, assetPath: string): string {
|
||||
const fromParts = normalizeProjectAssetPath(sourceFile).split("/").filter(Boolean);
|
||||
const targetParts = normalizeProjectAssetPath(assetPath).split("/").filter(Boolean);
|
||||
|
||||
fromParts.pop();
|
||||
|
||||
while (fromParts.length > 0 && targetParts.length > 0 && fromParts[0] === targetParts[0]) {
|
||||
fromParts.shift();
|
||||
targetParts.shift();
|
||||
}
|
||||
|
||||
return [...fromParts.map(() => ".."), ...targetParts].join("/") || assetPath;
|
||||
}
|
||||
|
||||
function isAbsoluteFilePath(value: string): boolean {
|
||||
return /^(?:\/|[A-Za-z]:[\\/]|\\\\)/.test(value);
|
||||
}
|
||||
|
||||
export function toProjectAbsolutePath(
|
||||
projectDir: string | null,
|
||||
sourceFile: string,
|
||||
): string | undefined {
|
||||
const trimmedSource = sourceFile.trim();
|
||||
if (!trimmedSource) return undefined;
|
||||
|
||||
const normalizedSource = trimmedSource.replace(/\\/g, "/");
|
||||
if (isAbsoluteFilePath(normalizedSource)) return normalizedSource;
|
||||
|
||||
const normalizedRoot = projectDir?.trim().replace(/\\/g, "/").replace(/\/+$/, "");
|
||||
if (!normalizedRoot) return undefined;
|
||||
|
||||
return `${normalizedRoot}/${normalizedSource.replace(/^\.?\//, "")}`;
|
||||
}
|
||||
|
||||
export function normalizeDomEditStyleValue(property: string, value: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return trimmed;
|
||||
|
||||
if (
|
||||
["border-radius", "border-width", "font-size", "letter-spacing"].includes(property) &&
|
||||
/^-?\d+(\.\d+)?$/.test(trimmed)
|
||||
) {
|
||||
return `${trimmed}px`;
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
export function isImageBackgroundValue(value: string): boolean {
|
||||
return /^url\(/i.test(value.trim());
|
||||
}
|
||||
|
||||
export function isManualGeometryStyleProperty(property: string): boolean {
|
||||
return property === "left" || property === "top" || property === "width" || property === "height";
|
||||
}
|
||||
|
||||
export function getEventTargetElement(target: EventTarget | null): HTMLElement | null {
|
||||
if (!target || typeof target !== "object") return null;
|
||||
const maybeNode = target as {
|
||||
nodeType?: number;
|
||||
parentElement?: Element | null;
|
||||
};
|
||||
if (maybeNode.nodeType === 1) return target as HTMLElement;
|
||||
if (maybeNode.nodeType === 3 && maybeNode.parentElement) {
|
||||
return maybeNode.parentElement as HTMLElement;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function shouldIgnoreHistoryShortcut(target: EventTarget | null): boolean {
|
||||
const el = getEventTargetElement(target);
|
||||
if (!el) return false;
|
||||
return Boolean(
|
||||
el.closest("input, textarea, select, [contenteditable='true'], [role='textbox'], .cm-editor"),
|
||||
);
|
||||
}
|
||||
|
||||
export function getHistoryShortcutLabel(action: "undo" | "redo"): string {
|
||||
const isMac =
|
||||
typeof navigator !== "undefined" && /Mac|iPhone|iPad|iPod/i.test(navigator.platform);
|
||||
const modifier = isMac ? "Cmd" : "Ctrl";
|
||||
return action === "undo" ? `${modifier}+Z` : `${modifier}+Shift+Z`;
|
||||
}
|
||||
|
||||
type ElementMatchSelection = Pick<
|
||||
DomEditSelection,
|
||||
"id" | "selector" | "selectorIndex" | "sourceFile" | "compositionSrc" | "isCompositionHost"
|
||||
>;
|
||||
|
||||
function matchesByDomId(
|
||||
selection: ElementMatchSelection,
|
||||
element: TimelineElement,
|
||||
selectionSourceFile: string,
|
||||
): boolean {
|
||||
if (!selection.id) return false;
|
||||
return (
|
||||
element.domId === selection.id && (element.sourceFile || "index.html") === selectionSourceFile
|
||||
);
|
||||
}
|
||||
|
||||
function matchesByCompositionHost(
|
||||
selection: ElementMatchSelection,
|
||||
element: TimelineElement,
|
||||
): boolean {
|
||||
if (!selection.isCompositionHost || !selection.compositionSrc) return false;
|
||||
return element.compositionSrc === selection.compositionSrc;
|
||||
}
|
||||
|
||||
function matchesBySelector(selection: ElementMatchSelection, element: TimelineElement): boolean {
|
||||
if (!selection.selector) return false;
|
||||
return (
|
||||
element.selector === selection.selector &&
|
||||
(element.selectorIndex ?? 0) === (selection.selectorIndex ?? 0) &&
|
||||
(element.sourceFile ?? "index.html") === selection.sourceFile
|
||||
);
|
||||
}
|
||||
|
||||
function elementMatchesSelection(
|
||||
selection: ElementMatchSelection,
|
||||
element: TimelineElement,
|
||||
selectionSourceFile: string,
|
||||
): boolean {
|
||||
return (
|
||||
matchesByDomId(selection, element, selectionSourceFile) ||
|
||||
matchesByCompositionHost(selection, element) ||
|
||||
matchesBySelector(selection, element)
|
||||
);
|
||||
}
|
||||
|
||||
export function findMatchingTimelineElementId(
|
||||
selection: ElementMatchSelection,
|
||||
elements: TimelineElement[],
|
||||
): string | null {
|
||||
const selectionSourceFile = selection.sourceFile || "index.html";
|
||||
const match = elements.find((el) => elementMatchesSelection(selection, el, selectionSourceFile));
|
||||
if (match) return match.key ?? match.id;
|
||||
|
||||
// Child inside a sub-composition: return a qualified ID so the expansion
|
||||
// hook can resolve the child via clipParentMap even though no timeline
|
||||
// element exists for it yet (the expansion creates it on the fly).
|
||||
if (selection.id && selectionSourceFile !== "index.html") {
|
||||
return `${selectionSourceFile}#${selection.id}`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A selected DOM node may be a static descendant of a clip (e.g. the `.num` text
|
||||
* inside a `#stat1` card) — not a timeline element itself. Walk up to the nearest
|
||||
* ancestor that IS a clip so the timeline still selects + inline-expands around it.
|
||||
*/
|
||||
export function findTimelineIdByAncestor(
|
||||
element: Element | null | undefined,
|
||||
elements: TimelineElement[],
|
||||
sourceFile: string,
|
||||
): string | null {
|
||||
let ancestor = element?.parentElement ?? null;
|
||||
while (ancestor) {
|
||||
const id = ancestor.id;
|
||||
if (id) {
|
||||
const match = elements.find(
|
||||
(el) => el.domId === id && (el.sourceFile ?? "index.html") === sourceFile,
|
||||
);
|
||||
if (match) return match.key ?? match.id;
|
||||
}
|
||||
ancestor = ancestor.parentElement;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the timeline element id for a DOM selection: direct match first, then
|
||||
* nearest clip ancestor. The ancestor lookup resolves against the selection's own
|
||||
* source file, falling back to the active composition path, then index.html — so a
|
||||
* sub-composition selection with no explicit sourceFile resolves against the comp
|
||||
* currently open, not always the root file.
|
||||
*/
|
||||
export function resolveTimelineIdForSelection(
|
||||
selection: DomEditSelection,
|
||||
elements: TimelineElement[],
|
||||
activeCompPath: string | null,
|
||||
): string | null {
|
||||
return (
|
||||
findMatchingTimelineElementId(selection, elements) ??
|
||||
findTimelineIdByAncestor(
|
||||
selection.element,
|
||||
elements,
|
||||
selection.sourceFile || activeCompPath || "index.html",
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveTimelineSelectionSeekTime(
|
||||
currentTime: number,
|
||||
element: Pick<TimelineElement, "start" | "duration"> | null | undefined,
|
||||
): number | null {
|
||||
if (!element) return null;
|
||||
if (!Number.isFinite(element.start) || !Number.isFinite(element.duration)) return null;
|
||||
|
||||
const start = Math.max(0, element.start);
|
||||
const end = Math.max(start, start + Math.max(0, element.duration));
|
||||
const time = Number.isFinite(currentTime) ? currentTime : start;
|
||||
|
||||
return clampNumber(time, start, end);
|
||||
}
|
||||
|
||||
export function clampNumber(value: number, min: number, max: number): number {
|
||||
if (max < min) return min;
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line unused-export
|
||||
export { COMPOSITION_ROOT_OPEN_TAG_RE } from "./compositionPatterns";
|
||||
|
||||
export function collectHtmlIds(source: string): string[] {
|
||||
return Array.from(source.matchAll(/\bid="([^"]+)"/g), (match) => match[1] ?? "");
|
||||
}
|
||||
|
||||
const DEFAULT_TIMELINE_ASSET_DURATION: Record<TimelineAssetKind, number> = {
|
||||
image: 3,
|
||||
video: 5,
|
||||
audio: 5,
|
||||
};
|
||||
|
||||
export async function resolveDroppedAssetDuration(
|
||||
projectId: string,
|
||||
assetPath: string,
|
||||
kind: TimelineAssetKind,
|
||||
): Promise<number> {
|
||||
if (kind === "image") return DEFAULT_TIMELINE_ASSET_DURATION.image;
|
||||
|
||||
const media = document.createElement(kind === "video" ? "video" : "audio");
|
||||
media.preload = "metadata";
|
||||
media.src = `/api/projects/${projectId}/preview/${assetPath}`;
|
||||
|
||||
const duration = await new Promise<number>((resolve) => {
|
||||
const timeout = window.setTimeout(() => resolve(DEFAULT_TIMELINE_ASSET_DURATION[kind]), 3000);
|
||||
const finalize = (value: number) => {
|
||||
window.clearTimeout(timeout);
|
||||
resolve(value);
|
||||
};
|
||||
|
||||
media.addEventListener(
|
||||
"loadedmetadata",
|
||||
() => {
|
||||
const raw = Number(media.duration);
|
||||
finalize(
|
||||
Number.isFinite(raw) && raw > 0
|
||||
? roundToCenti(raw)
|
||||
: DEFAULT_TIMELINE_ASSET_DURATION[kind],
|
||||
);
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
media.addEventListener("error", () => finalize(DEFAULT_TIMELINE_ASSET_DURATION[kind]), {
|
||||
once: true,
|
||||
});
|
||||
});
|
||||
|
||||
media.src = "";
|
||||
media.load();
|
||||
return duration;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
addStudioPendingEditFlushListener,
|
||||
flushStudioPendingEdits,
|
||||
trackStudioPendingEdit,
|
||||
} from "./studioPendingEdits";
|
||||
|
||||
describe("studio pending edit flush", () => {
|
||||
it("waits for mounted panels to persist pending local edits", async () => {
|
||||
const persist = vi.fn(async () => undefined);
|
||||
const remove = addStudioPendingEditFlushListener(persist);
|
||||
|
||||
try {
|
||||
await flushStudioPendingEdits();
|
||||
expect(persist).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
remove();
|
||||
}
|
||||
});
|
||||
|
||||
it("waits for edits already started by unmounted panels", async () => {
|
||||
const steps: string[] = [];
|
||||
let resolvePersist!: () => void;
|
||||
trackStudioPendingEdit(
|
||||
new Promise<void>((resolve) => {
|
||||
resolvePersist = resolve;
|
||||
}).then(() => {
|
||||
steps.push("persisted");
|
||||
}),
|
||||
);
|
||||
|
||||
const flushed = flushStudioPendingEdits().then(() => {
|
||||
steps.push("flushed");
|
||||
});
|
||||
await Promise.resolve();
|
||||
expect(steps).toEqual([]);
|
||||
|
||||
resolvePersist();
|
||||
await flushed;
|
||||
expect(steps).toEqual(["persisted", "flushed"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
const STUDIO_FLUSH_PENDING_EDITS_EVENT = "hf-studio-flush-pending-edits";
|
||||
|
||||
interface StudioFlushPendingEditsDetail {
|
||||
promises: Array<Promise<unknown>>;
|
||||
}
|
||||
|
||||
const pendingEditPromises = new Set<Promise<unknown>>();
|
||||
|
||||
export function trackStudioPendingEdit(
|
||||
result: Promise<unknown> | unknown,
|
||||
): Promise<unknown> | undefined {
|
||||
if (!result) return undefined;
|
||||
const promise = Promise.resolve(result);
|
||||
pendingEditPromises.add(promise);
|
||||
promise.then(
|
||||
() => pendingEditPromises.delete(promise),
|
||||
() => pendingEditPromises.delete(promise),
|
||||
);
|
||||
return promise;
|
||||
}
|
||||
|
||||
export async function flushStudioPendingEdits(): Promise<void> {
|
||||
const detail: StudioFlushPendingEditsDetail = { promises: [] };
|
||||
window.dispatchEvent(
|
||||
new CustomEvent<StudioFlushPendingEditsDetail>(STUDIO_FLUSH_PENDING_EDITS_EVENT, { detail }),
|
||||
);
|
||||
while (detail.promises.length > 0 || pendingEditPromises.size > 0) {
|
||||
const promises = [...detail.promises, ...pendingEditPromises];
|
||||
detail.promises = [];
|
||||
await Promise.allSettled(promises);
|
||||
}
|
||||
}
|
||||
|
||||
export function addStudioPendingEditFlushListener(
|
||||
handler: () => Promise<unknown> | unknown,
|
||||
): () => void {
|
||||
const listener = (event: Event) => {
|
||||
const detail = (event as CustomEvent<StudioFlushPendingEditsDetail>).detail;
|
||||
if (!detail?.promises) return;
|
||||
const promise = trackStudioPendingEdit(handler());
|
||||
if (promise) detail.promises.push(promise);
|
||||
};
|
||||
window.addEventListener(STUDIO_FLUSH_PENDING_EDITS_EVENT, listener);
|
||||
return () => window.removeEventListener(STUDIO_FLUSH_PENDING_EDITS_EVENT, listener);
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
coversComposition,
|
||||
getPreviewTargetFromPointer,
|
||||
pauseStudioPreviewPlayback,
|
||||
} from "./studioPreviewHelpers";
|
||||
|
||||
function domRect(left: number, top: number, width: number, height: number): DOMRect {
|
||||
return {
|
||||
x: left,
|
||||
y: top,
|
||||
left,
|
||||
top,
|
||||
width,
|
||||
height,
|
||||
right: left + width,
|
||||
bottom: top + height,
|
||||
toJSON: () => ({}),
|
||||
};
|
||||
}
|
||||
|
||||
function stubRect(el: Element, rect: DOMRect): void {
|
||||
el.getBoundingClientRect = () => rect;
|
||||
}
|
||||
|
||||
describe("coversComposition (full-bleed canvas-pick exclusion)", () => {
|
||||
const viewport = { width: 1920, height: 1080 };
|
||||
|
||||
it("treats a full-bleed scene wrapper as covering the composition", () => {
|
||||
expect(coversComposition({ width: 1920, height: 1080 }, viewport)).toBe(true);
|
||||
expect(coversComposition({ width: 1900, height: 1040 }, viewport)).toBe(true); // ~99%/96%
|
||||
});
|
||||
|
||||
it("does NOT exclude inner content (a stat card, a heading)", () => {
|
||||
expect(coversComposition({ width: 320, height: 180 }, viewport)).toBe(false);
|
||||
expect(coversComposition({ width: 1900, height: 200 }, viewport)).toBe(false); // wide but short
|
||||
expect(coversComposition({ width: 200, height: 1040 }, viewport)).toBe(false); // tall but narrow
|
||||
});
|
||||
|
||||
it("needs BOTH axes near full-bleed (>=95%)", () => {
|
||||
expect(coversComposition({ width: 1800, height: 1080 }, viewport)).toBe(false); // 93.75% wide
|
||||
expect(coversComposition({ width: 1920, height: 1000 }, viewport)).toBe(false); // 92.6% tall
|
||||
});
|
||||
|
||||
it("guards against a degenerate viewport", () => {
|
||||
expect(coversComposition({ width: 100, height: 100 }, { width: 0, height: 0 })).toBe(false);
|
||||
expect(coversComposition({ width: 100, height: 100 }, { width: 1, height: 1 })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pauseStudioPreviewPlayback", () => {
|
||||
it("pauses through __player without pausing sibling timelines directly", () => {
|
||||
const playerPause = vi.fn();
|
||||
const timelinePause = vi.fn();
|
||||
const siblingPause = vi.fn();
|
||||
|
||||
const iframe = {
|
||||
contentWindow: {
|
||||
__player: {
|
||||
getTime: () => 4.25,
|
||||
pause: playerPause,
|
||||
},
|
||||
__timeline: {
|
||||
time: () => 4.25,
|
||||
pause: timelinePause,
|
||||
},
|
||||
__timelines: {
|
||||
root: {
|
||||
pause: siblingPause,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as HTMLIFrameElement;
|
||||
|
||||
expect(pauseStudioPreviewPlayback(iframe)).toBe(4.25);
|
||||
expect(playerPause).toHaveBeenCalledTimes(1);
|
||||
expect(timelinePause).not.toHaveBeenCalled();
|
||||
expect(siblingPause).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to pausing timelines directly when __player is unavailable", () => {
|
||||
const timelinePause = vi.fn();
|
||||
const siblingPause = vi.fn();
|
||||
|
||||
const iframe = {
|
||||
contentWindow: {
|
||||
__timeline: {
|
||||
time: () => 2.5,
|
||||
pause: timelinePause,
|
||||
},
|
||||
__timelines: {
|
||||
root: {
|
||||
pause: siblingPause,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as HTMLIFrameElement;
|
||||
|
||||
expect(pauseStudioPreviewPlayback(iframe)).toBe(2.5);
|
||||
expect(timelinePause).toHaveBeenCalledTimes(1);
|
||||
expect(siblingPause).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPreviewTargetFromPointer", () => {
|
||||
it("skips candidates hidden from author hit-testing by inherited pointer-events:none", () => {
|
||||
const iframe = document.createElement("iframe");
|
||||
document.body.append(iframe);
|
||||
const doc = iframe.contentDocument;
|
||||
if (!doc) throw new Error("Expected iframe document");
|
||||
|
||||
doc.body.innerHTML = `
|
||||
<main id="scene" data-composition-id="scene">
|
||||
<h1 id="headline">Launch title</h1>
|
||||
<div id="overlay-parent" style="pointer-events: none;">
|
||||
<div id="overlay" style="position: absolute; background: rgba(0, 0, 0, 0.1);"></div>
|
||||
</div>
|
||||
</main>
|
||||
`;
|
||||
|
||||
const scene = doc.getElementById("scene");
|
||||
const headline = doc.getElementById("headline");
|
||||
const overlayParent = doc.getElementById("overlay-parent");
|
||||
const overlay = doc.getElementById("overlay");
|
||||
if (!scene || !headline || !overlayParent || !overlay) {
|
||||
throw new Error("Expected preview fixture elements");
|
||||
}
|
||||
|
||||
stubRect(iframe, domRect(0, 0, 400, 300));
|
||||
stubRect(scene, domRect(0, 0, 400, 300));
|
||||
stubRect(headline, domRect(40, 40, 160, 48));
|
||||
stubRect(overlayParent, domRect(0, 0, 360, 260));
|
||||
stubRect(overlay, domRect(0, 0, 360, 260));
|
||||
doc.elementsFromPoint = () => [overlay, overlayParent, headline, scene];
|
||||
|
||||
expect(getPreviewTargetFromPointer(iframe, 80, 64, "index.html")).toBe(headline);
|
||||
|
||||
iframe.remove();
|
||||
});
|
||||
|
||||
it("honors a CSS-class pointer-events:auto opt-in under a pointer-events:none ancestor", () => {
|
||||
const iframe = document.createElement("iframe");
|
||||
document.body.append(iframe);
|
||||
const doc = iframe.contentDocument;
|
||||
if (!doc) throw new Error("Expected iframe document");
|
||||
|
||||
doc.head.innerHTML = `<style>.clickable { pointer-events: auto; }</style>`;
|
||||
doc.body.innerHTML = `
|
||||
<main id="scene" data-composition-id="scene">
|
||||
<div id="overlay-parent" style="pointer-events: none;">
|
||||
<button id="clickable-child" class="clickable">Play</button>
|
||||
</div>
|
||||
</main>
|
||||
`;
|
||||
|
||||
const scene = doc.getElementById("scene");
|
||||
const overlayParent = doc.getElementById("overlay-parent");
|
||||
const clickableChild = doc.getElementById("clickable-child");
|
||||
if (!scene || !overlayParent || !clickableChild) {
|
||||
throw new Error("Expected preview fixture elements");
|
||||
}
|
||||
|
||||
stubRect(iframe, domRect(0, 0, 400, 300));
|
||||
stubRect(scene, domRect(0, 0, 400, 300));
|
||||
stubRect(overlayParent, domRect(0, 0, 360, 260));
|
||||
stubRect(clickableChild, domRect(40, 40, 80, 24));
|
||||
doc.elementsFromPoint = () => [clickableChild, overlayParent, scene];
|
||||
|
||||
expect(getPreviewTargetFromPointer(iframe, 60, 50, "index.html")).toBe(clickableChild);
|
||||
|
||||
iframe.remove();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,355 @@
|
||||
import type { DomEditViewport } from "../components/editor/domEditing";
|
||||
import {
|
||||
getDomLayerPatchTarget,
|
||||
isElementComputedVisible,
|
||||
resolveAllVisualDomEditTargets,
|
||||
} from "../components/editor/domEditingElement";
|
||||
import { isHtmlElement } from "../components/editor/domEditingDom";
|
||||
import { getEventTargetElement } from "./studioHelpers";
|
||||
|
||||
interface PreviewLocalPointer {
|
||||
x: number;
|
||||
y: number;
|
||||
viewport: DomEditViewport;
|
||||
}
|
||||
|
||||
// An element is "full-bleed" when its box spans nearly the whole composition on
|
||||
// BOTH axes. Such elements (scene wrappers, backdrops) are excluded from canvas
|
||||
// click-picking so a click lands on inner content — or deselects on empty area —
|
||||
// instead of grabbing the giant container. The Layers panel still selects them.
|
||||
// ponytail: pure size heuristic; tighten the ratio if decorative full-bleed art
|
||||
// should remain canvas-selectable.
|
||||
const FULL_BLEED_RATIO = 0.95;
|
||||
|
||||
export function coversComposition(
|
||||
elRect: { width: number; height: number },
|
||||
viewport: DomEditViewport,
|
||||
): boolean {
|
||||
if (viewport.width <= 1 || viewport.height <= 1) return false;
|
||||
return (
|
||||
elRect.width / viewport.width >= FULL_BLEED_RATIO &&
|
||||
elRect.height / viewport.height >= FULL_BLEED_RATIO
|
||||
);
|
||||
}
|
||||
|
||||
function isFullBleedTarget(el: HTMLElement, viewport: DomEditViewport): boolean {
|
||||
return coversComposition(el.getBoundingClientRect(), viewport);
|
||||
}
|
||||
|
||||
function resolvePreviewLocalPointer(
|
||||
iframe: HTMLIFrameElement,
|
||||
doc: Document,
|
||||
win: Window,
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
): PreviewLocalPointer | null {
|
||||
const iframeRect = iframe.getBoundingClientRect();
|
||||
const root =
|
||||
doc.querySelector<HTMLElement>("[data-composition-id]") ?? doc.documentElement ?? null;
|
||||
const rootRect = root?.getBoundingClientRect();
|
||||
const rootWidth = rootRect?.width || win.innerWidth;
|
||||
const rootHeight = rootRect?.height || win.innerHeight;
|
||||
if (!rootWidth || !rootHeight) return null;
|
||||
|
||||
const scaleX = iframeRect.width / rootWidth;
|
||||
const scaleY = iframeRect.height / rootHeight;
|
||||
return {
|
||||
x: (clientX - iframeRect.left) / scaleX,
|
||||
y: (clientY - iframeRect.top) / scaleY,
|
||||
viewport: { width: rootWidth, height: rootHeight },
|
||||
};
|
||||
}
|
||||
|
||||
const POINTER_EVENTS_OVERRIDE_ID = "__hf_studio_pointer_events_override__";
|
||||
|
||||
function forcePointerEventsAuto(doc: Document): HTMLStyleElement | null {
|
||||
try {
|
||||
const style = doc.createElement("style");
|
||||
style.id = POINTER_EVENTS_OVERRIDE_ID;
|
||||
style.textContent = "* { pointer-events: auto !important; }";
|
||||
doc.head.appendChild(style);
|
||||
return style;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function removePointerEventsOverride(style: HTMLStyleElement | null): void {
|
||||
try {
|
||||
style?.remove();
|
||||
} catch {
|
||||
// cross-origin or detached doc
|
||||
}
|
||||
}
|
||||
|
||||
const pointerEventsInheritanceFallbackByDocument = new WeakMap<Document, boolean>();
|
||||
|
||||
function needsPointerEventsInheritanceFallback(doc: Document, win: Window): boolean {
|
||||
const cached = pointerEventsInheritanceFallbackByDocument.get(doc);
|
||||
if (cached !== undefined) return cached;
|
||||
|
||||
const parent = doc.createElement("div");
|
||||
const child = doc.createElement("div");
|
||||
parent.style.pointerEvents = "none";
|
||||
parent.appendChild(child);
|
||||
const host = doc.body ?? doc.documentElement;
|
||||
if (!host) return false;
|
||||
|
||||
host.appendChild(parent);
|
||||
const needsFallback = win.getComputedStyle(child).pointerEvents !== "none";
|
||||
parent.remove();
|
||||
pointerEventsInheritanceFallbackByDocument.set(doc, needsFallback);
|
||||
return needsFallback;
|
||||
}
|
||||
|
||||
// Own declared pointer-events value, via computed style rather than inline
|
||||
// style, so a CSS-class opt-in/opt-out (not just an inline style attribute)
|
||||
// is honored when walking back down from a pointer-events:none ancestor.
|
||||
function hasOwnPointerEventsOverride(el: HTMLElement, win: Window): boolean {
|
||||
const value = win.getComputedStyle(el).pointerEvents;
|
||||
return value !== "" && value !== "inherit" && value !== "unset";
|
||||
}
|
||||
|
||||
function inheritsPointerEventsNoneFromAncestor(el: HTMLElement, win: Window): boolean {
|
||||
let current = el.parentElement;
|
||||
while (current) {
|
||||
if (win.getComputedStyle(current).pointerEvents === "none") {
|
||||
let descendant: HTMLElement | null = el;
|
||||
while (descendant && descendant !== current) {
|
||||
if (hasOwnPointerEventsOverride(descendant, win)) {
|
||||
return win.getComputedStyle(descendant).pointerEvents === "none";
|
||||
}
|
||||
descendant = descendant.parentElement;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
current = current.parentElement;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function hasAuthorPointerEventsNone(el: HTMLElement): boolean {
|
||||
const win = el.ownerDocument.defaultView;
|
||||
if (!win) return false;
|
||||
if (win.getComputedStyle(el).pointerEvents === "none") return true;
|
||||
if (!needsPointerEventsInheritanceFallback(el.ownerDocument, win)) return false;
|
||||
return inheritsPointerEventsNoneFromAncestor(el, win);
|
||||
}
|
||||
|
||||
function collectPointerEventsNoneTargets(
|
||||
elements: Iterable<Element | null | undefined>,
|
||||
): WeakSet<HTMLElement> {
|
||||
const disabled = new WeakSet<HTMLElement>();
|
||||
for (const entry of elements) {
|
||||
if (isHtmlElement(entry) && hasAuthorPointerEventsNone(entry)) {
|
||||
disabled.add(entry);
|
||||
}
|
||||
}
|
||||
return disabled;
|
||||
}
|
||||
|
||||
// Shared tail of both pointer resolvers: hit-test candidates minus elements the
|
||||
// author hid from hit-testing via pointer-events:none.
|
||||
function filterAuthorInteractiveTargets(
|
||||
elements: Element[],
|
||||
activeCompositionPath: string | null,
|
||||
): HTMLElement[] {
|
||||
const pointerEventsNoneTargets = collectPointerEventsNoneTargets(elements);
|
||||
return resolveAllVisualDomEditTargets(elements, { activeCompositionPath }).filter(
|
||||
(el) => !pointerEventsNoneTargets.has(el),
|
||||
);
|
||||
}
|
||||
|
||||
// Animated group members can move outside their wrapper's static layout box, so
|
||||
// the empty space inside a group's *visual* bounds (the member-union the overlay
|
||||
// draws) doesn't hit-test to the group via elementsFromPoint. Recover it: if the
|
||||
// point falls within a group's live member-union rect, return that wrapper.
|
||||
// Innermost (smallest-area) group wins for nested groups.
|
||||
// fallow-ignore-next-line complexity
|
||||
function findGroupAtPoint(doc: Document, x: number, y: number): HTMLElement | null {
|
||||
let best: HTMLElement | null = null;
|
||||
let bestArea = Infinity;
|
||||
for (const group of Array.from(doc.querySelectorAll<HTMLElement>("[data-hf-group]"))) {
|
||||
let left = Infinity;
|
||||
let top = Infinity;
|
||||
let right = -Infinity;
|
||||
let bottom = -Infinity;
|
||||
for (const member of Array.from(group.children)) {
|
||||
const r = member.getBoundingClientRect();
|
||||
if (r.width === 0 && r.height === 0) continue;
|
||||
left = Math.min(left, r.left);
|
||||
top = Math.min(top, r.top);
|
||||
right = Math.max(right, r.right);
|
||||
bottom = Math.max(bottom, r.bottom);
|
||||
}
|
||||
if (right < left || x < left || x > right || y < top || y > bottom) continue;
|
||||
const area = (right - left) * (bottom - top);
|
||||
if (area < bestArea) {
|
||||
bestArea = area;
|
||||
best = group;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
export function getPreviewTargetFromPointer(
|
||||
iframe: HTMLIFrameElement,
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
activeCompositionPath: string | null,
|
||||
): HTMLElement | null {
|
||||
let doc: Document | null = null;
|
||||
let win: Window | null = null;
|
||||
try {
|
||||
doc = iframe.contentDocument;
|
||||
win = iframe.contentWindow;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!doc || !win) return null;
|
||||
|
||||
const localPointer = resolvePreviewLocalPointer(iframe, doc, win, clientX, clientY);
|
||||
if (!localPointer) return null;
|
||||
|
||||
let overrideStyle = forcePointerEventsAuto(doc);
|
||||
try {
|
||||
if (typeof doc.elementsFromPoint === "function") {
|
||||
const elements = doc.elementsFromPoint(localPointer.x, localPointer.y);
|
||||
removePointerEventsOverride(overrideStyle);
|
||||
overrideStyle = null;
|
||||
const candidates = filterAuthorInteractiveTargets(elements, activeCompositionPath);
|
||||
const visualTarget =
|
||||
candidates.find((el) => !isFullBleedTarget(el, localPointer.viewport)) ?? null;
|
||||
if (visualTarget) return visualTarget;
|
||||
}
|
||||
|
||||
// Belt-and-suspenders: elementsFromPoint is universally supported in the
|
||||
// browsers this ships in, so the override is already removed by this
|
||||
// point in practice — but guard the environment without it too, so
|
||||
// hasAuthorPointerEventsNone below never reads a forced-auto value.
|
||||
removePointerEventsOverride(overrideStyle);
|
||||
overrideStyle = null;
|
||||
|
||||
// No element hit (e.g. empty space inside an animated group's overlay) — fall
|
||||
// back to the group whose member-union contains the point, so the whole group
|
||||
// area is hoverable/selectable, not just where a member currently sits.
|
||||
const groupHit = findGroupAtPoint(doc, localPointer.x, localPointer.y);
|
||||
if (
|
||||
groupHit &&
|
||||
!hasAuthorPointerEventsNone(groupHit) &&
|
||||
getDomLayerPatchTarget(groupHit, activeCompositionPath)
|
||||
)
|
||||
return groupHit;
|
||||
|
||||
const fallback = getEventTargetElement(doc.elementFromPoint(localPointer.x, localPointer.y));
|
||||
if (!fallback || !getDomLayerPatchTarget(fallback, activeCompositionPath)) return null;
|
||||
if (hasAuthorPointerEventsNone(fallback)) return null;
|
||||
if (!isElementComputedVisible(fallback)) return null;
|
||||
if (isFullBleedTarget(fallback, localPointer.viewport)) return null;
|
||||
return fallback;
|
||||
} finally {
|
||||
removePointerEventsOverride(overrideStyle);
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns all independently-selectable elements at the pointer (topmost first). */
|
||||
export function getAllPreviewTargetsFromPointer(
|
||||
iframe: HTMLIFrameElement,
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
activeCompositionPath: string | null,
|
||||
): HTMLElement[] {
|
||||
let doc: Document | null = null;
|
||||
let win: Window | null = null;
|
||||
try {
|
||||
doc = iframe.contentDocument;
|
||||
win = iframe.contentWindow;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
if (!doc || !win) return [];
|
||||
|
||||
const localPointer = resolvePreviewLocalPointer(iframe, doc, win, clientX, clientY);
|
||||
if (!localPointer) return [];
|
||||
|
||||
let overrideStyle = forcePointerEventsAuto(doc);
|
||||
try {
|
||||
if (typeof doc.elementsFromPoint === "function") {
|
||||
const elements = doc.elementsFromPoint(localPointer.x, localPointer.y);
|
||||
removePointerEventsOverride(overrideStyle);
|
||||
overrideStyle = null;
|
||||
return filterAuthorInteractiveTargets(elements, activeCompositionPath).filter(
|
||||
(el) => !isFullBleedTarget(el, localPointer.viewport),
|
||||
);
|
||||
}
|
||||
const fallback = getEventTargetElement(doc.elementFromPoint(localPointer.x, localPointer.y));
|
||||
if (!fallback || !getDomLayerPatchTarget(fallback, activeCompositionPath)) return [];
|
||||
removePointerEventsOverride(overrideStyle);
|
||||
overrideStyle = null;
|
||||
if (hasAuthorPointerEventsNone(fallback)) return [];
|
||||
if (!isElementComputedVisible(fallback)) return [];
|
||||
if (isFullBleedTarget(fallback, localPointer.viewport)) return [];
|
||||
return [fallback];
|
||||
} finally {
|
||||
removePointerEventsOverride(overrideStyle);
|
||||
}
|
||||
}
|
||||
|
||||
function objectLike(value: unknown): object | null {
|
||||
return value && (typeof value === "object" || typeof value === "function") ? value : null;
|
||||
}
|
||||
|
||||
function callPlaybackMethod(target: object | null, key: string): void {
|
||||
const method = target ? Reflect.get(target, key) : null;
|
||||
if (typeof method !== "function") return;
|
||||
try {
|
||||
method.call(target);
|
||||
} catch {
|
||||
// Best-effort playback freeze; drag should still work if playback control is unavailable.
|
||||
}
|
||||
}
|
||||
|
||||
function readPlaybackTime(target: object | null, key: string): number | null {
|
||||
const method = target ? Reflect.get(target, key) : null;
|
||||
if (typeof method !== "function") return null;
|
||||
try {
|
||||
const value = method.call(target);
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function pauseStudioPreviewPlayback(iframe: HTMLIFrameElement | null): number | null {
|
||||
const win = iframe?.contentWindow;
|
||||
if (!win) return null;
|
||||
|
||||
try {
|
||||
const player = objectLike(Reflect.get(win, "__player"));
|
||||
const playerPausedTime = readPlaybackTime(player, "getTime");
|
||||
const playerPause = player ? Reflect.get(player, "pause") : null;
|
||||
if (typeof playerPause === "function") {
|
||||
callPlaybackMethod(player, "pause");
|
||||
return playerPausedTime;
|
||||
}
|
||||
|
||||
let pausedTime: number | null = null;
|
||||
const timeline = objectLike(Reflect.get(win, "__timeline"));
|
||||
pausedTime = pausedTime ?? readPlaybackTime(timeline, "time");
|
||||
callPlaybackMethod(timeline, "pause");
|
||||
|
||||
const timelines = objectLike(Reflect.get(win, "__timelines"));
|
||||
if (timelines) {
|
||||
for (const value of Object.values(timelines)) {
|
||||
const timelineRecord = objectLike(value);
|
||||
pausedTime = pausedTime ?? readPlaybackTime(timelineRecord, "time");
|
||||
callPlaybackMethod(timelineRecord, "pause");
|
||||
}
|
||||
}
|
||||
|
||||
return pausedTime;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
StudioSaveHttpError,
|
||||
StudioSaveNetworkError,
|
||||
buildStudioSaveFailureProperties,
|
||||
getStudioSaveStatusCode,
|
||||
retryStudioSave,
|
||||
} from "./studioSaveDiagnostics";
|
||||
|
||||
describe("studio save diagnostics", () => {
|
||||
it("builds save_failure properties with stable diagnostics", () => {
|
||||
const error = new StudioSaveHttpError("Failed to save index.html (503)", 503);
|
||||
|
||||
expect(
|
||||
buildStudioSaveFailureProperties({
|
||||
source: "code_editor",
|
||||
error,
|
||||
filePath: "index.html",
|
||||
mutationType: "put",
|
||||
attempt: 3,
|
||||
}),
|
||||
).toEqual({
|
||||
source: "code_editor",
|
||||
error_message: "Failed to save index.html (503)",
|
||||
status_code: 503,
|
||||
file_path: "index.html",
|
||||
mutation_type: "put",
|
||||
attempt: 3,
|
||||
label: undefined,
|
||||
target_id: undefined,
|
||||
target_selector: undefined,
|
||||
target_source_file: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("reads nested status codes from error causes", () => {
|
||||
const cause = new StudioSaveHttpError("Too many requests", 429);
|
||||
const error = new Error("retry wrapper") as Error & { cause?: unknown };
|
||||
error.cause = cause;
|
||||
|
||||
expect(getStudioSaveStatusCode(error)).toBe(429);
|
||||
});
|
||||
|
||||
it("retries transient save failures with exponential backoff and jitter", async () => {
|
||||
const sleeps: number[] = [];
|
||||
const operation = vi
|
||||
.fn<(attempt: number) => Promise<string>>()
|
||||
.mockRejectedValueOnce(new StudioSaveHttpError("Server restarting", 503))
|
||||
.mockRejectedValueOnce(new StudioSaveHttpError("Still restarting", 503))
|
||||
.mockRejectedValueOnce(new StudioSaveHttpError("Almost ready", 503))
|
||||
.mockResolvedValue("saved");
|
||||
|
||||
await expect(
|
||||
retryStudioSave(operation, {
|
||||
random: () => 0.5,
|
||||
sleep: async (delayMs) => {
|
||||
sleeps.push(delayMs);
|
||||
},
|
||||
}),
|
||||
).resolves.toBe("saved");
|
||||
|
||||
expect(operation).toHaveBeenCalledTimes(4);
|
||||
expect(operation.mock.calls.map(([attempt]) => attempt)).toEqual([1, 2, 3, 4]);
|
||||
expect(sleeps).toEqual([500, 1000, 2000]);
|
||||
});
|
||||
|
||||
it("does not retry non-transient client failures", async () => {
|
||||
const operation = vi
|
||||
.fn<(attempt: number) => Promise<string>>()
|
||||
.mockRejectedValue(new StudioSaveHttpError("Too large", 413));
|
||||
|
||||
await expect(
|
||||
retryStudioSave(operation, {
|
||||
sleep: async () => {},
|
||||
}),
|
||||
).rejects.toThrow("Too large");
|
||||
|
||||
expect(operation).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("retries typed network failures", async () => {
|
||||
const operation = vi
|
||||
.fn<(attempt: number) => Promise<string>>()
|
||||
.mockRejectedValueOnce(new StudioSaveNetworkError("network dropped"))
|
||||
.mockResolvedValue("saved");
|
||||
|
||||
await expect(
|
||||
retryStudioSave(operation, {
|
||||
sleep: async () => {},
|
||||
}),
|
||||
).resolves.toBe("saved");
|
||||
|
||||
expect(operation).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not retry plain JavaScript errors", async () => {
|
||||
const operation = vi
|
||||
.fn<(attempt: number) => Promise<string>>()
|
||||
.mockRejectedValue(new Error("local assertion failed"));
|
||||
|
||||
await expect(
|
||||
retryStudioSave(operation, {
|
||||
sleep: async () => {},
|
||||
}),
|
||||
).rejects.toThrow("local assertion failed");
|
||||
|
||||
expect(operation).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("aborts while waiting between retry attempts", async () => {
|
||||
const controller = new AbortController();
|
||||
const operation = vi
|
||||
.fn<(attempt: number) => Promise<string>>()
|
||||
.mockRejectedValue(new StudioSaveHttpError("Server restarting", 503));
|
||||
|
||||
const pending = retryStudioSave(operation, {
|
||||
signal: controller.signal,
|
||||
sleep: async (_delayMs, signal) => {
|
||||
controller.abort();
|
||||
if (signal?.aborted) throw new DOMException("Save aborted", "AbortError");
|
||||
},
|
||||
});
|
||||
|
||||
await expect(pending).rejects.toMatchObject({ name: "AbortError" });
|
||||
expect(operation).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,203 @@
|
||||
import { trackStudioEvent } from "./studioTelemetry";
|
||||
|
||||
type StudioTelemetryValue = string | number | boolean | null | undefined;
|
||||
const STUDIO_SAVE_ATTEMPT_PROPERTY = "__studioSaveAttempt";
|
||||
|
||||
export interface StudioSaveFailureInput {
|
||||
source: string;
|
||||
error: unknown;
|
||||
statusCode?: number | null;
|
||||
filePath?: string | null;
|
||||
mutationType?: string | null;
|
||||
attempt?: number | null;
|
||||
label?: string | null;
|
||||
targetId?: string | null;
|
||||
targetSelector?: string | null;
|
||||
targetSourceFile?: string | null;
|
||||
}
|
||||
|
||||
export class StudioSaveHttpError extends Error {
|
||||
readonly statusCode: number;
|
||||
readonly alreadyToasted: boolean;
|
||||
|
||||
constructor(message: string, statusCode: number, options: { alreadyToasted?: boolean } = {}) {
|
||||
super(message);
|
||||
this.name = "StudioSaveHttpError";
|
||||
this.statusCode = statusCode;
|
||||
this.alreadyToasted = options.alreadyToasted ?? false;
|
||||
}
|
||||
}
|
||||
|
||||
export class StudioSaveNetworkError extends Error {
|
||||
constructor(message: string, options?: { cause?: unknown }) {
|
||||
super(message, options);
|
||||
this.name = "StudioSaveNetworkError";
|
||||
}
|
||||
}
|
||||
|
||||
function readNumericProperty(value: object, key: string): number | undefined {
|
||||
const record = value as Record<string, unknown>;
|
||||
const property = record[key];
|
||||
return typeof property === "number" && Number.isFinite(property) ? property : undefined;
|
||||
}
|
||||
|
||||
function createStudioSaveAbortError(): Error {
|
||||
if (typeof DOMException !== "undefined") return new DOMException("Save aborted", "AbortError");
|
||||
const error = new Error("Save aborted");
|
||||
error.name = "AbortError";
|
||||
return error;
|
||||
}
|
||||
|
||||
function throwIfStudioSaveAborted(signal?: AbortSignal): void {
|
||||
if (signal?.aborted) throw createStudioSaveAbortError();
|
||||
}
|
||||
|
||||
function attachStudioSaveAttempt(error: unknown, attempt: number): unknown {
|
||||
if (!error || typeof error !== "object") return error;
|
||||
try {
|
||||
Object.defineProperty(error, STUDIO_SAVE_ATTEMPT_PROPERTY, {
|
||||
value: attempt,
|
||||
configurable: true,
|
||||
});
|
||||
} catch {
|
||||
// Best-effort diagnostic only.
|
||||
}
|
||||
return error;
|
||||
}
|
||||
|
||||
export function getStudioSaveErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error && error.message) return error.message;
|
||||
if (typeof error === "string" && error.trim()) return error;
|
||||
return "Unknown save failure";
|
||||
}
|
||||
|
||||
export function getStudioSaveStatusCode(error: unknown): number | undefined {
|
||||
if (!error || typeof error !== "object") return undefined;
|
||||
const direct =
|
||||
readNumericProperty(error, "statusCode") ??
|
||||
readNumericProperty(error, "status") ??
|
||||
readNumericProperty(error, "status_code");
|
||||
if (direct != null) return direct;
|
||||
|
||||
const cause = (error as { cause?: unknown }).cause;
|
||||
if (cause && cause !== error) return getStudioSaveStatusCode(cause);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getStudioSaveAttempt(error: unknown): number | undefined {
|
||||
if (!error || typeof error !== "object") return undefined;
|
||||
const direct = readNumericProperty(error, STUDIO_SAVE_ATTEMPT_PROPERTY);
|
||||
if (direct != null) return direct;
|
||||
|
||||
const cause = (error as { cause?: unknown }).cause;
|
||||
if (cause && cause !== error) return getStudioSaveAttempt(cause);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isStudioSaveAbortError(error: unknown): boolean {
|
||||
return error instanceof Error && error.name === "AbortError";
|
||||
}
|
||||
|
||||
function isRetryableStudioSaveError(error: unknown): boolean {
|
||||
if (isStudioSaveAbortError(error)) return false;
|
||||
if (error instanceof StudioSaveNetworkError) return true;
|
||||
const statusCode = getStudioSaveStatusCode(error);
|
||||
if (statusCode == null) return false;
|
||||
return statusCode === 408 || statusCode === 425 || statusCode === 429 || statusCode >= 500;
|
||||
}
|
||||
|
||||
export function buildStudioSaveFailureProperties(
|
||||
input: StudioSaveFailureInput,
|
||||
): Record<string, StudioTelemetryValue> {
|
||||
const statusCode = input.statusCode ?? getStudioSaveStatusCode(input.error) ?? null;
|
||||
const attempt = input.attempt ?? getStudioSaveAttempt(input.error) ?? undefined;
|
||||
return {
|
||||
source: input.source,
|
||||
error_message: getStudioSaveErrorMessage(input.error),
|
||||
status_code: statusCode,
|
||||
file_path: input.filePath ?? input.targetSourceFile ?? undefined,
|
||||
mutation_type: input.mutationType ?? undefined,
|
||||
attempt,
|
||||
label: input.label ?? undefined,
|
||||
target_id: input.targetId ?? undefined,
|
||||
target_selector: input.targetSelector ?? undefined,
|
||||
target_source_file: input.targetSourceFile ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function trackStudioSaveFailure(input: StudioSaveFailureInput): void {
|
||||
trackStudioEvent("save_failure", buildStudioSaveFailureProperties(input));
|
||||
}
|
||||
|
||||
export async function createStudioSaveHttpError(
|
||||
response: Response,
|
||||
fallbackMessage: string,
|
||||
options: { alreadyToasted?: boolean } = {},
|
||||
): Promise<StudioSaveHttpError> {
|
||||
let body = "";
|
||||
try {
|
||||
body = await response.text();
|
||||
} catch {
|
||||
body = "";
|
||||
}
|
||||
const detail = body.trim().slice(0, 300);
|
||||
const message = detail
|
||||
? `${fallbackMessage} (${response.status}): ${detail}`
|
||||
: `${fallbackMessage} (${response.status})`;
|
||||
return new StudioSaveHttpError(message, response.status, options);
|
||||
}
|
||||
|
||||
export async function retryStudioSave<T>(
|
||||
operation: (attempt: number) => Promise<T>,
|
||||
options: {
|
||||
retries?: number;
|
||||
baseDelayMs?: number;
|
||||
maxDelayMs?: number;
|
||||
jitterRatio?: number;
|
||||
random?: () => number;
|
||||
signal?: AbortSignal;
|
||||
shouldRetry?: (error: unknown, attempt: number) => boolean;
|
||||
sleep?: (delayMs: number, signal?: AbortSignal) => Promise<void>;
|
||||
} = {},
|
||||
): Promise<T> {
|
||||
const retries = options.retries ?? 3;
|
||||
const baseDelayMs = options.baseDelayMs ?? 500;
|
||||
const maxDelayMs = options.maxDelayMs ?? 8000;
|
||||
const jitterRatio = options.jitterRatio ?? 0.25;
|
||||
const random = options.random ?? Math.random;
|
||||
const shouldRetry = options.shouldRetry ?? isRetryableStudioSaveError;
|
||||
const sleep =
|
||||
options.sleep ??
|
||||
((delayMs: number, signal?: AbortSignal) =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
throwIfStudioSaveAborted(signal);
|
||||
const onAbort = () => {
|
||||
globalThis.clearTimeout(timeout);
|
||||
reject(createStudioSaveAbortError());
|
||||
};
|
||||
const timeout = globalThis.setTimeout(() => {
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
resolve();
|
||||
}, delayMs);
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
}));
|
||||
const maxAttempts = retries + 1;
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
try {
|
||||
throwIfStudioSaveAborted(options.signal);
|
||||
return await operation(attempt);
|
||||
} catch (error) {
|
||||
const failure = attachStudioSaveAttempt(error, attempt);
|
||||
if (attempt >= maxAttempts || !shouldRetry(failure, attempt)) throw failure;
|
||||
const retryIndex = attempt - 1;
|
||||
const exponentialDelay = Math.min(baseDelayMs * 2 ** retryIndex, maxDelayMs);
|
||||
const jitterSpan = exponentialDelay * jitterRatio;
|
||||
const jitteredDelay = Math.round(exponentialDelay + (random() * 2 - 1) * jitterSpan);
|
||||
const delayMs = Math.max(0, Math.min(maxDelayMs, jitteredDelay));
|
||||
await sleep(delayMs, options.signal);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("Save retry loop exited unexpectedly");
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildStudioSelectionSnapshot } from "./studioSelectionSnapshot";
|
||||
import type { DomEditSelection } from "../components/editor/domEditing";
|
||||
|
||||
describe("buildStudioSelectionSnapshot", () => {
|
||||
it("serializes a DOM edit selection without the live HTMLElement", () => {
|
||||
const selection = {
|
||||
element: { tagName: "H1" } as HTMLElement,
|
||||
id: null,
|
||||
hfId: "hero-title",
|
||||
selector: ".title",
|
||||
selectorIndex: 0,
|
||||
label: "Hero title",
|
||||
tagName: "h1",
|
||||
sourceFile: "index.html",
|
||||
compositionPath: "index.html",
|
||||
isCompositionHost: false,
|
||||
isInsideLockedComposition: false,
|
||||
boundingBox: { x: 10, y: 20, width: 300, height: 64 },
|
||||
textContent: "Launch faster",
|
||||
dataAttributes: { "data-hf-id": "hero-title" },
|
||||
inlineStyles: { color: "white" },
|
||||
computedStyles: { "font-size": "48px" },
|
||||
textFields: [],
|
||||
capabilities: { canSelect: true, canEditStyles: true },
|
||||
} as DomEditSelection;
|
||||
|
||||
const snapshot = buildStudioSelectionSnapshot({
|
||||
projectId: "demo",
|
||||
selection,
|
||||
currentTime: 1.25,
|
||||
});
|
||||
|
||||
expect(snapshot).toMatchObject({
|
||||
schemaVersion: 1,
|
||||
projectId: "demo",
|
||||
compositionPath: "index.html",
|
||||
sourceFile: "index.html",
|
||||
currentTime: 1.25,
|
||||
target: { hfId: "hero-title", selector: ".title", selectorIndex: 0 },
|
||||
thumbnailUrl:
|
||||
"/api/projects/demo/thumbnail/index.html?t=1.25&format=png&selector=.title&selectorIndex=0",
|
||||
});
|
||||
expect(JSON.stringify(snapshot)).not.toContain("HTMLElement");
|
||||
expect(snapshot).not.toHaveProperty("element");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { StudioSelectionSnapshot } from "@hyperframes/studio-server";
|
||||
import type { DomEditSelection } from "../components/editor/domEditing";
|
||||
|
||||
function round3(value: number): number {
|
||||
return Math.round(value * 1000) / 1000;
|
||||
}
|
||||
|
||||
function thumbnailUrl({
|
||||
projectId,
|
||||
selection,
|
||||
currentTime,
|
||||
}: {
|
||||
projectId: string;
|
||||
selection: DomEditSelection;
|
||||
currentTime: number;
|
||||
}): string {
|
||||
const compPath = encodeURIComponent(
|
||||
selection.compositionPath || selection.sourceFile || "index.html",
|
||||
);
|
||||
const params = new URLSearchParams({
|
||||
t: String(round3(currentTime)),
|
||||
format: "png",
|
||||
});
|
||||
if (selection.selector) params.set("selector", selection.selector);
|
||||
if (selection.selectorIndex != null) params.set("selectorIndex", String(selection.selectorIndex));
|
||||
return `/api/projects/${encodeURIComponent(projectId)}/thumbnail/${compPath}?${params.toString()}`;
|
||||
}
|
||||
|
||||
export function buildStudioSelectionSnapshot({
|
||||
projectId,
|
||||
selection,
|
||||
currentTime,
|
||||
}: {
|
||||
projectId: string;
|
||||
selection: DomEditSelection;
|
||||
currentTime: number;
|
||||
}): StudioSelectionSnapshot {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
projectId,
|
||||
compositionPath: selection.compositionPath,
|
||||
sourceFile: selection.sourceFile,
|
||||
currentTime: round3(currentTime),
|
||||
target: {
|
||||
id: selection.id,
|
||||
hfId: selection.hfId,
|
||||
selector: selection.selector,
|
||||
selectorIndex: selection.selectorIndex,
|
||||
},
|
||||
label: selection.label,
|
||||
tagName: selection.tagName,
|
||||
boundingBox: {
|
||||
x: round3(selection.boundingBox.x),
|
||||
y: round3(selection.boundingBox.y),
|
||||
width: round3(selection.boundingBox.width),
|
||||
height: round3(selection.boundingBox.height),
|
||||
},
|
||||
textContent: selection.textContent,
|
||||
dataAttributes: { ...selection.dataAttributes },
|
||||
inlineStyles: { ...selection.inlineStyles },
|
||||
computedStyles: { ...selection.computedStyles },
|
||||
textFields: selection.textFields.map((field) => ({
|
||||
key: field.key,
|
||||
label: field.label,
|
||||
value: field.value,
|
||||
tagName: field.tagName,
|
||||
source: field.source,
|
||||
})),
|
||||
capabilities: { ...selection.capabilities },
|
||||
thumbnailUrl: thumbnailUrl({ projectId, selection, currentTime }),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { resolveStudioDistinctId } from "../telemetry/distinctId";
|
||||
|
||||
// PostHog public ingest key — write-only, safe to ship in the client bundle
|
||||
const POSTHOG_API_KEY = "phc_zjjbX0PnWxERXrMHhkEJWj9A9BhGVLRReICgsfTMmpx";
|
||||
const POSTHOG_HOST = "https://us.i.posthog.com";
|
||||
const FLUSH_INTERVAL_MS = 30_000;
|
||||
const FLUSH_TIMEOUT_MS = 5_000;
|
||||
|
||||
interface EventProperties {
|
||||
[key: string]: string | number | boolean | null | undefined;
|
||||
}
|
||||
|
||||
interface QueuedEvent {
|
||||
event: string;
|
||||
properties: EventProperties;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
let queue: QueuedEvent[] = [];
|
||||
let flushTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
// Delegates to the single source of truth (telemetry/distinctId.ts) so `studio:*`
|
||||
// events share one id with `studio_*` / render events, and adopt the CLI's
|
||||
// distinct_id when the CLI launched Studio.
|
||||
function getDistinctId(): string {
|
||||
return resolveStudioDistinctId();
|
||||
}
|
||||
|
||||
function isEnabled(): boolean {
|
||||
try {
|
||||
return localStorage.getItem("hf-studio-telemetry-opt-out") !== "1";
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function getSessionProperties(): EventProperties {
|
||||
return {
|
||||
studio_version: typeof __STUDIO_VERSION__ !== "undefined" ? __STUDIO_VERSION__ : "dev",
|
||||
screen_width: window.screen?.width,
|
||||
screen_height: window.screen?.height,
|
||||
viewport_width: window.innerWidth,
|
||||
viewport_height: window.innerHeight,
|
||||
user_agent: navigator.userAgent,
|
||||
url_hash: location.hash.replace(/#project\//, ""),
|
||||
};
|
||||
}
|
||||
|
||||
declare const __STUDIO_VERSION__: string;
|
||||
|
||||
export function trackStudioEvent(event: string, properties: EventProperties = {}): void {
|
||||
if (!isEnabled()) return;
|
||||
|
||||
queue.push({
|
||||
event: `studio:${event}`,
|
||||
properties: { ...getSessionProperties(), ...properties },
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
if (!flushTimer) {
|
||||
flushTimer = setInterval(flushEvents, FLUSH_INTERVAL_MS);
|
||||
}
|
||||
}
|
||||
|
||||
async function flushEvents(): Promise<void> {
|
||||
if (queue.length === 0) return;
|
||||
|
||||
const batch = queue.map((e) => ({
|
||||
event: e.event,
|
||||
properties: { ...e.properties, $ip: null },
|
||||
distinct_id: getDistinctId(),
|
||||
timestamp: e.timestamp,
|
||||
}));
|
||||
queue = [];
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), FLUSH_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
await fetch(`${POSTHOG_HOST}/batch/`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ api_key: POSTHOG_API_KEY, batch }),
|
||||
signal: controller.signal,
|
||||
});
|
||||
} catch {
|
||||
// Telemetry must never break the studio
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
// Synchronously drains the queue via sendBeacon — safe to call from any
|
||||
// tab-hide handler regardless of listener registration order. Exported so
|
||||
// other modules (e.g. sdkResolverShadow.ts) can force delivery of an event
|
||||
// they just queued without racing this module's own visibilitychange
|
||||
// listener below.
|
||||
export function flushViaBeacon(): void {
|
||||
if (flushTimer) {
|
||||
clearInterval(flushTimer);
|
||||
flushTimer = null;
|
||||
}
|
||||
if (queue.length === 0) return;
|
||||
const batch = queue.map((e) => ({
|
||||
event: e.event,
|
||||
properties: { ...e.properties, $ip: null },
|
||||
distinct_id: getDistinctId(),
|
||||
timestamp: e.timestamp,
|
||||
}));
|
||||
queue = [];
|
||||
const body = JSON.stringify({ api_key: POSTHOG_API_KEY, batch });
|
||||
try {
|
||||
navigator.sendBeacon(`${POSTHOG_HOST}/batch/`, body);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("visibilitychange", () => {
|
||||
if (document.visibilityState === "hidden") flushViaBeacon();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readStudioUiPreferences, writeStudioUiPreferences } from "./studioUiPreferences";
|
||||
|
||||
function createStorage(): Storage {
|
||||
const entries = new Map<string, string>();
|
||||
return {
|
||||
get length() {
|
||||
return entries.size;
|
||||
},
|
||||
clear: () => entries.clear(),
|
||||
getItem: (key) => entries.get(key) ?? null,
|
||||
key: (index) => Array.from(entries.keys())[index] ?? null,
|
||||
removeItem: (key) => entries.delete(key),
|
||||
setItem: (key, value) => entries.set(key, value),
|
||||
};
|
||||
}
|
||||
|
||||
describe("studio UI preferences", () => {
|
||||
it("merges preference patches into one localStorage entry", () => {
|
||||
const storage = createStorage();
|
||||
|
||||
writeStudioUiPreferences({ timelineVisible: false }, storage);
|
||||
writeStudioUiPreferences({ playbackRate: 1.5 }, storage);
|
||||
writeStudioUiPreferences({ audioMuted: true }, storage);
|
||||
writeStudioUiPreferences({ previewZoom: { zoomPercent: 160, panX: -20, panY: 12 } }, storage);
|
||||
|
||||
expect(readStudioUiPreferences(storage)).toEqual({
|
||||
timelineVisible: false,
|
||||
playbackRate: 1.5,
|
||||
audioMuted: true,
|
||||
previewZoom: { zoomPercent: 160, panX: -20, panY: 12 },
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores malformed stored values", () => {
|
||||
const storage = createStorage();
|
||||
storage.setItem(
|
||||
"hf-studio-ui-preferences",
|
||||
JSON.stringify({
|
||||
leftCollapsed: "yes",
|
||||
timelineVisible: true,
|
||||
playbackRate: Number.NaN,
|
||||
audioMuted: "false",
|
||||
previewZoom: { zoomPercent: 150, panX: 0, panY: "bad" },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(readStudioUiPreferences(storage)).toEqual({
|
||||
timelineVisible: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("timelineSnapEnabled preference", () => {
|
||||
it("round-trips through storage", () => {
|
||||
const storage = createStorage();
|
||||
writeStudioUiPreferences({ timelineSnapEnabled: false }, storage);
|
||||
expect(readStudioUiPreferences(storage).timelineSnapEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores non-boolean values", () => {
|
||||
const storage = createStorage();
|
||||
storage.setItem("hf-studio-ui-preferences", JSON.stringify({ timelineSnapEnabled: "yes" }));
|
||||
expect(readStudioUiPreferences(storage).timelineSnapEnabled).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("timeline zoom pin persistence", () => {
|
||||
it("round-trips a pinned manual zoom (survives the post-edit reload)", () => {
|
||||
const storage = createStorage();
|
||||
writeStudioUiPreferences(
|
||||
{ timelineZoomMode: "manual", timelineManualZoomPercent: 250 },
|
||||
storage,
|
||||
);
|
||||
const prefs = readStudioUiPreferences(storage);
|
||||
expect(prefs.timelineZoomMode).toBe("manual");
|
||||
expect(prefs.timelineManualZoomPercent).toBe(250);
|
||||
});
|
||||
|
||||
it("ignores an invalid zoom mode and a non-finite percent", () => {
|
||||
const storage = createStorage();
|
||||
storage.setItem(
|
||||
"hf-studio-ui-preferences",
|
||||
JSON.stringify({ timelineZoomMode: "zoomy", timelineManualZoomPercent: "big" }),
|
||||
);
|
||||
const prefs = readStudioUiPreferences(storage);
|
||||
expect(prefs.timelineZoomMode).toBeUndefined();
|
||||
expect(prefs.timelineManualZoomPercent).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
export interface StoredPreviewZoomState {
|
||||
zoomPercent: number;
|
||||
panX: number;
|
||||
panY: number;
|
||||
}
|
||||
|
||||
export interface StudioUiPreferences {
|
||||
leftCollapsed?: boolean;
|
||||
timelineVisible?: boolean;
|
||||
timelineHeight?: number;
|
||||
playbackRate?: number;
|
||||
audioMuted?: boolean;
|
||||
previewZoom?: StoredPreviewZoomState;
|
||||
recentBlocks?: string[];
|
||||
snapEnabled?: boolean;
|
||||
gridVisible?: boolean;
|
||||
gridSpacing?: number;
|
||||
snapToGrid?: boolean;
|
||||
/** Timeline magnet: snap clip drags/trims/drops to playhead, clip edges, and beats. */
|
||||
timelineSnapEnabled?: boolean;
|
||||
/** Transport + ruler readout mode: timecode or frame number. */
|
||||
timeDisplayMode?: "time" | "frame";
|
||||
/**
|
||||
* Timeline zoom mode. Persisted so a zoom PINNED on the first edit survives the
|
||||
* post-edit iframe reload — otherwise the store reset to "fit" and the duration
|
||||
* change rescaled every clip (the blink-fix's rescale symptom).
|
||||
*/
|
||||
timelineZoomMode?: "fit" | "manual";
|
||||
/** Manual timeline zoom percent, paired with `timelineZoomMode: "manual"`. */
|
||||
timelineManualZoomPercent?: number;
|
||||
}
|
||||
|
||||
const STUDIO_UI_PREFERENCES_KEY = "hf-studio-ui-preferences";
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function getBrowserStorage(): Storage | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
try {
|
||||
return window.localStorage;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
function readStorage(storage: Storage | null): StudioUiPreferences {
|
||||
if (!storage) return {};
|
||||
try {
|
||||
const raw = storage.getItem(STUDIO_UI_PREFERENCES_KEY);
|
||||
if (!raw) return {};
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (!isRecord(parsed)) return {};
|
||||
|
||||
const preferences: StudioUiPreferences = {};
|
||||
if (typeof parsed.leftCollapsed === "boolean") {
|
||||
preferences.leftCollapsed = parsed.leftCollapsed;
|
||||
}
|
||||
if (typeof parsed.timelineVisible === "boolean") {
|
||||
preferences.timelineVisible = parsed.timelineVisible;
|
||||
}
|
||||
if (typeof parsed.timelineHeight === "number" && Number.isFinite(parsed.timelineHeight)) {
|
||||
preferences.timelineHeight = parsed.timelineHeight;
|
||||
}
|
||||
if (typeof parsed.playbackRate === "number" && Number.isFinite(parsed.playbackRate)) {
|
||||
preferences.playbackRate = parsed.playbackRate;
|
||||
}
|
||||
if (typeof parsed.audioMuted === "boolean") {
|
||||
preferences.audioMuted = parsed.audioMuted;
|
||||
}
|
||||
if (isRecord(parsed.previewZoom)) {
|
||||
const { zoomPercent, panX, panY } = parsed.previewZoom;
|
||||
if (
|
||||
typeof zoomPercent === "number" &&
|
||||
Number.isFinite(zoomPercent) &&
|
||||
typeof panX === "number" &&
|
||||
Number.isFinite(panX) &&
|
||||
typeof panY === "number" &&
|
||||
Number.isFinite(panY)
|
||||
) {
|
||||
preferences.previewZoom = { zoomPercent, panX, panY };
|
||||
}
|
||||
}
|
||||
if (Array.isArray(parsed.recentBlocks)) {
|
||||
preferences.recentBlocks = parsed.recentBlocks.filter(
|
||||
(v: unknown): v is string => typeof v === "string",
|
||||
);
|
||||
}
|
||||
if (typeof parsed.snapEnabled === "boolean") {
|
||||
preferences.snapEnabled = parsed.snapEnabled;
|
||||
}
|
||||
if (typeof parsed.gridVisible === "boolean") {
|
||||
preferences.gridVisible = parsed.gridVisible;
|
||||
}
|
||||
if (typeof parsed.gridSpacing === "number" && Number.isFinite(parsed.gridSpacing)) {
|
||||
preferences.gridSpacing = parsed.gridSpacing;
|
||||
}
|
||||
if (typeof parsed.snapToGrid === "boolean") {
|
||||
preferences.snapToGrid = parsed.snapToGrid;
|
||||
}
|
||||
if (typeof parsed.timelineSnapEnabled === "boolean") {
|
||||
preferences.timelineSnapEnabled = parsed.timelineSnapEnabled;
|
||||
}
|
||||
if (parsed.timeDisplayMode === "time" || parsed.timeDisplayMode === "frame") {
|
||||
preferences.timeDisplayMode = parsed.timeDisplayMode;
|
||||
}
|
||||
if (parsed.timelineZoomMode === "fit" || parsed.timelineZoomMode === "manual") {
|
||||
preferences.timelineZoomMode = parsed.timelineZoomMode;
|
||||
}
|
||||
if (
|
||||
typeof parsed.timelineManualZoomPercent === "number" &&
|
||||
Number.isFinite(parsed.timelineManualZoomPercent)
|
||||
) {
|
||||
preferences.timelineManualZoomPercent = parsed.timelineManualZoomPercent;
|
||||
}
|
||||
return preferences;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function readStudioUiPreferences(storage: Storage | null = getBrowserStorage()) {
|
||||
return readStorage(storage);
|
||||
}
|
||||
|
||||
export function writeStudioUiPreferences(
|
||||
patch: StudioUiPreferences,
|
||||
storage: Storage | null = getBrowserStorage(),
|
||||
) {
|
||||
if (!storage) return;
|
||||
try {
|
||||
const next = {
|
||||
...readStorage(storage),
|
||||
...patch,
|
||||
};
|
||||
storage.setItem(STUDIO_UI_PREFERENCES_KEY, JSON.stringify(next));
|
||||
} catch {
|
||||
/* localStorage may be unavailable or full */
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
buildStudioHash,
|
||||
normalizeStudioCompositionPath,
|
||||
normalizeStudioUrlPanelTab,
|
||||
parseStudioUrlStateFromHash,
|
||||
resolveMasterCompositionPath,
|
||||
} from "./studioUrlState";
|
||||
import { useStudioUrlState } from "../hooks/useStudioUrlState";
|
||||
import { usePlayerStore } from "../player";
|
||||
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
describe("resolveMasterCompositionPath", () => {
|
||||
it("prefers index.html when present", () => {
|
||||
expect(resolveMasterCompositionPath(["frames/a.html", "index.html", "b.html"])).toBe(
|
||||
"index.html",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the first .html when there is no index.html", () => {
|
||||
expect(resolveMasterCompositionPath(["notes.md", "card.html", "hero.html"])).toBe("card.html");
|
||||
});
|
||||
|
||||
it("returns null when the project carries no composition", () => {
|
||||
expect(resolveMasterCompositionPath(["notes.md", "styles.css"])).toBeNull();
|
||||
expect(resolveMasterCompositionPath([])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeStudioUrlPanelTab", () => {
|
||||
it("accepts slideshow and variables as valid tabs", () => {
|
||||
expect(normalizeStudioUrlPanelTab("slideshow", { inspectorPanelsEnabled: true })).toBe(
|
||||
"slideshow",
|
||||
);
|
||||
expect(normalizeStudioUrlPanelTab("variables", { inspectorPanelsEnabled: true })).toBe(
|
||||
"variables",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function resetPlayerStore() {
|
||||
usePlayerStore.setState({
|
||||
isPlaying: false,
|
||||
currentTime: 0,
|
||||
duration: 0,
|
||||
timelineReady: false,
|
||||
elements: [],
|
||||
selectedElementId: null,
|
||||
requestedSeekTime: null,
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
document.body.innerHTML = "";
|
||||
window.history.replaceState(null, "", "/");
|
||||
resetPlayerStore();
|
||||
});
|
||||
|
||||
function renderStudioUrlStateHarness(
|
||||
props: Partial<React.ComponentProps<typeof StudioUrlStateHarness>> = {},
|
||||
) {
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const root = createRoot(host);
|
||||
const baseProps: React.ComponentProps<typeof StudioUrlStateHarness> = {
|
||||
projectId: "demo",
|
||||
activeCompPath: null,
|
||||
currentTime: 0,
|
||||
duration: 30,
|
||||
isPlaying: false,
|
||||
compositionLoading: false,
|
||||
refreshKey: 0,
|
||||
previewIframeRef: { current: null },
|
||||
rightPanelTab: "renders",
|
||||
rightCollapsed: true,
|
||||
timelineVisible: true,
|
||||
activeCompPathHydrated: true,
|
||||
domEditSelection: null,
|
||||
buildDomSelectionFromTarget: () => Promise.resolve(null),
|
||||
applyDomSelection: () => {},
|
||||
initialState: {
|
||||
activeCompPath: null,
|
||||
currentTime: 4.2,
|
||||
rightPanelTab: null,
|
||||
rightCollapsed: null,
|
||||
timelineVisible: null,
|
||||
selection: null,
|
||||
},
|
||||
};
|
||||
|
||||
const render = (nextProps: Partial<React.ComponentProps<typeof StudioUrlStateHarness>> = {}) => {
|
||||
act(() => {
|
||||
root.render(
|
||||
React.createElement(StudioUrlStateHarness, {
|
||||
...baseProps,
|
||||
...props,
|
||||
...nextProps,
|
||||
}),
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
render();
|
||||
return {
|
||||
rerender: render,
|
||||
unmount: () =>
|
||||
act(() => {
|
||||
root.unmount();
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function StudioUrlStateHarness(props: Parameters<typeof useStudioUrlState>[0]) {
|
||||
useStudioUrlState(props);
|
||||
return null;
|
||||
}
|
||||
|
||||
describe("studio url state", () => {
|
||||
it("parses persisted studio state from project hash", () => {
|
||||
const state = parseStudioUrlStateFromHash(
|
||||
"#project/demo?v=1&comp=compositions%2Ftitle.html&t=4.25&tab=design&rc=0&tv=1&selFile=index.html&selId=hero",
|
||||
);
|
||||
|
||||
expect(state.activeCompPath).toBe("compositions/title.html");
|
||||
expect(state.currentTime).toBe(4.25);
|
||||
expect(state.rightPanelTab).toBe("design");
|
||||
expect(state.rightCollapsed).toBe(false);
|
||||
expect(state.timelineVisible).toBe(true);
|
||||
expect(state.selection).toEqual({
|
||||
sourceFile: "index.html",
|
||||
id: "hero",
|
||||
selector: undefined,
|
||||
selectorIndex: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("builds a project hash with persisted studio state", () => {
|
||||
expect(
|
||||
buildStudioHash("demo", {
|
||||
activeCompPath: "compositions/title.html",
|
||||
currentTime: 4.2571,
|
||||
rightPanelTab: "layers",
|
||||
rightCollapsed: true,
|
||||
timelineVisible: false,
|
||||
selection: {
|
||||
sourceFile: "index.html",
|
||||
selector: ".card",
|
||||
selectorIndex: 2,
|
||||
},
|
||||
}),
|
||||
).toBe(
|
||||
"#project/demo?v=1&comp=compositions%2Ftitle.html&t=4.257&tab=layers&rc=1&tv=0&selFile=index.html&selSelector=.card&selIndex=2",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back cleanly on invalid values", () => {
|
||||
const state = parseStudioUrlStateFromHash("#project/demo?tab=nope&t=abc&rc=9&tv=7");
|
||||
|
||||
expect(state.activeCompPath).toBeNull();
|
||||
expect(state.currentTime).toBeNull();
|
||||
expect(state.rightPanelTab).toBeNull();
|
||||
expect(state.rightCollapsed).toBeNull();
|
||||
expect(state.timelineVisible).toBeNull();
|
||||
expect(state.selection).toBeNull();
|
||||
});
|
||||
|
||||
it("normalizes stale composition paths to the master composition", () => {
|
||||
expect(
|
||||
normalizeStudioCompositionPath("compositions/missing.html", [
|
||||
"index.html",
|
||||
"compositions/title.html",
|
||||
]),
|
||||
).toBeNull();
|
||||
expect(
|
||||
normalizeStudioCompositionPath("compositions/title.html", [
|
||||
"index.html",
|
||||
"compositions/title.html",
|
||||
]),
|
||||
).toBe("compositions/title.html");
|
||||
});
|
||||
|
||||
it("normalizes url tabs against feature flags", () => {
|
||||
expect(normalizeStudioUrlPanelTab("renders")).toBe("renders");
|
||||
expect(normalizeStudioUrlPanelTab("layers", { inspectorPanelsEnabled: false })).toBe("renders");
|
||||
});
|
||||
|
||||
it("hydrates seek first, preserves the initial url state, then restores selection", async () => {
|
||||
vi.useFakeTimers();
|
||||
window.history.replaceState(null, "", "#project/demo?t=4.2&tab=design&selId=hero");
|
||||
const requestSeek = vi.fn();
|
||||
usePlayerStore.setState({ requestSeek });
|
||||
const selectedElement = document.createElement("div");
|
||||
selectedElement.id = "hero";
|
||||
document.body.append(selectedElement);
|
||||
const previewDoc = document.implementation.createHTMLDocument("preview");
|
||||
previewDoc.body.append(selectedElement);
|
||||
const applyDomSelection = vi.fn();
|
||||
const restoredSelection = {
|
||||
element: selectedElement,
|
||||
id: "hero",
|
||||
selector: "#hero",
|
||||
selectorIndex: 0,
|
||||
sourceFile: "index.html",
|
||||
tagName: "div",
|
||||
label: "Hero",
|
||||
textContent: "",
|
||||
textFields: [],
|
||||
capabilities: {
|
||||
canEditText: false,
|
||||
canEditLayout: true,
|
||||
canApplyManualOffset: true,
|
||||
canApplyManualSize: true,
|
||||
canApplyManualRotation: true,
|
||||
canAdjustOpacity: true,
|
||||
canAdjustFill: true,
|
||||
canAdjustBorderRadius: true,
|
||||
canAdjustStroke: true,
|
||||
canAdjustShadow: true,
|
||||
canAdjustZIndex: true,
|
||||
},
|
||||
computedStyle: {
|
||||
display: "block",
|
||||
position: "absolute",
|
||||
},
|
||||
};
|
||||
|
||||
const harness = renderStudioUrlStateHarness({
|
||||
previewIframeRef: {
|
||||
current: { contentDocument: previewDoc } as HTMLIFrameElement,
|
||||
},
|
||||
rightPanelTab: "design",
|
||||
rightCollapsed: false,
|
||||
applyDomSelection,
|
||||
buildDomSelectionFromTarget: () => Promise.resolve(restoredSelection),
|
||||
initialState: {
|
||||
activeCompPath: null,
|
||||
currentTime: 4.2,
|
||||
rightPanelTab: "design",
|
||||
rightCollapsed: false,
|
||||
timelineVisible: true,
|
||||
selection: { id: "hero" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(requestSeek).toHaveBeenCalledWith(4.2);
|
||||
expect(applyDomSelection).not.toHaveBeenCalled();
|
||||
expect(window.location.hash).toContain("t=4.2");
|
||||
expect(window.location.hash).toContain("tab=design");
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(250);
|
||||
});
|
||||
expect(window.location.hash).toContain("t=4.2");
|
||||
expect(applyDomSelection).not.toHaveBeenCalled();
|
||||
|
||||
// Drive the hook's internal currentTime read. Per #1311 the hook stopped
|
||||
// taking currentTime as a prop and now subscribes to the player store
|
||||
// directly (usePlayerStore((s) => s.currentTime)). The harness prop is a
|
||||
// no-op; the selection-hydration useEffect's time-stability guard
|
||||
// (`Math.abs(currentTime - stableTimeRef.current) > 0.05`) only passes
|
||||
// once the store's currentTime catches up to the seek target.
|
||||
act(() => {
|
||||
usePlayerStore.setState({ currentTime: 4.2 });
|
||||
});
|
||||
harness.rerender({ currentTime: 4.2 });
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(250);
|
||||
// Flush microtasks so the async buildDomSelectionFromTarget Promise resolves
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(applyDomSelection).toHaveBeenCalledWith(restoredSelection, { revealPanel: false });
|
||||
|
||||
harness.rerender({ currentTime: 4.2, domEditSelection: restoredSelection });
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(250);
|
||||
});
|
||||
expect(window.location.hash).toContain("t=4.2");
|
||||
expect(window.location.hash).toContain("selId=hero");
|
||||
|
||||
harness.unmount();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
import type { RightPanelTab } from "./studioHelpers";
|
||||
import { buildProjectHash, parseProjectHashRoute } from "./projectRouting";
|
||||
import { STUDIO_INSPECTOR_PANELS_ENABLED } from "../components/editor/manualEditingAvailability";
|
||||
import { roundTo3 } from "./rounding";
|
||||
|
||||
export interface StudioUrlSelectionState {
|
||||
sourceFile?: string;
|
||||
id?: string;
|
||||
selector?: string;
|
||||
selectorIndex?: number;
|
||||
}
|
||||
|
||||
export interface StudioUrlState {
|
||||
activeCompPath: string | null;
|
||||
currentTime: number | null;
|
||||
rightPanelTab: RightPanelTab | null;
|
||||
rightCollapsed: boolean | null;
|
||||
timelineVisible: boolean | null;
|
||||
selection: StudioUrlSelectionState | null;
|
||||
}
|
||||
|
||||
const VALID_TABS: RightPanelTab[] = ["layers", "design", "renders", "slideshow", "variables"];
|
||||
|
||||
/**
|
||||
* The composition a schema-level panel (Variables / Slideshow) targets on the
|
||||
* master view, where there is no explicit `activeCompPath`. Prefer the
|
||||
* `index.html` convention, but fall back to the first `.html` in the file tree
|
||||
* (composition-browser order) so projects whose entry file is `card.html`,
|
||||
* `hero.html`, etc. don't silently mis-target a non-existent `index.html`.
|
||||
* Returns null when the project carries no composition file at all.
|
||||
*/
|
||||
export function resolveMasterCompositionPath(fileTree: string[]): string | null {
|
||||
if (fileTree.includes("index.html")) return "index.html";
|
||||
return fileTree.find((p) => p.endsWith(".html")) ?? null;
|
||||
}
|
||||
|
||||
export function normalizeStudioUrlPanelTab(
|
||||
tab: RightPanelTab | null,
|
||||
options: {
|
||||
inspectorPanelsEnabled?: boolean;
|
||||
} = {},
|
||||
): RightPanelTab | null {
|
||||
if (!tab) return null;
|
||||
if (!VALID_TABS.includes(tab)) return null;
|
||||
const inspectorPanelsEnabled = options.inspectorPanelsEnabled ?? STUDIO_INSPECTOR_PANELS_ENABLED;
|
||||
|
||||
if (!inspectorPanelsEnabled && tab !== "renders") return "renders";
|
||||
return tab;
|
||||
}
|
||||
|
||||
export function normalizeStudioCompositionPath(
|
||||
activeCompPath: string | null,
|
||||
fileTree: string[],
|
||||
): string | null {
|
||||
if (!activeCompPath || activeCompPath === "index.html") return null;
|
||||
return fileTree.includes(activeCompPath) ? activeCompPath : null;
|
||||
}
|
||||
|
||||
function parseBoolean(value: string | null): boolean | null {
|
||||
if (value === "1") return true;
|
||||
if (value === "0") return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseNumber(value: string | null): number | null {
|
||||
if (value == null || value === "") return null;
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function parseTab(value: string | null): RightPanelTab | null {
|
||||
return VALID_TABS.includes(value as RightPanelTab) ? (value as RightPanelTab) : null;
|
||||
}
|
||||
|
||||
function normalizeSelection(params: URLSearchParams): StudioUrlSelectionState | null {
|
||||
const sourceFile = params.get("selFile") || undefined;
|
||||
const id = params.get("selId") || undefined;
|
||||
const selector = params.get("selSelector") || undefined;
|
||||
const selectorIndex = parseNumber(params.get("selIndex"));
|
||||
|
||||
if (!sourceFile && !id && !selector) return null;
|
||||
|
||||
return {
|
||||
sourceFile,
|
||||
id,
|
||||
selector,
|
||||
selectorIndex: selectorIndex != null ? Math.max(0, Math.floor(selectorIndex)) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function defaultStudioUrlState(): StudioUrlState {
|
||||
return {
|
||||
activeCompPath: null,
|
||||
currentTime: null,
|
||||
rightPanelTab: null,
|
||||
rightCollapsed: null,
|
||||
timelineVisible: null,
|
||||
selection: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseStudioUrlStateFromHash(hash: string): StudioUrlState {
|
||||
const route = parseProjectHashRoute(hash);
|
||||
if (!route) return defaultStudioUrlState();
|
||||
|
||||
const { params } = route;
|
||||
return {
|
||||
activeCompPath: params.get("comp") || null,
|
||||
currentTime: parseNumber(params.get("t")),
|
||||
rightPanelTab: normalizeStudioUrlPanelTab(parseTab(params.get("tab"))),
|
||||
rightCollapsed: parseBoolean(params.get("rc")),
|
||||
timelineVisible: parseBoolean(params.get("tv")),
|
||||
selection: normalizeSelection(params),
|
||||
};
|
||||
}
|
||||
|
||||
export function readStudioUrlStateFromWindow(): StudioUrlState {
|
||||
if (typeof window === "undefined") return defaultStudioUrlState();
|
||||
return parseStudioUrlStateFromHash(window.location.hash);
|
||||
}
|
||||
|
||||
// Pre-existing param-assembly complexity — surfaced by this PR's line shifts.
|
||||
// fallow-ignore-next-line complexity
|
||||
export function buildStudioHash(projectId: string, state: StudioUrlState): string {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
params.set("v", "1");
|
||||
if (state.activeCompPath) params.set("comp", state.activeCompPath);
|
||||
if (state.currentTime != null && Number.isFinite(state.currentTime)) {
|
||||
params.set("t", String(Math.max(0, roundTo3(state.currentTime))));
|
||||
}
|
||||
if (state.rightPanelTab) params.set("tab", state.rightPanelTab);
|
||||
if (state.rightCollapsed != null) params.set("rc", state.rightCollapsed ? "1" : "0");
|
||||
if (state.timelineVisible != null) params.set("tv", state.timelineVisible ? "1" : "0");
|
||||
if (state.selection) {
|
||||
if (state.selection.sourceFile) params.set("selFile", state.selection.sourceFile);
|
||||
if (state.selection.id) params.set("selId", state.selection.id);
|
||||
if (state.selection.selector) params.set("selSelector", state.selection.selector);
|
||||
if (typeof state.selection.selectorIndex === "number") {
|
||||
params.set("selIndex", String(Math.max(0, Math.floor(state.selection.selectorIndex))));
|
||||
}
|
||||
}
|
||||
|
||||
return buildProjectHash(projectId, params);
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildTimelineFileDropPlacements,
|
||||
buildTimelineAssetInsertHtml,
|
||||
getTimelineAssetKind,
|
||||
insertTimelineAssetIntoSource,
|
||||
resolveTimelineAssetInitialGeometry,
|
||||
resolveTimelineAssetSrc,
|
||||
} from "./timelineAssetDrop";
|
||||
|
||||
describe("getTimelineAssetKind", () => {
|
||||
it("detects image, video, and audio assets", () => {
|
||||
expect(getTimelineAssetKind("assets/photo.png")).toBe("image");
|
||||
expect(getTimelineAssetKind("assets/clip.mp4")).toBe("video");
|
||||
expect(getTimelineAssetKind("assets/clip.mov")).toBe("video");
|
||||
expect(getTimelineAssetKind("assets/music.mp3")).toBe("audio");
|
||||
expect(getTimelineAssetKind("assets/music.wav")).toBe("audio");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildTimelineAssetInsertHtml", () => {
|
||||
it("builds an image clip with explicit timing and track", () => {
|
||||
const html = buildTimelineAssetInsertHtml({
|
||||
id: "photo_asset",
|
||||
assetPath: "assets/photo.png",
|
||||
kind: "image",
|
||||
start: 1.25,
|
||||
duration: 3,
|
||||
track: 2,
|
||||
zIndex: 4,
|
||||
geometry: { left: 0, top: 0, width: 1280, height: 720 },
|
||||
});
|
||||
|
||||
expect(html).toContain('img id="photo_asset"');
|
||||
expect(html).toContain("left: 0px");
|
||||
expect(html).toContain("width: 1280px");
|
||||
expect(html).not.toContain("inset:");
|
||||
});
|
||||
|
||||
it("builds an audio clip without visual layout styles", () => {
|
||||
const html = buildTimelineAssetInsertHtml({
|
||||
id: "music_asset",
|
||||
assetPath: "assets/music.wav",
|
||||
kind: "audio",
|
||||
start: 0.5,
|
||||
duration: 5,
|
||||
track: 0,
|
||||
zIndex: 1,
|
||||
});
|
||||
expect(html).toContain("<audio");
|
||||
expect(html).not.toContain("object-fit");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveTimelineAssetInitialGeometry", () => {
|
||||
it("uses the target composition dimensions for visual media", () => {
|
||||
expect(
|
||||
resolveTimelineAssetInitialGeometry(
|
||||
`<div data-composition-id="main" data-width="330" data-height="228"></div>`,
|
||||
),
|
||||
).toEqual({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 330,
|
||||
height: 228,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveTimelineAssetSrc", () => {
|
||||
it("keeps project-root asset paths for index.html", () => {
|
||||
expect(resolveTimelineAssetSrc("index.html", "assets/photo.png")).toBe("assets/photo.png");
|
||||
});
|
||||
|
||||
it("rewrites asset paths relative to sub-compositions", () => {
|
||||
expect(resolveTimelineAssetSrc("compositions/scene-a.html", "assets/photo.png")).toBe(
|
||||
"../assets/photo.png",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildTimelineFileDropPlacements", () => {
|
||||
it("returns no placements for an empty drop set", () => {
|
||||
expect(buildTimelineFileDropPlacements({ start: 1.5, track: 2 }, [])).toEqual([]);
|
||||
});
|
||||
|
||||
it("uses the dropped start and spaces multiple files by duration on the same track", () => {
|
||||
expect(buildTimelineFileDropPlacements({ start: 1.5, track: 2 }, [1.2, 1.6, 1.1])).toEqual([
|
||||
{ start: 1.5, track: 2 },
|
||||
{ start: 2.7, track: 2 },
|
||||
{ start: 4.3, track: 2 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses fallback spacing when a duration is unavailable", () => {
|
||||
expect(buildTimelineFileDropPlacements({ start: 1.5, track: 2 }, [1.2, 0, 1.1])).toEqual([
|
||||
{ start: 1.5, track: 2 },
|
||||
{ start: 2.7, track: 2 },
|
||||
{ start: 7.7, track: 2 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("moves the spaced sequence to a clear track when the dropped row is occupied", () => {
|
||||
expect(
|
||||
buildTimelineFileDropPlacements(
|
||||
{ start: 1.5, track: 2 },
|
||||
[1.2, 1.6, 1.1],
|
||||
[
|
||||
{ start: 0, duration: 8, track: 2 },
|
||||
{ start: 0, duration: 4, track: 5 },
|
||||
],
|
||||
),
|
||||
).toEqual([
|
||||
{ start: 1.5, track: 6 },
|
||||
{ start: 2.7, track: 6 },
|
||||
{ start: 4.3, track: 6 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps a requested track above occupied rows when that track is clear", () => {
|
||||
expect(
|
||||
buildTimelineFileDropPlacements(
|
||||
{ start: 1.5, track: 8 },
|
||||
[1.2, 1.6],
|
||||
[
|
||||
{ start: 0, duration: 8, track: 2 },
|
||||
{ start: 0, duration: 4, track: 5 },
|
||||
],
|
||||
),
|
||||
).toEqual([
|
||||
{ start: 1.5, track: 8 },
|
||||
{ start: 2.7, track: 8 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("moves a default-track drop to a clear row when track 0 is occupied at time 0", () => {
|
||||
expect(
|
||||
buildTimelineFileDropPlacements(
|
||||
{ start: 0, track: 0 },
|
||||
[1.2, 1.6],
|
||||
[{ start: 0, duration: 8, track: 0 }],
|
||||
),
|
||||
).toEqual([
|
||||
{ start: 0, track: 1 },
|
||||
{ start: 1.2, track: 1 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("insertTimelineAssetIntoSource", () => {
|
||||
it("appends the new asset inside the root composition", () => {
|
||||
const source = `<!doctype html><html><body><div id="root" data-composition-id="main"></div></body></html>`;
|
||||
const html = insertTimelineAssetIntoSource(
|
||||
source,
|
||||
'<img id="photo_asset" data-start="0" data-duration="3" />',
|
||||
);
|
||||
|
||||
expect(html).toContain('data-composition-id="main">');
|
||||
expect(html).toContain('<img id="photo_asset" data-start="0" data-duration="3" />');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
import { AUDIO_EXT, IMAGE_EXT, VIDEO_EXT } from "./mediaTypes";
|
||||
import { roundToCenti } from "./rounding";
|
||||
import { COMPOSITION_ROOT_OPEN_TAG_RE } from "./compositionPatterns";
|
||||
|
||||
export const TIMELINE_ASSET_MIME = "application/x-hyperframes-asset";
|
||||
export const TIMELINE_BLOCK_MIME = "application/x-hyperframes-block";
|
||||
const FALLBACK_TIMELINE_FILE_DROP_DURATION = 5;
|
||||
|
||||
export type TimelineAssetKind = "image" | "video" | "audio";
|
||||
|
||||
export function getTimelineAssetKind(assetPath: string): TimelineAssetKind | null {
|
||||
if (IMAGE_EXT.test(assetPath)) return "image";
|
||||
if (VIDEO_EXT.test(assetPath)) return "video";
|
||||
if (AUDIO_EXT.test(assetPath)) return "audio";
|
||||
return null;
|
||||
}
|
||||
|
||||
export function buildTimelineAssetId(assetPath: string, existingIds: Iterable<string>): string {
|
||||
const baseName = assetPath.split("/").pop() ?? "asset";
|
||||
const normalized = baseName
|
||||
.replace(/\.[^.]+$/, "")
|
||||
.replace(/[^a-zA-Z0-9_-]+/g, "_")
|
||||
.replace(/^_+|_+$/g, "")
|
||||
.toLowerCase();
|
||||
const baseId = normalized || "asset";
|
||||
const ids = new Set(existingIds);
|
||||
if (!ids.has(baseId)) return baseId;
|
||||
let suffix = 2;
|
||||
while (ids.has(`${baseId}_${suffix}`)) suffix += 1;
|
||||
return `${baseId}_${suffix}`;
|
||||
}
|
||||
|
||||
export function resolveTimelineAssetSrc(targetPath: string, assetPath: string): string {
|
||||
const targetDir = targetPath.includes("/")
|
||||
? targetPath.slice(0, targetPath.lastIndexOf("/"))
|
||||
: "";
|
||||
if (!targetDir) return assetPath;
|
||||
|
||||
const fromParts = targetDir.split("/").filter(Boolean);
|
||||
const toParts = assetPath.split("/").filter(Boolean);
|
||||
while (fromParts.length > 0 && toParts.length > 0 && fromParts[0] === toParts[0]) {
|
||||
fromParts.shift();
|
||||
toParts.shift();
|
||||
}
|
||||
|
||||
const up = fromParts.map(() => "..");
|
||||
const relative = [...up, ...toParts].join("/");
|
||||
return relative || assetPath.split("/").pop() || assetPath;
|
||||
}
|
||||
|
||||
export function buildTimelineFileDropPlacements(
|
||||
placement: { start: number; track: number },
|
||||
durations: number[],
|
||||
occupiedClips: Array<{ start: number; duration: number; track: number }> = [],
|
||||
): Array<{ start: number; track: number }> {
|
||||
let nextStart = roundToCenti(Math.max(0, placement.start));
|
||||
const sequenceStart = nextStart;
|
||||
const resolvedDurations = durations.map((duration) =>
|
||||
Number.isFinite(duration) && duration > 0 ? duration : FALLBACK_TIMELINE_FILE_DROP_DURATION,
|
||||
);
|
||||
const sequenceEnd = resolvedDurations.reduce(
|
||||
(end, duration) => roundToCenti(end + duration),
|
||||
sequenceStart,
|
||||
);
|
||||
const overlapsDropTrack = occupiedClips.some((clip) => {
|
||||
if (clip.track !== placement.track) return false;
|
||||
const clipStart = Math.max(0, clip.start);
|
||||
const clipEnd = clipStart + Math.max(0, clip.duration);
|
||||
return sequenceStart < clipEnd && sequenceEnd > clipStart;
|
||||
});
|
||||
const track = overlapsDropTrack
|
||||
? Math.max(placement.track, ...occupiedClips.map((clip) => clip.track)) + 1
|
||||
: placement.track;
|
||||
|
||||
return resolvedDurations.map((duration) => {
|
||||
const start = nextStart;
|
||||
nextStart = roundToCenti(nextStart + duration);
|
||||
return { start, track };
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveTimelineAssetInitialGeometry(source: string): {
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
height: number;
|
||||
} {
|
||||
const width = Number.parseFloat(source.match(/\bdata-width=(["'])([^"']+)\1/i)?.[2] ?? "");
|
||||
const height = Number.parseFloat(source.match(/\bdata-height=(["'])([^"']+)\1/i)?.[2] ?? "");
|
||||
|
||||
return {
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: Number.isFinite(width) && width > 0 ? Math.round(width) : 640,
|
||||
height: Number.isFinite(height) && height > 0 ? Math.round(height) : 360,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildTimelineAssetInsertHtml(input: {
|
||||
id: string;
|
||||
assetPath: string;
|
||||
kind: TimelineAssetKind;
|
||||
start: number;
|
||||
duration: number;
|
||||
track: number;
|
||||
zIndex: number;
|
||||
geometry?: { left: number; top: number; width: number; height: number };
|
||||
}): string {
|
||||
const sharedAttrs = `id="${input.id}" class="clip" src="${input.assetPath}" data-start="${input.start}" data-duration="${input.duration}" data-track-index="${input.track}"`;
|
||||
const geometry = input.geometry ?? { left: 0, top: 0, width: 640, height: 360 };
|
||||
const visualStyles = `position: absolute; left: ${geometry.left}px; top: ${geometry.top}px; width: ${geometry.width}px; height: ${geometry.height}px; object-fit: contain; z-index: ${input.zIndex}`;
|
||||
|
||||
if (input.kind === "image") {
|
||||
return `<img ${sharedAttrs} style="${visualStyles}" />`;
|
||||
}
|
||||
|
||||
if (input.kind === "video") {
|
||||
return `<video ${sharedAttrs} muted playsinline style="${visualStyles}"></video>`;
|
||||
}
|
||||
|
||||
return `<audio ${sharedAttrs} style="z-index: ${input.zIndex}"></audio>`;
|
||||
}
|
||||
|
||||
export function insertTimelineAssetIntoSource(source: string, assetHtml: string): string {
|
||||
const match = COMPOSITION_ROOT_OPEN_TAG_RE.exec(source);
|
||||
if (!match || match.index == null) {
|
||||
throw new Error("No composition root found in target source");
|
||||
}
|
||||
const insertAt = match.index + match[0].length;
|
||||
const lineStart = source.lastIndexOf("\n", match.index);
|
||||
const leadingWhitespace = source.slice(lineStart + 1, match.index).match(/^(\s*)/)?.[1] ?? "";
|
||||
const childIndent = leadingWhitespace + " ";
|
||||
const indented = assetHtml
|
||||
.split("\n")
|
||||
.map((line, i) => (i === 0 ? line : childIndent + line))
|
||||
.join("\n");
|
||||
return `${source.slice(0, insertAt)}\n${childIndent}${indented}${source.slice(insertAt)}`;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
TIMELINE_TOGGLE_SHORTCUT_LABEL,
|
||||
getTimelineToggleTitle,
|
||||
shouldHandleTimelineToggleHotkey,
|
||||
} from "./timelineDiscovery";
|
||||
|
||||
describe("shouldHandleTimelineToggleHotkey", () => {
|
||||
it("accepts Shift+T when focus is not inside an editor", () => {
|
||||
expect(
|
||||
shouldHandleTimelineToggleHotkey({
|
||||
key: "T",
|
||||
shiftKey: true,
|
||||
metaKey: false,
|
||||
ctrlKey: false,
|
||||
altKey: false,
|
||||
target: {
|
||||
tagName: "DIV",
|
||||
isContentEditable: false,
|
||||
closest: () => null,
|
||||
},
|
||||
} as KeyboardEvent),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores the shortcut inside text inputs", () => {
|
||||
expect(
|
||||
shouldHandleTimelineToggleHotkey({
|
||||
key: "t",
|
||||
shiftKey: true,
|
||||
metaKey: false,
|
||||
ctrlKey: false,
|
||||
altKey: false,
|
||||
target: {
|
||||
tagName: "TEXTAREA",
|
||||
isContentEditable: false,
|
||||
closest: () => null,
|
||||
},
|
||||
} as KeyboardEvent),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores the shortcut inside contenteditable editors", () => {
|
||||
expect(
|
||||
shouldHandleTimelineToggleHotkey({
|
||||
key: "t",
|
||||
shiftKey: true,
|
||||
metaKey: false,
|
||||
ctrlKey: false,
|
||||
altKey: false,
|
||||
target: {
|
||||
tagName: "DIV",
|
||||
isContentEditable: true,
|
||||
closest: () => null,
|
||||
},
|
||||
} as KeyboardEvent),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("requires Shift without other modifiers", () => {
|
||||
expect(
|
||||
shouldHandleTimelineToggleHotkey({
|
||||
key: "t",
|
||||
shiftKey: false,
|
||||
metaKey: false,
|
||||
ctrlKey: false,
|
||||
altKey: false,
|
||||
target: null,
|
||||
} as KeyboardEvent),
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
shouldHandleTimelineToggleHotkey({
|
||||
key: "t",
|
||||
shiftKey: true,
|
||||
metaKey: true,
|
||||
ctrlKey: false,
|
||||
altKey: false,
|
||||
target: null,
|
||||
} as KeyboardEvent),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTimelineToggleTitle", () => {
|
||||
it("includes the shortcut in both show and hide titles", () => {
|
||||
expect(getTimelineToggleTitle(true)).toContain(TIMELINE_TOGGLE_SHORTCUT_LABEL);
|
||||
expect(getTimelineToggleTitle(false)).toContain(TIMELINE_TOGGLE_SHORTCUT_LABEL);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
export const TIMELINE_TOGGLE_SHORTCUT_LABEL = "Shift+T";
|
||||
type TimelineToggleHotkeyEvent = Pick<
|
||||
KeyboardEvent,
|
||||
"key" | "shiftKey" | "metaKey" | "ctrlKey" | "altKey" | "target"
|
||||
>;
|
||||
|
||||
interface EditableTargetLike {
|
||||
tagName?: string;
|
||||
isContentEditable?: boolean;
|
||||
closest?: (selector: string) => unknown;
|
||||
getAttribute?: (name: string) => string | null;
|
||||
}
|
||||
|
||||
export function isEditableTarget(target: EventTarget | null): boolean {
|
||||
if (!target || typeof target !== "object") return false;
|
||||
|
||||
const element = target as EditableTargetLike;
|
||||
const tagName = element.tagName?.toLowerCase();
|
||||
if (tagName === "input" || tagName === "textarea" || tagName === "select") return true;
|
||||
if (element.isContentEditable) return true;
|
||||
|
||||
const role = element.getAttribute?.("role");
|
||||
if (role === "textbox" || role === "searchbox" || role === "combobox") return true;
|
||||
|
||||
return Boolean(
|
||||
element.closest?.(
|
||||
"input, textarea, select, [contenteditable='true'], [role='textbox'], .cm-editor",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function shouldHandleTimelineToggleHotkey(event: TimelineToggleHotkeyEvent): boolean {
|
||||
if (event.metaKey || event.ctrlKey || event.altKey) return false;
|
||||
if (!event.shiftKey) return false;
|
||||
if (event.key.toLowerCase() !== "t") return false;
|
||||
return !isEditableTarget(event.target);
|
||||
}
|
||||
|
||||
export function getTimelineToggleTitle(timelineVisible: boolean): string {
|
||||
return `${timelineVisible ? "Hide" : "Show"} timeline editor (${TIMELINE_TOGGLE_SHORTCUT_LABEL})`;
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type { TimelineElement } from "../player/store/playerStore";
|
||||
import {
|
||||
SPLIT_BOUNDARY_EPSILON_S,
|
||||
canSplitElementAt,
|
||||
isSplitTimeWithinBounds,
|
||||
selectSplittableElements,
|
||||
} from "./timelineElementSplit";
|
||||
|
||||
function element(overrides: Partial<TimelineElement> = {}): TimelineElement {
|
||||
return {
|
||||
id: "el-1",
|
||||
tag: "div",
|
||||
start: 1,
|
||||
duration: 4,
|
||||
track: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("isSplitTimeWithinBounds", () => {
|
||||
const start = 1;
|
||||
const duration = 4;
|
||||
const end = start + duration;
|
||||
|
||||
it("accepts the exact lower clamp boundary", () => {
|
||||
// The timeline canvas clamps an edge click to exactly
|
||||
// start + SPLIT_BOUNDARY_EPSILON_S, so that value must be splittable.
|
||||
expect(isSplitTimeWithinBounds(start + SPLIT_BOUNDARY_EPSILON_S, start, duration)).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts the exact upper clamp boundary", () => {
|
||||
expect(
|
||||
isSplitTimeWithinBounds(start + duration - SPLIT_BOUNDARY_EPSILON_S, start, duration),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts an interior split time", () => {
|
||||
expect(isSplitTimeWithinBounds(3, start, duration)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects times at or outside the clip edges", () => {
|
||||
expect(isSplitTimeWithinBounds(start, start, duration)).toBe(false);
|
||||
expect(isSplitTimeWithinBounds(end, start, duration)).toBe(false);
|
||||
expect(isSplitTimeWithinBounds(start - 1, start, duration)).toBe(false);
|
||||
expect(isSplitTimeWithinBounds(end + 1, start, duration)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects times inside the epsilon margins", () => {
|
||||
expect(isSplitTimeWithinBounds(start + SPLIT_BOUNDARY_EPSILON_S / 2, start, duration)).toBe(
|
||||
false,
|
||||
);
|
||||
expect(isSplitTimeWithinBounds(end - SPLIT_BOUNDARY_EPSILON_S / 2, start, duration)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects every time on a clip shorter than two epsilons", () => {
|
||||
// Math.max(min, Math.min(max, t)) collapses to min when the clip is too
|
||||
// short for the clamp range; that collapsed value must still be rejected.
|
||||
const shortDuration = SPLIT_BOUNDARY_EPSILON_S;
|
||||
expect(isSplitTimeWithinBounds(start + SPLIT_BOUNDARY_EPSILON_S, start, shortDuration)).toBe(
|
||||
false,
|
||||
);
|
||||
expect(isSplitTimeWithinBounds(start + shortDuration / 2, start, shortDuration)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("canSplitElementAt", () => {
|
||||
it("accepts a splittable element at an interior time", () => {
|
||||
expect(canSplitElementAt(element({ start: 1, duration: 4 }), 3)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a time inside the boundary epsilon", () => {
|
||||
expect(
|
||||
canSplitElementAt(element({ start: 1, duration: 4 }), 1 + SPLIT_BOUNDARY_EPSILON_S / 2),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects locked, implicit and sub-composition elements", () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
describe("selectSplittableElements", () => {
|
||||
it("excludes a clip shorter than two epsilons even when the time is inside it", () => {
|
||||
// Regression: split-all used raw start < t < end, so a clip too short for
|
||||
// the epsilon margin was still selected and produced a degenerate slice.
|
||||
const tiny = element({ id: "tiny", start: 1, duration: SPLIT_BOUNDARY_EPSILON_S + 0.01 });
|
||||
const interiorTime = tiny.start + tiny.duration / 2;
|
||||
expect(interiorTime).toBeGreaterThan(tiny.start);
|
||||
expect(interiorTime).toBeLessThan(tiny.start + tiny.duration);
|
||||
expect(selectSplittableElements([tiny], interiorTime)).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps only the elements whose epsilon-bounded range contains the time", () => {
|
||||
const inside = element({ id: "inside", start: 0, duration: 4 });
|
||||
const outside = element({ id: "outside", start: 5, duration: 4 });
|
||||
const locked = element({ id: "locked", start: 0, duration: 4, timelineLocked: true });
|
||||
const result = selectSplittableElements([inside, outside, locked], 2);
|
||||
expect(result.map((el) => el.id)).toEqual(["inside"]);
|
||||
});
|
||||
|
||||
it("accepts an element at the exact lower clamp boundary", () => {
|
||||
const el = element({ start: 2, duration: 4 });
|
||||
expect(selectSplittableElements([el], 2 + SPLIT_BOUNDARY_EPSILON_S)).toEqual([el]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { TimelineElement } from "../player/store/playerStore";
|
||||
|
||||
export { buildPatchTarget, readFileContent } from "../hooks/timelineEditingHelpers";
|
||||
|
||||
/** Minimum distance (seconds) from clip boundaries to allow a split. */
|
||||
export const SPLIT_BOUNDARY_EPSILON_S = 0.03;
|
||||
|
||||
/**
|
||||
* True when splitTime leaves at least SPLIT_BOUNDARY_EPSILON_S on both sides
|
||||
* of the cut. Inclusive at the epsilon offsets: the timeline canvas clamps
|
||||
* edge clicks to exactly start/end ± epsilon, so the clamped value must pass.
|
||||
*/
|
||||
export function isSplitTimeWithinBounds(
|
||||
splitTime: number,
|
||||
clipStart: number,
|
||||
clipDuration: number,
|
||||
): boolean {
|
||||
return (
|
||||
splitTime >= clipStart + SPLIT_BOUNDARY_EPSILON_S &&
|
||||
splitTime <= clipStart + clipDuration - SPLIT_BOUNDARY_EPSILON_S
|
||||
);
|
||||
}
|
||||
|
||||
export function canSplitElement(el: TimelineElement): boolean {
|
||||
return (
|
||||
!el.timelineLocked &&
|
||||
el.timingSource !== "implicit" &&
|
||||
!el.compositionSrc &&
|
||||
!!el.duration &&
|
||||
Number.isFinite(el.duration)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when `el` can be split AND `splitTime` lies within its boundary epsilon.
|
||||
* Shared by the single-clip and split-all razor paths so both honor the same
|
||||
* minimum-distance rule (split-all previously used raw `>`/`<`, letting cuts
|
||||
* land inside the epsilon margin and produce a degenerate slice).
|
||||
*/
|
||||
export function canSplitElementAt(el: TimelineElement, splitTime: number): boolean {
|
||||
return canSplitElement(el) && isSplitTimeWithinBounds(splitTime, el.start, el.duration);
|
||||
}
|
||||
|
||||
/** Elements that the split-all razor action can cut at `splitTime`. */
|
||||
export function selectSplittableElements(
|
||||
elements: TimelineElement[],
|
||||
splitTime: number,
|
||||
): TimelineElement[] {
|
||||
return elements.filter((el) => canSplitElementAt(el, splitTime));
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isAudioTimelineElement, isMusicTrack, resolveBeatSourceTrack } from "./timelineInspector";
|
||||
import type { TimelineElement } from "../player";
|
||||
|
||||
// Minimal element factory for tests
|
||||
function el(
|
||||
overrides: Partial<
|
||||
Pick<TimelineElement, "tag" | "src" | "id" | "domId" | "timelineRole" | "duration">
|
||||
>,
|
||||
): Pick<TimelineElement, "tag" | "src" | "id" | "domId" | "timelineRole" | "duration"> {
|
||||
return {
|
||||
tag: "audio",
|
||||
src: "assets/track.mp3",
|
||||
id: "el-1",
|
||||
domId: "el-1",
|
||||
timelineRole: undefined,
|
||||
duration: 10,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("isAudioTimelineElement", () => {
|
||||
it("is true for audio tag", () => {
|
||||
expect(isAudioTimelineElement(el({ tag: "audio" }))).toBe(true);
|
||||
});
|
||||
|
||||
it("is true for music/sfx/narration semantic tags", () => {
|
||||
expect(isAudioTimelineElement(el({ tag: "music" }))).toBe(true);
|
||||
expect(isAudioTimelineElement(el({ tag: "sfx" }))).toBe(true);
|
||||
expect(isAudioTimelineElement(el({ tag: "narration" }))).toBe(true);
|
||||
});
|
||||
|
||||
it("is true for img/div with an audio src extension", () => {
|
||||
expect(isAudioTimelineElement(el({ tag: "div", src: "assets/bg.mp3" }))).toBe(true);
|
||||
expect(isAudioTimelineElement(el({ tag: "div", src: "assets/fx.wav" }))).toBe(true);
|
||||
});
|
||||
|
||||
it("is false for video and image elements", () => {
|
||||
expect(isAudioTimelineElement(el({ tag: "video", src: "assets/clip.mp4" }))).toBe(false);
|
||||
expect(isAudioTimelineElement(el({ tag: "img", src: "assets/logo.svg" }))).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for null/undefined", () => {
|
||||
expect(isAudioTimelineElement(null)).toBe(false);
|
||||
expect(isAudioTimelineElement(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isMusicTrack", () => {
|
||||
it("is true when timelineRole is 'music'", () => {
|
||||
expect(isMusicTrack(el({ timelineRole: "music" }))).toBe(true);
|
||||
});
|
||||
|
||||
it("is false for explicit non-music roles", () => {
|
||||
expect(isMusicTrack(el({ timelineRole: "sfx" }))).toBe(false);
|
||||
expect(isMusicTrack(el({ timelineRole: "voiceover" }))).toBe(false);
|
||||
});
|
||||
|
||||
it("matches music-like ids when no role is set", () => {
|
||||
expect(isMusicTrack(el({ domId: "bgm", timelineRole: undefined }))).toBe(true);
|
||||
expect(isMusicTrack(el({ domId: "background_music", timelineRole: undefined }))).toBe(true);
|
||||
expect(isMusicTrack(el({ domId: "soundtrack", timelineRole: undefined }))).toBe(true);
|
||||
});
|
||||
|
||||
it("is false for generic ids with no role", () => {
|
||||
expect(isMusicTrack(el({ domId: "my_audio_file", timelineRole: undefined }))).toBe(false);
|
||||
expect(isMusicTrack(el({ domId: "drop_1", timelineRole: undefined }))).toBe(false);
|
||||
});
|
||||
|
||||
it("is false for non-audio elements even with a music-like id", () => {
|
||||
expect(isMusicTrack(el({ tag: "img", src: "assets/logo.svg", domId: "bgm" }))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveBeatSourceTrack", () => {
|
||||
it("returns null when there are no elements", () => {
|
||||
expect(resolveBeatSourceTrack([])).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when there are no audio elements", () => {
|
||||
const elements = [el({ tag: "img", src: "assets/logo.png" })];
|
||||
expect(resolveBeatSourceTrack(elements)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns isFallback=false for an explicitly tagged music track", () => {
|
||||
const music = el({ timelineRole: "music" });
|
||||
const result = resolveBeatSourceTrack([music]);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.isFallback).toBe(false);
|
||||
expect(result!.element).toBe(music);
|
||||
});
|
||||
|
||||
it("prefers the explicit music track over a longer untagged clip", () => {
|
||||
const music = el({ timelineRole: "music", duration: 30 });
|
||||
const other = el({ id: "drop_1", domId: "drop_1", timelineRole: undefined, duration: 120 });
|
||||
const result = resolveBeatSourceTrack([music, other]);
|
||||
expect(result!.element).toBe(music);
|
||||
expect(result!.isFallback).toBe(false);
|
||||
});
|
||||
|
||||
it("falls back to the longest untagged audio clip when no music track exists", () => {
|
||||
const short = el({ id: "drop_1", domId: "drop_1", duration: 5, timelineRole: undefined });
|
||||
const long = el({ id: "drop_2", domId: "drop_2", duration: 60, timelineRole: undefined });
|
||||
const result = resolveBeatSourceTrack([short, long]);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.isFallback).toBe(true);
|
||||
expect(result!.element).toBe(long);
|
||||
});
|
||||
|
||||
it("excludes explicitly non-music roles (sfx, voiceover) from the fallback", () => {
|
||||
const sfx = el({ id: "sfx_1", domId: "sfx_1", timelineRole: "sfx", duration: 30 });
|
||||
const vo = el({ id: "vo_1", domId: "vo_1", timelineRole: "voiceover", duration: 20 });
|
||||
expect(resolveBeatSourceTrack([sfx, vo])).toBeNull();
|
||||
});
|
||||
|
||||
it("returns isFallback=true for an untagged clip with a non-music id", () => {
|
||||
const clip = el({ id: "my_audio_file", domId: "my_audio_file", timelineRole: undefined });
|
||||
const result = resolveBeatSourceTrack([clip]);
|
||||
expect(result!.isFallback).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { TimelineElement } from "../player";
|
||||
|
||||
const AUDIO_TIMELINE_TAGS = new Set(["audio", "music", "sfx", "sound", "narration"]);
|
||||
const AUDIO_SOURCE_EXT_RE = /\.(aac|flac|m4a|mp3|ogg|opus|wav)(?:[?#].*)?$/i;
|
||||
const MUSIC_ID_RE = /\b(music|bgm|soundtrack|background[-_]?music)\b/i;
|
||||
|
||||
export function isAudioTimelineElement(
|
||||
element: Pick<TimelineElement, "tag" | "src"> | null | undefined,
|
||||
): boolean {
|
||||
if (!element) return false;
|
||||
const tag = element.tag.trim().toLowerCase();
|
||||
if (AUDIO_TIMELINE_TAGS.has(tag)) return true;
|
||||
return Boolean(element.src && AUDIO_SOURCE_EXT_RE.test(element.src));
|
||||
}
|
||||
|
||||
/** True for the music track: an audio element with data-timeline-role="music",
|
||||
* or — when no role is set — an id matching the music regex. Voiceover/other
|
||||
* audio (explicit non-music role) is excluded. */
|
||||
export function isMusicTrack(
|
||||
element:
|
||||
| Pick<TimelineElement, "tag" | "src" | "id" | "domId" | "timelineRole">
|
||||
| null
|
||||
| undefined,
|
||||
): boolean {
|
||||
if (!element) return false;
|
||||
if (!isAudioTimelineElement(element)) return false;
|
||||
if (element.timelineRole === "music") return true;
|
||||
if (element.timelineRole && element.timelineRole !== "music") return false;
|
||||
const id = element.domId ?? element.id ?? "";
|
||||
return MUSIC_ID_RE.test(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the best audio source for beat analysis. An explicitly tagged or
|
||||
* named music track wins; when none is present (e.g. an audio file dropped
|
||||
* from Finder with a generic id), the LONGEST untagged audio clip is used as a
|
||||
* fallback. Ties on duration resolve to the FIRST such clip encountered (the loop
|
||||
* keeps the current best on `>` only), i.e. discovery/DOM order wins.
|
||||
* Returns the element and whether it was found via the fallback path.
|
||||
*
|
||||
* The `isMusicTrack` predicate is unchanged so beat-snap and drag-exclusion
|
||||
* logic remain unaffected by this fallback.
|
||||
*/
|
||||
export function resolveBeatSourceTrack(
|
||||
elements: readonly Pick<
|
||||
TimelineElement,
|
||||
"tag" | "src" | "id" | "domId" | "timelineRole" | "duration"
|
||||
>[],
|
||||
): { element: (typeof elements)[number]; isFallback: boolean } | null {
|
||||
const explicit = elements.find(isMusicTrack);
|
||||
if (explicit) return { element: explicit, isFallback: false };
|
||||
|
||||
// Fallback: pick the longest audio clip (skipping explicitly non-music roles
|
||||
// like "sfx" or "voiceover" to avoid triggering beat analysis on those).
|
||||
let best: (typeof elements)[number] | null = null;
|
||||
for (const el of elements) {
|
||||
if (!isAudioTimelineElement(el)) continue;
|
||||
if (el.timelineRole && el.timelineRole !== "music") continue;
|
||||
if (!best || el.duration > best.duration) best = el;
|
||||
}
|
||||
return best ? { element: best, isFallback: true } : null;
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { applyPatchByTarget, applyPatch } from "./sourcePatcher";
|
||||
|
||||
/**
|
||||
* Reproduction tests for https://github.com/heygen-com/hyperframes/issues/958
|
||||
*
|
||||
* The bug: dragging a clip in the Studio timeline rewrites index.html with
|
||||
* inline style="z-index: N" on EVERY clip, overriding the author's CSS z-index.
|
||||
* Additionally, void elements (img, audio) get malformed self-closing tags.
|
||||
*/
|
||||
|
||||
const ISSUE_HTML = `<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body { margin: 0; width: 1920px; height: 1080px; overflow: hidden; background: #000; }
|
||||
#bg-video { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; z-index: 0; }
|
||||
#title { position: absolute; top: 50%; left: 50%; transform: translate(-50%,-50%);
|
||||
font: 700 120px sans-serif; color: #fff; z-index: 20; }
|
||||
</style>
|
||||
|
||||
<div id="root" data-composition-id="main" data-start="0" data-duration="10"
|
||||
data-width="1920" data-height="1080">
|
||||
<video id="bg-video" data-start="0" data-track-index="0" src="some-bg.mp4" muted playsinline></video>
|
||||
<div id="title" class="clip" data-start="0" data-duration="10" data-track-index="1">TITLE</div>
|
||||
</div>`;
|
||||
|
||||
const VOID_ELEMENT_HTML = `<div id="root" data-composition-id="main" data-start="0" data-duration="14"
|
||||
data-width="1920" data-height="1080">
|
||||
<video id="bg-video" data-start="0" data-track-index="0" src="some-bg.mp4" muted playsinline></video>
|
||||
<img id="gif-img" class="clip" data-start="1" data-duration="13" data-track-index="2" src="assets/earth.gif" alt="rotating earth gif" />
|
||||
<div id="title" class="clip" data-start="0" data-duration="10" data-track-index="1">TITLE</div>
|
||||
</div>`;
|
||||
|
||||
describe("issue #958 — timeline drag must not inject inline z-index", () => {
|
||||
it("reproduces the old bug: z-index injection on all clips overrides CSS layering", () => {
|
||||
// Simulate the OLD behavior: buildTrackZIndexMap + loop over all clips
|
||||
function buildTrackZIndexMap(tracks: number[]): Map<number, number> {
|
||||
const uniqueTracks = Array.from(new Set(tracks)).sort((a, b) => a - b);
|
||||
const maxZIndex = uniqueTracks.length;
|
||||
return new Map(uniqueTracks.map((track, index) => [track, maxZIndex - index]));
|
||||
}
|
||||
|
||||
const elements = [
|
||||
{ id: "bg-video", track: 0 },
|
||||
{ id: "title", track: 1 },
|
||||
];
|
||||
const trackZIndices = buildTrackZIndexMap(elements.map((e) => e.track));
|
||||
|
||||
// Apply the old z-index injection loop
|
||||
let broken = ISSUE_HTML;
|
||||
for (const el of elements) {
|
||||
const nextZIndex = trackZIndices.get(el.track);
|
||||
if (nextZIndex == null) continue;
|
||||
broken = applyPatch(broken, el.id, {
|
||||
type: "inline-style",
|
||||
property: "z-index",
|
||||
value: String(nextZIndex),
|
||||
});
|
||||
}
|
||||
|
||||
// Verify the bug: bg-video gets z-index: 2, title gets z-index: 1
|
||||
// This INVERTS the intended layering (CSS had bg-video: 0, title: 20)
|
||||
expect(broken).toContain('id="bg-video"');
|
||||
expect(broken).toContain('style="z-index: 2"');
|
||||
expect(broken).toContain('id="title"');
|
||||
expect(broken).toContain('style="z-index: 1"');
|
||||
|
||||
// The title (z-index: 1) is now BEHIND the video (z-index: 2)
|
||||
// — the opposite of the author's intent (CSS z-index: 20 vs 0)
|
||||
});
|
||||
|
||||
it("verifies the fix: moving a clip only patches data-start and data-track-index", () => {
|
||||
// Simulate the NEW behavior: only patch the moved clip's timing attributes
|
||||
const movedElement = { id: "bg-video" };
|
||||
const updates = { start: "2.5", track: "0" };
|
||||
|
||||
let fixed = applyPatchByTarget(
|
||||
ISSUE_HTML,
|
||||
{ id: movedElement.id },
|
||||
{ type: "attribute", property: "start", value: updates.start },
|
||||
);
|
||||
fixed = applyPatchByTarget(
|
||||
fixed,
|
||||
{ id: movedElement.id },
|
||||
{ type: "attribute", property: "track-index", value: updates.track },
|
||||
);
|
||||
|
||||
// The moved clip's timing changed
|
||||
expect(fixed).toContain('id="bg-video" data-start="2.5" data-track-index="0"');
|
||||
|
||||
// No inline z-index was injected on ANY element
|
||||
expect(fixed).not.toContain('style="z-index');
|
||||
|
||||
// The title clip is completely untouched
|
||||
expect(fixed).toContain(
|
||||
'<div id="title" class="clip" data-start="0" data-duration="10" data-track-index="1">TITLE</div>',
|
||||
);
|
||||
|
||||
// CSS z-index declarations in <style> are preserved
|
||||
expect(fixed).toContain("z-index: 0;");
|
||||
expect(fixed).toContain("z-index: 20;");
|
||||
});
|
||||
|
||||
it("verifies the fix: deleting a clip does not inject z-index on remaining clips", () => {
|
||||
// After the element removal API call returns the content without bg-video,
|
||||
// the old code would loop over remaining clips and inject z-index.
|
||||
// The new code just uses the removal result as-is.
|
||||
const afterRemoval = ISSUE_HTML.replace(/<video id="bg-video"[^>]*><\/video>\n /, "");
|
||||
|
||||
// No z-index injection step — the result is used directly
|
||||
expect(afterRemoval).not.toContain('style="z-index');
|
||||
expect(afterRemoval).toContain(
|
||||
'<div id="title" class="clip" data-start="0" data-duration="10" data-track-index="1">TITLE</div>',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("issue #958 — void element inline style injection", () => {
|
||||
it("reproduces the old bug: self-closing tags get malformed when style is injected", () => {
|
||||
// The old code did: tag.replace(/>$/, "") + ` style="z-index: 7"`
|
||||
// But `tag` from the regex capture never includes `>`, and for self-closing
|
||||
// elements it ends with `/`. The replace was a no-op, producing:
|
||||
// <img ... / style="z-index: 7">
|
||||
const img = `<img id="gif-img" class="clip" data-start="1" src="earth.gif" alt="earth" />`;
|
||||
const result = applyPatch(img, "gif-img", {
|
||||
type: "inline-style",
|
||||
property: "z-index",
|
||||
value: "7",
|
||||
});
|
||||
|
||||
// With the fix, the style is inserted before the self-closing slash
|
||||
expect(result).not.toContain("/ style");
|
||||
expect(result).toContain('style="z-index: 7" />');
|
||||
});
|
||||
|
||||
it("verifies inline style on void elements in a full composition", () => {
|
||||
// Even though we no longer inject z-index during move/delete,
|
||||
// the patchInlineStyleInTag fix is still important for other inline style
|
||||
// patches (e.g., position, opacity) that the Studio applies to void elements.
|
||||
const result = applyPatch(VOID_ELEMENT_HTML, "gif-img", {
|
||||
type: "inline-style",
|
||||
property: "opacity",
|
||||
value: "0.8",
|
||||
});
|
||||
|
||||
expect(result).toContain('style="opacity: 0.8" />');
|
||||
expect(result).not.toContain("/ style");
|
||||
// Other elements untouched
|
||||
expect(result).toContain(
|
||||
'<video id="bg-video" data-start="0" data-track-index="0" src="some-bg.mp4" muted playsinline></video>',
|
||||
);
|
||||
expect(result).toContain(
|
||||
'<div id="title" class="clip" data-start="0" data-duration="10" data-track-index="1">TITLE</div>',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { fitEasesFromVelocity, type FittedKeyframe } from "./velocityEaseFitter";
|
||||
|
||||
function makeSamples(
|
||||
count: number,
|
||||
duration: number,
|
||||
velocityFn: (t: number) => number,
|
||||
): { time: number; properties: Record<string, number> }[] {
|
||||
const samples = [];
|
||||
let pos = 0;
|
||||
for (let i = 0; i <= count; i++) {
|
||||
const t = (i / count) * duration;
|
||||
const v = velocityFn(t / duration);
|
||||
pos += v * (duration / count);
|
||||
samples.push({ time: t, properties: { x: pos } });
|
||||
}
|
||||
return samples;
|
||||
}
|
||||
|
||||
describe("fitEasesFromVelocity", () => {
|
||||
it("constant speed → no ease assigned", () => {
|
||||
const kfs: FittedKeyframe[] = [
|
||||
{ percentage: 0, properties: { x: 0 } },
|
||||
{ percentage: 100, properties: { x: 100 } },
|
||||
];
|
||||
const samples = makeSamples(60, 1, () => 100);
|
||||
const result = fitEasesFromVelocity(kfs, samples, 1);
|
||||
expect(result[1].ease).toBeUndefined();
|
||||
});
|
||||
|
||||
it("decelerate at end → AE Easy Ease In (slow-end curve)", () => {
|
||||
const kfs: FittedKeyframe[] = [
|
||||
{ percentage: 0, properties: { x: 0 } },
|
||||
{ percentage: 100, properties: { x: 100 } },
|
||||
];
|
||||
// Start fast, end slow → playback must also be slow at the end (CP2 y=1).
|
||||
const samples = makeSamples(60, 1, (t) => Math.max(0, 200 * (1 - t)));
|
||||
const result = fitEasesFromVelocity(kfs, samples, 1);
|
||||
expect(result[1].ease).toBe("custom(M0,0 C0.333,0.333 0.667,1 1,1)");
|
||||
});
|
||||
|
||||
it("accelerate from start → AE Easy Ease Out (slow-start curve)", () => {
|
||||
const kfs: FittedKeyframe[] = [
|
||||
{ percentage: 0, properties: { x: 0 } },
|
||||
{ percentage: 100, properties: { x: 100 } },
|
||||
];
|
||||
// Start slow, end fast → playback must also be slow at the start (CP1 y=0).
|
||||
const samples = makeSamples(60, 1, (t) => 200 * t);
|
||||
const result = fitEasesFromVelocity(kfs, samples, 1);
|
||||
expect(result[1].ease).toBe("custom(M0,0 C0.333,0 0.667,0.667 1,1)");
|
||||
});
|
||||
|
||||
it("single keyframe → returns unchanged", () => {
|
||||
const kfs: FittedKeyframe[] = [{ percentage: 0, properties: { x: 0 } }];
|
||||
const result = fitEasesFromVelocity(kfs, [], 1);
|
||||
expect(result).toEqual(kfs);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
interface TimedSample {
|
||||
time: number;
|
||||
value: number;
|
||||
}
|
||||
|
||||
// After Effects convention (ease named by the keyframe side it acts on):
|
||||
// Easy Ease — slow at both ends (cubic-bezier 0.333,0 0.667,1)
|
||||
// Easy Ease In — eases *into* the keyframe → decelerates → slow at the END
|
||||
// Easy Ease Out — eases *out of* the keyframe → accelerates → slow at the START
|
||||
// The control-point y values must match that polarity (a flat tangent at the
|
||||
// slow side): slow-end pins CP2 at y=1, slow-start pins CP1 at y=0.
|
||||
const AE_EASE = "custom(M0,0 C0.333,0 0.667,1 1,1)";
|
||||
const AE_EASE_IN = "custom(M0,0 C0.333,0.333 0.667,1 1,1)";
|
||||
const AE_EASE_OUT = "custom(M0,0 C0.333,0 0.667,0.667 1,1)";
|
||||
const VELOCITY_THRESHOLD = 0.3;
|
||||
|
||||
function averageSpeed(samples: TimedSample[], from: number, to: number): number {
|
||||
const seg = samples.filter((s) => s.time >= from && s.time <= to);
|
||||
if (seg.length < 2) return 0;
|
||||
let total = 0;
|
||||
for (let i = 1; i < seg.length; i++) {
|
||||
const dt = seg[i].time - seg[i - 1].time;
|
||||
if (dt > 0) total += Math.abs(seg[i].value - seg[i - 1].value) / dt;
|
||||
}
|
||||
return total / (seg.length - 1);
|
||||
}
|
||||
|
||||
function speedAtEdge(
|
||||
samples: TimedSample[],
|
||||
t: number,
|
||||
window: number,
|
||||
side: "start" | "end",
|
||||
): number {
|
||||
const near = samples.filter((s) =>
|
||||
side === "start" ? s.time >= t && s.time <= t + window : s.time >= t - window && s.time <= t,
|
||||
);
|
||||
if (near.length < 2) return 0;
|
||||
let total = 0;
|
||||
for (let i = 1; i < near.length; i++) {
|
||||
const dt = near[i].time - near[i - 1].time;
|
||||
if (dt > 0) total += Math.abs(near[i].value - near[i - 1].value) / dt;
|
||||
}
|
||||
return total / (near.length - 1);
|
||||
}
|
||||
|
||||
export interface FittedKeyframe {
|
||||
percentage: number;
|
||||
properties: Record<string, number | string>;
|
||||
ease?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze velocity profile of raw samples between keyframes and assign
|
||||
* per-keyframe eases based on deceleration/acceleration patterns.
|
||||
*
|
||||
* For each segment between consecutive keyframes:
|
||||
* - Constant speed → linear ("none")
|
||||
* - Decelerates at end → Easy Ease In
|
||||
* - Accelerates from start → Easy Ease Out
|
||||
* - Both → Easy Ease (full)
|
||||
*/
|
||||
// fallow-ignore-next-line complexity
|
||||
export function fitEasesFromVelocity(
|
||||
keyframes: FittedKeyframe[],
|
||||
rawSamples: { time: number; properties: Record<string, number> }[],
|
||||
totalDuration: number,
|
||||
): FittedKeyframe[] {
|
||||
if (keyframes.length < 2 || rawSamples.length < 3) return keyframes;
|
||||
|
||||
const result = [...keyframes.map((kf) => ({ ...kf }))];
|
||||
|
||||
for (let i = 1; i < result.length; i++) {
|
||||
const prevPct = result[i - 1].percentage;
|
||||
const currPct = result[i].percentage;
|
||||
const segStart = (prevPct / 100) * totalDuration;
|
||||
const segEnd = (currPct / 100) * totalDuration;
|
||||
const segDur = segEnd - segStart;
|
||||
if (segDur <= 0) continue;
|
||||
|
||||
// Use the dominant property (largest range) for velocity analysis
|
||||
const props = Object.keys(result[i].properties);
|
||||
let bestProp = props[0] ?? "x";
|
||||
let bestRange = 0;
|
||||
for (const p of props) {
|
||||
const startVal = Number(result[i - 1].properties[p] ?? 0);
|
||||
const endVal = Number(result[i].properties[p] ?? 0);
|
||||
const range = Math.abs(endVal - startVal);
|
||||
if (range > bestRange) {
|
||||
bestRange = range;
|
||||
bestProp = p;
|
||||
}
|
||||
}
|
||||
|
||||
const propSamples: TimedSample[] = rawSamples
|
||||
.filter((s) => s.time >= segStart && s.time <= segEnd)
|
||||
.map((s) => ({ time: s.time, value: s.properties[bestProp] ?? 0 }));
|
||||
|
||||
if (propSamples.length < 3) continue;
|
||||
|
||||
const edgeWindow = segDur * 0.25;
|
||||
const avgSpd = averageSpeed(propSamples, segStart, segEnd);
|
||||
if (avgSpd < 1e-6) continue;
|
||||
|
||||
const startSpd = speedAtEdge(propSamples, segStart, edgeWindow, "start");
|
||||
const endSpd = speedAtEdge(propSamples, segEnd, edgeWindow, "end");
|
||||
|
||||
const slowStart = startSpd / avgSpd < VELOCITY_THRESHOLD;
|
||||
const slowEnd = endSpd / avgSpd < VELOCITY_THRESHOLD;
|
||||
|
||||
if (slowStart && slowEnd) {
|
||||
result[i].ease = AE_EASE;
|
||||
} else if (slowEnd) {
|
||||
result[i].ease = AE_EASE_IN;
|
||||
} else if (slowStart) {
|
||||
result[i].ease = AE_EASE_OUT;
|
||||
}
|
||||
// Otherwise leave ease undefined → linear (constant speed)
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
Reference in New Issue
Block a user