mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-08 10:46:06 +00:00
* feat(studio): add pasteboard background to preview viewport Adds bg-neutral-800 to the preview viewport so the area outside the canvas is visually distinct from the composition content — consistent with professional video editors (Premiere, DaVinci, Figma). * feat(studio): pasteboard background and canvas outline around preview - NLEPreview: viewport gets bg-neutral-700 (#404040) as the pasteboard color surrounding the canvas — distinct from the app chrome (#0a0a0a) - Player wrapper: drop bg-black so the pasteboard shows around the canvas (loading overlays still cover the area with bg-black during load) - Player: set host background to transparent via inline style (overrides :host { background: #000 } in shadow DOM), and inject a style rule into the open shadow root so .hfp-container has overflow:visible and the canvas iframe gets a thin white ring + soft drop-shadow — making the canvas boundary legible against the pasteboard * feat(studio): disable manual positioning JSON by default, add toggle Manual edits were always stored in `.hyperframes/studio-manual-edits.json`, making it hard to share source without the sidecar file and easy to accidentally reposition elements via drag. Changes: - `enabled` field added to `StudioManualEditManifest` (defaults to `false` when absent — existing projects are unaffected until they opt in) - Drag handles, resize, and rotation handles are hidden when disabled - Layout X/Y/W/H/R fields in the Design panel are read-only when disabled - "Manual positioning" toggle added at the bottom of the Design panel, visible whether or not an element is selected - Toggle state is persisted to `.hyperframes/studio-manual-edits.json` so each project can opt in independently - `STUDIO_PREVIEW_MANUAL_EDITING_ENABLED` env flag still acts as a hard cap (env off → feature off regardless of project setting) * feat(studio): enable manual positioning by default (opt-out) * feat(studio): allow absolute elements to drag without toggle; gate JSON-backed drag behind toggle * feat(studio): persist positions directly to HTML; remove JSON sidecar and manual positioning toggle Replace the `.hyperframes/studio-manual-edits.json` sidecar with inline-style persistence baked directly into the HTML source. Drag/resize/rotation values are written as CSS custom properties (`--hf-studio-offset-x/y`, `--hf-studio-width/height`, `--hf-studio-rotation`) plus `translate`/`width`/`height`/`rotate` inline styles via `persistDomEditOperations` — no re-apply step needed on load. Key changes: - `sourcePatcher`: add `value: string | null` to `PatchOperation` — null removes the property/attribute from the HTML tag instead of setting it - `manualEditsDom`: add `build*Patches` / `buildClear*Patches` helpers that capture live element state into `PatchOperation[]` for HTML source writes; add `reapplyPositionEditsAfterSeek` (DOM-query-based seek hook, queries data-attribute markers) - `manualEdits.ts`: remove `applyStudioManualEditManifest` and all manifest target resolution; export `reapplyPositionEditsAfterSeek`; keep seek/play wrap infrastructure - `useManifestPersistence`: remove all JSON I/O — no disk read on load, no manifest state, no toggle state; `applyCurrentStudioManualEditsToPreview` now only installs seek hooks via `reapplyPositionEditsAfterSeek` - `useDomEditCommits`: replace `commitStudioManualEditManifestOptimistically` calls with direct DOM apply + `commitPositionPatchToHtml` (queued HTML patch write, skipRefresh) - `DomEditOverlay`: remove `manualEditsEnabled` prop; revert all `canMove || manualEditsEnabled` gates to just `canApplyManualOffset` — every draggable element is always draggable - `PropertyPanel`: remove `ManualPositioningToggle` component and all toggle props - `manualEditsParsing/manualEditsTypes`: remove manifest types, upsert functions, and `STUDIO_MANUAL_EDITS_PATH`; keep `finiteNumber`, `readStudioFileChangePath`, `roundRotationAngle`, and snapshot/CSS-property types * fix(studio): sync keyboard shortcut handler with main; fix keepPlaying seek assertions in test * fix(studio): strip GSAP-cached translate from transform on path offset apply * fix(studio): remove Reset edits button from design panel * feat(studio): wire reloadPreview into manifest persistence; drop stale group-selection refresh - Pass `reloadPreview` into `useManifestPersistence` so undo/redo reloads via the refresh-key path instead of directly touching the iframe. - Remove `refreshDomEditGroupSelectionsFromPreview` from commit handlers; HTML is now the source of truth so no stale-ref refresh is needed. - Add `manualEditsRenderScript` helper; export via studio-api and apply it in `htmlCompiler` during HTML compilation. * fix(studio): prevent root composition from being selected; correct overlay drift on resize - Guard `getDomLayerPatchTarget` against elements with `data-composition-id` so the root composition div is never returned as a visual selection target. - Apply the same guard to the raw `elementFromPoint` fallback in `getPreviewTargetFromPointer`, which was the actual escape path. - Thread `iframeRef` into gesture handler opts; after applying draft dimensions during resize, re-read the element BCR via `toOverlayRect` and update the overlay box position to compensate for visual drift on elements with centered transform-origin (e.g. GSAP scale tweens). * fix(studio): correct resize overlay for scaled elements; block invisible element selection - Resize: use BCR from `toOverlayRect` for both position and size after applying draft dimensions — GSAP scale makes visual size diverge from raw CSS size, BCR is the only accurate source during a gesture. - Click selection: add `isElementComputedVisible` guard to the `elementFromPoint` fallback so opacity-0 / autoAlpha-hidden elements cannot be selected even though the browser hit-test returns them. * fix(studio): reload preview on external file changes via SSE/HMR Share the app-level domEditSaveTimestampRef with useManifestPersistence so the SSE/HMR handler can suppress echoes from all studio saves (code tab, timeline, DOM edits), then call reloadPreview() for non-motion external changes that aren't echoes of our own saves. * fix(studio): suppress post-resize click to keep selection on resized element * fix(studio): serve registry blocks without index.html in preview Blocks ship as {id}.html + assets/ with no index.html. The preview route hard-coded index.html so these projects returned 404 and their assets (e.g. korea-map.png, map-nyc-paris.png) were never served. Add resolveProjectMainHtml() that falls back to {id}.html, thread the resolved compositionPath through transformPreviewHtml and injectStudioPreviewAugmentations, and update listProjects() in the vite adapter to surface block directories in the project list. * fix(render): preserve studio drag/resize/rotation offsets in rendered video Three issues caused studio-edited positions to be lost during rendering: 1. The seek-reapply script used setInterval to wrap window.__hf.seek, but Puppeteer's page.evaluate() calls don't yield the event loop for macrotasks — the interval never fired, so reapplyAll() never ran after GSAP seeks. Fix: use Object.defineProperty to trap writes to the seek property, wrapping it synchronously the instant the bridge assigns it. 2. MEDIA_VISUAL_STYLE_PROPERTIES (copied from <video> to proxy <img> during render) included "transform" but not "translate", "rotate", or "scale" — the CSS Transforms Level 2 individual properties used by studio drag/resize/rotation. The proxy was positioned at offsetLeft/ offsetTop without the translate offset. 3. getViewportMatrix (HDR compositor) only read cs.transform, missing individual transform properties entirely. Added composeIndividualTransforms to build the translate × rotate × scale matrix and compose it before the legacy transform matrix. * fix(studio): select elements with pointer-events: none in preview Compositions often set pointer-events: none on scenes, avatar wrappers, and decorative layers. elementsFromPoint() skips these elements entirely, making them unselectable in the Studio. Fix: temporarily inject a * { pointer-events: auto !important } stylesheet during hit-testing, then remove it immediately after. Also adds a pointer_events_none lint rule (info severity, visible with --verbose) so authors know which selectors may affect Studio selection.
519 lines
19 KiB
TypeScript
519 lines
19 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
import { Window } from "happy-dom";
|
|
import {
|
|
STUDIO_OFFSET_X_PROP,
|
|
STUDIO_OFFSET_Y_PROP,
|
|
STUDIO_ROTATION_PROP,
|
|
STUDIO_WIDTH_PROP,
|
|
applyStudioBoxSize,
|
|
applyStudioBoxSizeDraft,
|
|
applyStudioPathOffset,
|
|
applyStudioPathOffsetDraft,
|
|
applyStudioRotation,
|
|
applyStudioRotationDraft,
|
|
beginStudioManualEditGesture,
|
|
captureStudioBoxSize,
|
|
captureStudioRotation,
|
|
clearStudioBoxSize,
|
|
clearStudioPathOffset,
|
|
clearStudioRotation,
|
|
endStudioManualEditGesture,
|
|
installStudioManualEditSeekReapply,
|
|
readStudioBoxSize,
|
|
readStudioFileChangePath,
|
|
readStudioPathOffset,
|
|
readStudioRotation,
|
|
restoreStudioBoxSize,
|
|
restoreStudioRotation,
|
|
} from "./manualEdits";
|
|
|
|
function createDocument(markup: string): Document {
|
|
const window = new Window();
|
|
window.document.body.innerHTML = markup;
|
|
return window.document;
|
|
}
|
|
|
|
function mockBoundingRect(element: HTMLElement, width: number, height: number): void {
|
|
element.getBoundingClientRect = () =>
|
|
({
|
|
x: 0,
|
|
y: 0,
|
|
left: 0,
|
|
top: 0,
|
|
right: width,
|
|
bottom: height,
|
|
width,
|
|
height,
|
|
toJSON: () => ({}),
|
|
}) as DOMRect;
|
|
}
|
|
|
|
function mockComputedStyle(element: HTMLElement, values: Record<string, string>): void {
|
|
const win = element.ownerDocument.defaultView;
|
|
if (!win) throw new Error("defaultView fixture missing");
|
|
win.getComputedStyle = ((target: Element) =>
|
|
({
|
|
getPropertyValue: (property: string) => (target === element ? (values[property] ?? "") : ""),
|
|
}) as CSSStyleDeclaration) as typeof win.getComputedStyle;
|
|
}
|
|
|
|
describe("studio manual edits", () => {
|
|
it("recognizes studio file-change payloads", () => {
|
|
expect(readStudioFileChangePath({ path: ".hyperframes/studio-manual-edits.json" })).toBe(
|
|
".hyperframes/studio-manual-edits.json",
|
|
);
|
|
expect(readStudioFileChangePath({ data: '{"path":"nested/file.html"}' })).toBe(
|
|
"nested/file.html",
|
|
);
|
|
});
|
|
|
|
it("applies offsets through CSS translate longhand", () => {
|
|
const document = createDocument(`<div id="card"></div>`);
|
|
const card = document.getElementById("card") as HTMLElement;
|
|
|
|
applyStudioPathOffset(card, { x: 14, y: -8 });
|
|
|
|
expect(readStudioPathOffset(card)).toEqual({ x: 14, y: -8 });
|
|
expect(card.style.getPropertyValue(STUDIO_OFFSET_X_PROP)).toBe("14px");
|
|
expect(card.style.getPropertyValue(STUDIO_OFFSET_Y_PROP)).toBe("-8px");
|
|
expect(card.style.getPropertyValue("translate")).toContain(STUDIO_OFFSET_X_PROP);
|
|
});
|
|
|
|
it("preserves authored inline translate as the additive path offset base", () => {
|
|
const document = createDocument(`<div id="card" style="translate: 10px 20px"></div>`);
|
|
const card = document.getElementById("card") as HTMLElement;
|
|
|
|
applyStudioPathOffset(card, { x: 14, y: -8 });
|
|
|
|
expect(card.style.getPropertyValue("translate")).toContain("calc(10px +");
|
|
expect(card.style.getPropertyValue("translate")).toContain("calc(20px +");
|
|
expect(card.style.getPropertyValue("translate")).toContain(STUDIO_OFFSET_X_PROP);
|
|
expect(card.style.getPropertyValue("translate")).toContain(STUDIO_OFFSET_Y_PROP);
|
|
});
|
|
|
|
it("preserves stylesheet-authored transform longhands as additive bases", () => {
|
|
const document = createDocument(`<div id="card"></div>`);
|
|
const card = document.getElementById("card") as HTMLElement;
|
|
mockComputedStyle(card, {
|
|
translate: "10px 20px",
|
|
rotate: "8deg",
|
|
});
|
|
|
|
applyStudioPathOffset(card, { x: 14, y: -8 });
|
|
applyStudioRotation(card, { angle: 12 });
|
|
|
|
expect(card.style.getPropertyValue("translate")).toContain("calc(10px +");
|
|
expect(card.style.getPropertyValue("translate")).toContain("calc(20px +");
|
|
expect(card.style.getPropertyValue("translate")).toContain(STUDIO_OFFSET_X_PROP);
|
|
expect(card.style.getPropertyValue("rotate")).toContain("8deg");
|
|
expect(card.style.getPropertyValue("rotate")).toContain(STUDIO_ROTATION_PROP);
|
|
});
|
|
|
|
it("clears computed transform bases without freezing them inline", () => {
|
|
const document = createDocument(`<div id="card"></div>`);
|
|
const card = document.getElementById("card") as HTMLElement;
|
|
mockComputedStyle(card, {
|
|
translate: "10px 20px",
|
|
rotate: "8deg",
|
|
});
|
|
|
|
applyStudioPathOffset(card, { x: 14, y: -8 });
|
|
applyStudioRotation(card, { angle: 12 });
|
|
|
|
clearStudioPathOffset(card);
|
|
clearStudioRotation(card);
|
|
|
|
expect(card.style.getPropertyValue("translate")).toBe("");
|
|
expect(card.style.getPropertyValue("rotate")).toBe("");
|
|
});
|
|
|
|
it("does not compound stale studio variables as authored transform bases", () => {
|
|
const document = createDocument(`<div id="card"></div>`);
|
|
const card = document.getElementById("card") as HTMLElement;
|
|
|
|
card.style.setProperty(
|
|
"translate",
|
|
`var(${STUDIO_OFFSET_X_PROP}, 0px) var(${STUDIO_OFFSET_Y_PROP}, 0px)`,
|
|
);
|
|
card.style.setProperty("rotate", `var(${STUDIO_ROTATION_PROP}, 0deg)`);
|
|
|
|
applyStudioPathOffset(card, { x: 14, y: -8 });
|
|
applyStudioRotation(card, { angle: 12 });
|
|
|
|
expect(card.style.getPropertyValue("translate")).toBe(
|
|
`var(${STUDIO_OFFSET_X_PROP}, 0px) var(${STUDIO_OFFSET_Y_PROP}, 0px)`,
|
|
);
|
|
expect(card.style.getPropertyValue("rotate")).toBe(`var(${STUDIO_ROTATION_PROP}, 0deg)`);
|
|
});
|
|
|
|
it("applies box sizes through CSS dimensions and flex sizing overrides", () => {
|
|
const document = createDocument(`
|
|
<div style="display: flex; flex-direction: row">
|
|
<div id="card" style="width: 160px; height: 90px"></div>
|
|
</div>
|
|
`);
|
|
const card = document.getElementById("card") as HTMLElement;
|
|
mockBoundingRect(card, 160, 90);
|
|
|
|
applyStudioBoxSize(card, { width: 240, height: 135 });
|
|
|
|
expect(readStudioBoxSize(card)).toEqual({ width: 240, height: 135 });
|
|
expect(card.style.getPropertyValue(STUDIO_WIDTH_PROP)).toBe("240px");
|
|
expect(card.style.getPropertyValue("width")).toBe("240px");
|
|
expect(card.style.getPropertyValue("height")).toBe("135px");
|
|
expect(card.style.getPropertyValue("flex-basis")).toBe("240px");
|
|
expect(card.style.getPropertyValue("flex-grow")).toBe("0");
|
|
expect(card.style.getPropertyValue("flex-shrink")).toBe("0");
|
|
expect(card.style.getPropertyValue("box-sizing")).toBe("border-box");
|
|
expect(card.style.getPropertyValue("scale")).toBe("");
|
|
|
|
applyStudioBoxSizeDraft(card, { width: 260, height: 150 });
|
|
expect(readStudioBoxSize(card)).toEqual({ width: 260, height: 150 });
|
|
expect(card.style.getPropertyValue("width")).toBe("260px");
|
|
expect(card.style.getPropertyValue("height")).toBe("150px");
|
|
expect(card.style.getPropertyValue("flex-basis")).toBe("260px");
|
|
|
|
const snapshot = captureStudioBoxSize(card);
|
|
applyStudioBoxSizeDraft(card, { width: 280, height: 160 });
|
|
restoreStudioBoxSize(card, snapshot);
|
|
expect(readStudioBoxSize(card)).toEqual({ width: 260, height: 150 });
|
|
expect(card.style.getPropertyValue("width")).toBe("260px");
|
|
expect(card.style.getPropertyValue("height")).toBe("150px");
|
|
expect(card.style.getPropertyValue("flex-basis")).toBe("260px");
|
|
});
|
|
|
|
it("applies rotations through CSS rotate longhand around the element center", () => {
|
|
const document = createDocument(
|
|
`<div id="card" style="rotate: 8deg; transform-origin: left top"></div>`,
|
|
);
|
|
const card = document.getElementById("card") as HTMLElement;
|
|
|
|
applyStudioRotation(card, { angle: 24.24 });
|
|
|
|
expect(readStudioRotation(card)).toEqual({ angle: 24.2 });
|
|
expect(card.style.getPropertyValue(STUDIO_ROTATION_PROP)).toBe("24.2deg");
|
|
expect(card.style.getPropertyValue("rotate")).toContain("8deg");
|
|
expect(card.style.getPropertyValue("rotate")).toContain(STUDIO_ROTATION_PROP);
|
|
expect(card.style.getPropertyValue("transform-origin")).toBe("center center");
|
|
|
|
applyStudioRotationDraft(card, { angle: -12.26 });
|
|
expect(readStudioRotation(card)).toEqual({ angle: -12.3 });
|
|
expect(card.style.getPropertyValue("rotate")).toBe("calc(8deg + -12.3deg)");
|
|
expect(card.style.getPropertyValue("transform-origin")).toBe("center center");
|
|
|
|
const snapshot = captureStudioRotation(card);
|
|
applyStudioRotationDraft(card, { angle: 45 });
|
|
restoreStudioRotation(card, snapshot);
|
|
expect(readStudioRotation(card)).toEqual({ angle: -12.3 });
|
|
expect(card.style.getPropertyValue("rotate")).toBe("calc(8deg + -12.3deg)");
|
|
expect(card.style.getPropertyValue("transform-origin")).toBe("center center");
|
|
});
|
|
|
|
it("uses height for flex-basis inside column flex containers", () => {
|
|
const document = createDocument(`
|
|
<div style="display: flex; flex-direction: column">
|
|
<div id="card" style="width: 160px; height: 90px"></div>
|
|
</div>
|
|
`);
|
|
const card = document.getElementById("card") as HTMLElement;
|
|
|
|
applyStudioBoxSize(card, { width: 240, height: 135 });
|
|
|
|
expect(card.style.getPropertyValue("width")).toBe("240px");
|
|
expect(card.style.getPropertyValue("height")).toBe("135px");
|
|
expect(card.style.getPropertyValue("flex-basis")).toBe("135px");
|
|
});
|
|
|
|
it("uses additive CSS translate without mutating GSAP tweens during path-offset moves", () => {
|
|
const document = createDocument(`<div id="card"></div>`);
|
|
const card = document.getElementById("card") as HTMLElement;
|
|
const getTweensOf = vi.fn();
|
|
const getProperty = vi.fn();
|
|
const set = vi.fn();
|
|
const tickerTick = vi.fn();
|
|
const tween = {
|
|
vars: { x: 0, y: 10, startAt: { x: -240, y: -20 } },
|
|
targets: () => [card],
|
|
invalidate: vi.fn(),
|
|
parent: {
|
|
time: () => 1.25,
|
|
totalTime: vi.fn(),
|
|
invalidate: vi.fn(),
|
|
},
|
|
_startAt: {
|
|
vars: { x: -240, y: -20 },
|
|
invalidate: vi.fn(),
|
|
},
|
|
};
|
|
|
|
(
|
|
document.defaultView as unknown as {
|
|
gsap: {
|
|
getTweensOf: () => Array<typeof tween>;
|
|
getProperty: (_target: Element, property: string) => unknown;
|
|
set: (_target: Element, vars: Record<string, unknown>) => void;
|
|
ticker: { tick: () => void };
|
|
};
|
|
}
|
|
).gsap = {
|
|
getTweensOf,
|
|
getProperty,
|
|
set,
|
|
ticker: { tick: tickerTick },
|
|
};
|
|
|
|
applyStudioPathOffset(card, { x: 30, y: -12 });
|
|
|
|
expect(tween.vars).toMatchObject({
|
|
x: 0,
|
|
y: 10,
|
|
startAt: { x: -240, y: -20 },
|
|
});
|
|
expect(tween._startAt.vars).toEqual({ x: -240, y: -20 });
|
|
expect(readStudioPathOffset(card)).toEqual({ x: 30, y: -12 });
|
|
expect(card.style.getPropertyValue("translate")).toContain(STUDIO_OFFSET_X_PROP);
|
|
expect(getTweensOf).not.toHaveBeenCalled();
|
|
expect(getProperty).not.toHaveBeenCalled();
|
|
expect(set).not.toHaveBeenCalled();
|
|
expect(tickerTick).not.toHaveBeenCalled();
|
|
|
|
beginStudioManualEditGesture(card);
|
|
applyStudioPathOffsetDraft(card, { x: 35, y: -6 });
|
|
|
|
expect(readStudioPathOffset(card)).toEqual({ x: 35, y: -6 });
|
|
expect(card.style.getPropertyValue("translate")).toBe("35px -6px");
|
|
expect(tween.vars).toMatchObject({
|
|
x: 0,
|
|
y: 10,
|
|
startAt: { x: -240, y: -20 },
|
|
});
|
|
expect(tween._startAt.vars).toEqual({ x: -240, y: -20 });
|
|
expect(tickerTick).not.toHaveBeenCalled();
|
|
|
|
applyStudioPathOffset(card, { x: 35, y: -6 });
|
|
endStudioManualEditGesture(card);
|
|
|
|
expect(tween.vars).toMatchObject({
|
|
x: 0,
|
|
y: 10,
|
|
startAt: { x: -240, y: -20 },
|
|
});
|
|
expect(tween._startAt.vars).toEqual({ x: -240, y: -20 });
|
|
expect(card.style.getPropertyValue("translate")).toContain(STUDIO_OFFSET_X_PROP);
|
|
|
|
clearStudioPathOffset(card);
|
|
expect(tween.vars).toMatchObject({
|
|
x: 0,
|
|
y: 10,
|
|
startAt: { x: -240, y: -20 },
|
|
});
|
|
expect(tween._startAt.vars).toEqual({ x: -240, y: -20 });
|
|
expect(card.style.getPropertyValue(STUDIO_OFFSET_X_PROP)).toBe("");
|
|
expect(card.style.getPropertyValue(STUDIO_OFFSET_Y_PROP)).toBe("");
|
|
expect(card.style.getPropertyValue("translate")).toBe("");
|
|
});
|
|
|
|
it("clears path offsets and restores authored inline translate", () => {
|
|
const document = createDocument(`<div id="card" style="translate: 10px 20px"></div>`);
|
|
const card = document.getElementById("card") as HTMLElement;
|
|
|
|
applyStudioPathOffset(card, { x: 24, y: 12 });
|
|
expect(card.style.getPropertyValue("translate")).toContain(STUDIO_OFFSET_X_PROP);
|
|
|
|
clearStudioPathOffset(card);
|
|
|
|
expect(card.style.getPropertyValue("translate")).toBe("10px 20px");
|
|
});
|
|
|
|
it("clears stale offsets applied directly to the DOM", () => {
|
|
const document = createDocument(`<div id="card"></div>`);
|
|
const card = document.getElementById("card") as HTMLElement;
|
|
|
|
applyStudioPathOffset(card, { x: 24, y: 12 });
|
|
expect(readStudioPathOffset(card)).toEqual({ x: 24, y: 12 });
|
|
|
|
clearStudioPathOffset(card);
|
|
|
|
expect(readStudioPathOffset(card)).toEqual({ x: 0, y: 0 });
|
|
expect(card.style.getPropertyValue(STUDIO_OFFSET_X_PROP)).toBe("");
|
|
expect(card.style.getPropertyValue(STUDIO_OFFSET_Y_PROP)).toBe("");
|
|
expect(card.style.getPropertyValue("translate")).toBe("");
|
|
});
|
|
|
|
it("clears box sizes and restores authored inline size", () => {
|
|
const document = createDocument(`
|
|
<div style="display: flex; flex-direction: row">
|
|
<div id="card" style="width: 160px; height: 90px"></div>
|
|
</div>
|
|
`);
|
|
const card = document.getElementById("card") as HTMLElement;
|
|
mockBoundingRect(card, 160, 90);
|
|
|
|
applyStudioBoxSize(card, { width: 320, height: 180 });
|
|
expect(readStudioBoxSize(card)).toEqual({ width: 320, height: 180 });
|
|
expect(card.style.getPropertyValue("width")).toBe("320px");
|
|
expect(card.style.getPropertyValue("flex-basis")).toBe("320px");
|
|
|
|
clearStudioBoxSize(card);
|
|
expect(readStudioBoxSize(card)).toEqual({ width: 0, height: 0 });
|
|
expect(card.style.getPropertyValue("width")).toBe("160px");
|
|
expect(card.style.getPropertyValue("height")).toBe("90px");
|
|
expect(card.style.getPropertyValue("flex-basis")).toBe("");
|
|
expect(card.style.getPropertyValue("flex-grow")).toBe("");
|
|
expect(card.style.getPropertyValue("flex-shrink")).toBe("");
|
|
expect(card.style.getPropertyValue("scale")).toBe("");
|
|
});
|
|
|
|
it("clears rotations and restores authored inline rotation", () => {
|
|
const document = createDocument(
|
|
`<div id="card" style="rotate: 8deg; transform-origin: left top"></div>`,
|
|
);
|
|
const card = document.getElementById("card") as HTMLElement;
|
|
|
|
applyStudioRotation(card, { angle: 37.5 });
|
|
expect(readStudioRotation(card)).toEqual({ angle: 37.5 });
|
|
expect(card.style.getPropertyValue("rotate")).toContain(STUDIO_ROTATION_PROP);
|
|
expect(card.style.getPropertyValue("rotate")).toContain("8deg");
|
|
expect(card.style.getPropertyValue("transform-origin")).toBe("center center");
|
|
|
|
clearStudioRotation(card);
|
|
expect(readStudioRotation(card)).toEqual({ angle: 0 });
|
|
expect(card.style.getPropertyValue("rotate")).toBe("8deg");
|
|
expect(card.style.getPropertyValue("transform-origin")).toBe("left top");
|
|
});
|
|
|
|
it("does not replay a gesture-guarded offset during active gesture", () => {
|
|
const document = createDocument(`<div id="card"></div>`);
|
|
const card = document.getElementById("card") as HTMLElement;
|
|
|
|
applyStudioPathOffset(card, { x: 40, y: 24 });
|
|
const firstToken = beginStudioManualEditGesture(card);
|
|
const secondToken = beginStudioManualEditGesture(card);
|
|
endStudioManualEditGesture(card, firstToken);
|
|
|
|
// Gesture still active — offset should remain
|
|
expect(readStudioPathOffset(card)).toEqual({ x: 40, y: 24 });
|
|
|
|
endStudioManualEditGesture(card, secondToken);
|
|
// After gesture ends, offset remains (we don't auto-clear in this path)
|
|
expect(readStudioPathOffset(card)).toEqual({ x: 40, y: 24 });
|
|
});
|
|
|
|
it("reapplies the latest preview manifest after wrapped seeks", () => {
|
|
const window = new Window();
|
|
const seekArgs: unknown[][] = [];
|
|
const previewWindow = window as unknown as Parameters<
|
|
typeof installStudioManualEditSeekReapply
|
|
>[0] & {
|
|
__player: Record<string, unknown>;
|
|
};
|
|
previewWindow.__player = {
|
|
seek: (...args: unknown[]) => {
|
|
seekArgs.push(args);
|
|
},
|
|
};
|
|
|
|
let applied = 0;
|
|
expect(
|
|
installStudioManualEditSeekReapply(previewWindow, () => {
|
|
applied += 1;
|
|
}),
|
|
).toBe(true);
|
|
(previewWindow.__player.seek as (time: number, suppressEvents: boolean) => void)(1, false);
|
|
expect(applied).toBe(1);
|
|
expect(seekArgs).toEqual([[1, false]]);
|
|
|
|
expect(
|
|
installStudioManualEditSeekReapply(previewWindow, () => {
|
|
applied += 10;
|
|
}),
|
|
).toBe(true);
|
|
(previewWindow.__player.seek as (time: number) => void)(2);
|
|
expect(applied).toBe(11);
|
|
});
|
|
|
|
it("reapplies manual edits while fresh playback is active", () => {
|
|
const window = new Window();
|
|
const frames: FrameRequestCallback[] = [];
|
|
let playing = false;
|
|
const previewWindow = window as unknown as Parameters<
|
|
typeof installStudioManualEditSeekReapply
|
|
>[0] & {
|
|
__player: Record<string, unknown>;
|
|
requestAnimationFrame: (callback: FrameRequestCallback) => number;
|
|
};
|
|
previewWindow.requestAnimationFrame = (callback: FrameRequestCallback) => {
|
|
frames.push(callback);
|
|
return frames.length;
|
|
};
|
|
previewWindow.__player = {
|
|
play: () => {
|
|
playing = true;
|
|
},
|
|
isPlaying: () => playing,
|
|
};
|
|
|
|
let applied = 0;
|
|
expect(
|
|
installStudioManualEditSeekReapply(previewWindow, () => {
|
|
applied += 1;
|
|
}),
|
|
).toBe(true);
|
|
|
|
(previewWindow.__player.play as () => void)();
|
|
expect(applied).toBe(1);
|
|
expect(frames).toHaveLength(1);
|
|
|
|
frames.shift()?.(16);
|
|
expect(applied).toBe(2);
|
|
expect(frames).toHaveLength(1);
|
|
|
|
playing = false;
|
|
frames.shift()?.(32);
|
|
expect(applied).toBe(3);
|
|
expect(frames).toHaveLength(0);
|
|
});
|
|
|
|
it("stops playback reapply after an unpaused timeline has completed", () => {
|
|
const window = new Window();
|
|
const frames: FrameRequestCallback[] = [];
|
|
let currentTime = 0;
|
|
let paused = true;
|
|
const previewWindow = window as unknown as Parameters<
|
|
typeof installStudioManualEditSeekReapply
|
|
>[0] & {
|
|
__timeline: Record<string, unknown>;
|
|
requestAnimationFrame: (callback: FrameRequestCallback) => number;
|
|
};
|
|
previewWindow.requestAnimationFrame = (callback: FrameRequestCallback) => {
|
|
frames.push(callback);
|
|
return frames.length;
|
|
};
|
|
previewWindow.__timeline = {
|
|
play: () => {
|
|
paused = false;
|
|
},
|
|
paused: () => paused,
|
|
isActive: () => false,
|
|
time: () => currentTime,
|
|
duration: () => 2,
|
|
};
|
|
|
|
let applied = 0;
|
|
expect(
|
|
installStudioManualEditSeekReapply(previewWindow, () => {
|
|
applied += 1;
|
|
}),
|
|
).toBe(true);
|
|
|
|
(previewWindow.__timeline.play as () => void)();
|
|
expect(applied).toBe(1);
|
|
expect(frames).toHaveLength(1);
|
|
|
|
currentTime = 2;
|
|
frames.shift()?.(16);
|
|
expect(applied).toBe(2);
|
|
expect(frames).toHaveLength(0);
|
|
});
|
|
});
|