feat(studio): persist element positions in HTML, fix resize overlay drift and GSAP double-translation (#829)

* 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.
This commit is contained in:
Miguel Ángel
2026-05-15 06:43:00 +02:00
committed by GitHub
parent 83c29faaf9
commit 225010800a
28 changed files with 1066 additions and 1305 deletions
@@ -113,6 +113,7 @@ describe("DomEditOverlay", () => {
capabilities: {
canEditText: true,
canEditLayout: true,
canMove: true,
canApplyManualOffset: true,
canApplyManualSize: false,
canApplyManualRotation: false,
@@ -138,6 +138,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
const gestures = createDomEditOverlayGestureHandlers({
overlayRef,
iframeRef,
boxRef,
selectionRef,
overlayRectRef,
@@ -307,7 +308,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
cursor: allowCanvasMovement && groupCanMove ? "move" : "default",
}}
onPointerDown={(e) => {
if (!allowCanvasMovement || e.shiftKey) return;
if (!allowCanvasMovement || !groupCanMove || e.shiftKey) return;
gestures.startGroupDrag(e);
}}
onMouseDown={suppressBoxMouseDown}
@@ -1,5 +1,5 @@
import { memo } from "react";
import { Eye, Layers, MessageSquare, Move, RotateCcw, X } from "../../icons/SystemIcons";
import { Eye, Layers, MessageSquare, Move, X } from "../../icons/SystemIcons";
import {
collectDomEditLayerItems,
getDomEditLayerKey,
@@ -46,7 +46,6 @@ interface PropertyPanelProps {
onSetTextFieldStyle: (fieldKey: string, property: string, value: string) => void;
onAddTextField: (afterFieldKey?: string) => string | Promise<string | null> | null;
onRemoveTextField: (fieldKey: string) => void;
onResetManualEdits: (element: DomEditSelection) => void;
onAskAgent: () => void;
onImportAssets?: (files: FileList) => Promise<string[]>;
fontAssets?: ImportedFontAsset[];
@@ -134,7 +133,6 @@ export const PropertyPanel = memo(function PropertyPanel({
onSetTextFieldStyle,
onAddTextField,
onRemoveTextField,
onResetManualEdits,
onAskAgent,
onImportAssets,
fontAssets = [],
@@ -146,30 +144,32 @@ export const PropertyPanel = memo(function PropertyPanel({
if (!element) {
return (
<div className="flex h-full flex-col items-center justify-center bg-neutral-900 px-6 text-center">
{multiSelectCount > 1 ? (
<>
<Layers size={18} className="mb-3 text-neutral-600" />
<p className="text-sm font-medium text-neutral-200">
{multiSelectCount} elements selected
</p>
<p className="mt-2 max-w-[260px] text-xs leading-5 text-neutral-500">
Select a single element to edit its properties. Click an element in the preview or use
the timeline layer panel.
</p>
</>
) : (
<>
<Eye size={18} className="mb-3 text-neutral-600" />
<p className="text-sm font-medium text-neutral-200">
Select an element in the preview.
</p>
<p className="mt-2 max-w-[260px] text-xs leading-5 text-neutral-500">
The inspector is tuned for element edits with safer geometry controls, color picking,
and cleaner grouped layer controls.
</p>
</>
)}
<div className="flex h-full flex-col bg-neutral-900">
<div className="flex flex-1 flex-col items-center justify-center px-6 text-center">
{multiSelectCount > 1 ? (
<>
<Layers size={18} className="mb-3 text-neutral-600" />
<p className="text-sm font-medium text-neutral-200">
{multiSelectCount} elements selected
</p>
<p className="mt-2 max-w-[260px] text-xs leading-5 text-neutral-500">
Select a single element to edit its properties. Click an element in the preview or
use the timeline layer panel.
</p>
</>
) : (
<>
<Eye size={18} className="mb-3 text-neutral-600" />
<p className="text-sm font-medium text-neutral-200">
Select an element in the preview.
</p>
<p className="mt-2 max-w-[260px] text-xs leading-5 text-neutral-500">
The inspector is tuned for element edits with safer geometry controls, color
picking, and cleaner grouped layer controls.
</p>
</>
)}
</div>
</div>
);
}
@@ -253,15 +253,6 @@ export const PropertyPanel = memo(function PropertyPanel({
<MessageSquare size={15} />
<span>{copiedAgentPrompt ? "Prompt copied" : "Ask agent"}</span>
</button>
<button
type="button"
onClick={() => onResetManualEdits(element)}
title="Reset move, size, and rotation edits"
className="inline-flex h-8 items-center justify-center gap-2 rounded-xl border border-neutral-700 bg-neutral-950 px-3.5 text-[11px] font-medium text-neutral-100 transition-colors hover:border-neutral-500 hover:text-white"
>
<RotateCcw size={14} />
<span>Reset edits</span>
</button>
</div>
</div>
@@ -118,6 +118,7 @@ export function getDomLayerPatchTarget(
activeCompositionPath: string | null,
): Pick<DomEditSelection, "id" | "selector" | "selectorIndex" | "sourceFile"> | null {
if (!isInspectableLayerElement(el)) return null;
if (el.hasAttribute("data-composition-id")) return null;
const selector = buildStableSelector(el);
if (!selector) return null;
@@ -1,6 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import { Window } from "happy-dom";
import type { DomEditSelection } from "./domEditing";
import {
STUDIO_OFFSET_X_PROP,
STUDIO_OFFSET_Y_PROP,
@@ -8,7 +7,6 @@ import {
STUDIO_WIDTH_PROP,
applyStudioBoxSize,
applyStudioBoxSizeDraft,
applyStudioManualEditManifest,
applyStudioPathOffset,
applyStudioPathOffsetDraft,
applyStudioRotation,
@@ -16,22 +14,17 @@ import {
beginStudioManualEditGesture,
captureStudioBoxSize,
captureStudioRotation,
emptyStudioManualEditManifest,
clearStudioBoxSize,
clearStudioPathOffset,
clearStudioRotation,
endStudioManualEditGesture,
installStudioManualEditSeekReapply,
isStudioManualEditManifestPath,
parseStudioManualEditManifest,
readStudioFileChangePath,
readStudioBoxSize,
readStudioFileChangePath,
readStudioPathOffset,
readStudioRotation,
removeStudioManualEditsForSelection,
restoreStudioBoxSize,
restoreStudioRotation,
serializeStudioManualEditManifest,
upsertStudioBoxSizeEdit,
upsertStudioPathOffsetEdit,
upsertStudioRotationEdit,
} from "./manualEdits";
function createDocument(markup: string): Document {
@@ -40,36 +33,6 @@ function createDocument(markup: string): Document {
return window.document;
}
function createSelection(): DomEditSelection {
return {
element: {} as HTMLElement,
id: "card",
selector: "#card",
selectorIndex: undefined,
sourceFile: "index.html",
compositionPath: "index.html",
compositionSrc: undefined,
isCompositionHost: false,
label: "Card",
tagName: "div",
boundingBox: { x: 0, y: 0, width: 100, height: 100 },
textContent: null,
dataAttributes: {},
inlineStyles: {},
computedStyles: {},
textFields: [],
capabilities: {
canSelect: true,
canEditStyles: true,
canMove: false,
canResize: false,
canApplyManualOffset: true,
canApplyManualSize: true,
canApplyManualRotation: true,
},
};
}
function mockBoundingRect(element: HTMLElement, width: number, height: number): void {
element.getBoundingClientRect = () =>
({
@@ -95,149 +58,13 @@ function mockComputedStyle(element: HTMLElement, values: Record<string, string>)
}
describe("studio manual edits", () => {
it("upserts path offsets by stable target", () => {
const manifest = upsertStudioPathOffsetEdit(
emptyStudioManualEditManifest(),
createSelection(),
{
x: 12.4,
y: 30.6,
},
);
const updated = upsertStudioPathOffsetEdit(manifest, createSelection(), {
x: 20,
y: 42,
});
expect(updated.edits).toHaveLength(1);
expect(updated.edits[0]).toMatchObject({
kind: "path-offset",
target: { sourceFile: "index.html", selector: "#card", id: "card" },
x: 20,
y: 42,
});
});
it("upserts box sizes without replacing path offsets for the same target", () => {
const selection = createSelection();
const manifest = upsertStudioPathOffsetEdit(emptyStudioManualEditManifest(), selection, {
x: 12,
y: 30,
});
const updated = upsertStudioBoxSizeEdit(manifest, selection, {
width: 240.4,
height: 120.6,
});
const resized = upsertStudioBoxSizeEdit(updated, selection, {
width: 260,
height: 140,
});
expect(resized.edits).toHaveLength(2);
expect(resized.edits).toEqual(
expect.arrayContaining([
expect.objectContaining({ kind: "path-offset", x: 12, y: 30 }),
expect.objectContaining({ kind: "box-size", width: 260, height: 140 }),
]),
);
});
it("upserts rotations without replacing other manual edits for the same target", () => {
const selection = createSelection();
const manifest = upsertStudioPathOffsetEdit(emptyStudioManualEditManifest(), selection, {
x: 12,
y: 30,
});
const resized = upsertStudioBoxSizeEdit(manifest, selection, {
width: 240,
height: 120,
});
const rotated = upsertStudioRotationEdit(resized, selection, { angle: 32.34 });
const updated = upsertStudioRotationEdit(rotated, selection, { angle: -14.96 });
expect(updated.edits).toHaveLength(3);
expect(updated.edits).toEqual(
expect.arrayContaining([
expect.objectContaining({ kind: "path-offset", x: 12, y: 30 }),
expect.objectContaining({ kind: "box-size", width: 240, height: 120 }),
expect.objectContaining({ kind: "rotation", angle: -15 }),
]),
);
});
it("removes all manual edits for the selected target", () => {
const selection = createSelection();
const otherSelection = {
...createSelection(),
id: "other-card",
selector: "#other-card",
label: "Other card",
};
const moved = upsertStudioPathOffsetEdit(emptyStudioManualEditManifest(), selection, {
x: 12,
y: 30,
});
const resized = upsertStudioBoxSizeEdit(moved, selection, {
width: 240,
height: 120,
});
const rotated = upsertStudioRotationEdit(resized, selection, { angle: 32 });
const manifest = upsertStudioPathOffsetEdit(rotated, otherSelection, { x: 4, y: 8 });
const updated = removeStudioManualEditsForSelection(manifest, selection);
expect(updated.edits).toHaveLength(1);
expect(updated.edits[0]).toMatchObject({
kind: "path-offset",
target: { id: "other-card", selector: "#other-card" },
x: 4,
y: 8,
});
});
it("round-trips valid manifest entries and drops invalid entries", () => {
const content = serializeStudioManualEditManifest({
version: 1,
edits: [
{
kind: "path-offset",
target: { sourceFile: "index.html", selector: "#card", id: "card" },
x: 10,
y: 20,
},
{
kind: "box-size",
target: { sourceFile: "index.html", selector: "#card", id: "card" },
width: 320,
height: 180,
},
{
kind: "rotation",
target: { sourceFile: "index.html", selector: "#card", id: "card" },
angle: 22.5,
},
],
});
expect(parseStudioManualEditManifest(content).edits).toHaveLength(3);
expect(parseStudioManualEditManifest('{ "edits": [{ "kind": "path-offset" }] }').edits).toEqual(
[],
);
});
it("recognizes manual edit manifest file-change payloads", () => {
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",
);
expect(
isStudioManualEditManifestPath(
"/Users/example/project/.hyperframes/studio-manual-edits.json",
),
).toBe(true);
expect(isStudioManualEditManifestPath("index.html")).toBe(false);
});
it("applies offsets through CSS translate longhand", () => {
@@ -293,9 +120,8 @@ describe("studio manual edits", () => {
applyStudioPathOffset(card, { x: 14, y: -8 });
applyStudioRotation(card, { angle: 12 });
expect(
applyStudioManualEditManifest(document, emptyStudioManualEditManifest(), "index.html"),
).toBe(0);
clearStudioPathOffset(card);
clearStudioRotation(card);
expect(card.style.getPropertyValue("translate")).toBe("");
expect(card.style.getPropertyValue("rotate")).toBe("");
@@ -383,54 +209,6 @@ describe("studio manual edits", () => {
expect(card.style.getPropertyValue("transform-origin")).toBe("center center");
});
it("does not recapture a studio rotation draft as the authored base", () => {
const document = createDocument(`<div id="card" style="rotate: 8deg"></div>`);
const card = document.getElementById("card") as HTMLElement;
const manifest = parseStudioManualEditManifest(`{
"version": 1,
"edits": [
{
"kind": "rotation",
"target": { "sourceFile": "index.html", "selector": "#card", "id": "card" },
"angle": 35
}
]
}`);
applyStudioRotation(card, { angle: 12 });
applyStudioRotationDraft(card, { angle: 35 });
expect(card.style.getPropertyValue("rotate")).toBe("calc(8deg + 35deg)");
expect(applyStudioManualEditManifest(document, manifest, "index.html")).toBe(1);
expect(card.style.getPropertyValue("rotate")).toBe(
`calc(8deg + var(${STUDIO_ROTATION_PROP}, 0deg))`,
);
});
it("does not treat a base-free studio rotation draft as authored rotation", () => {
const document = createDocument(`<div id="card"></div>`);
const card = document.getElementById("card") as HTMLElement;
const manifest = parseStudioManualEditManifest(`{
"version": 1,
"edits": [
{
"kind": "rotation",
"target": { "sourceFile": "index.html", "selector": "#card", "id": "card" },
"angle": 35
}
]
}`);
applyStudioRotation(card, { angle: 12 });
applyStudioRotationDraft(card, { angle: 35 });
expect(card.style.getPropertyValue("rotate")).toBe("35deg");
expect(applyStudioManualEditManifest(document, manifest, "index.html")).toBe(1);
expect(card.style.getPropertyValue("rotate")).toBe(`var(${STUDIO_ROTATION_PROP}, 0deg)`);
});
it("uses height for flex-basis inside column flex containers", () => {
const document = createDocument(`
<div style="display: flex; flex-direction: column">
@@ -523,9 +301,7 @@ describe("studio manual edits", () => {
expect(tween._startAt.vars).toEqual({ x: -240, y: -20 });
expect(card.style.getPropertyValue("translate")).toContain(STUDIO_OFFSET_X_PROP);
expect(
applyStudioManualEditManifest(document, emptyStudioManualEditManifest(), "index.html"),
).toBe(0);
clearStudioPathOffset(card);
expect(tween.vars).toMatchObject({
x: 0,
y: 10,
@@ -537,197 +313,48 @@ describe("studio manual edits", () => {
expect(card.style.getPropertyValue("translate")).toBe("");
});
it("applies manifest offsets to matching preview elements", () => {
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 manifest = parseStudioManualEditManifest(`{
"version": 1,
"edits": [
{
"kind": "path-offset",
"target": { "sourceFile": "index.html", "selector": "#card", "id": "card" },
"x": 32,
"y": 18
}
]
}`);
const card = document.getElementById("card") as HTMLElement;
expect(applyStudioManualEditManifest(document, manifest, "index.html")).toBe(1);
expect(readStudioPathOffset(document.getElementById("card") as HTMLElement)).toEqual({
x: 32,
y: 18,
});
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("resolves manifest targets within the matching source file", () => {
const document = createDocument(`
<div data-composition-id="root">
<div id="card" class="tile"></div>
<div data-composition-id="nested" data-composition-file="scenes/nested.html">
<div id="card" class="tile"></div>
<div class="tile"></div>
</div>
</div>
`);
const htmlElement = document.defaultView?.HTMLElement;
if (!htmlElement) throw new Error("HTMLElement fixture missing");
const cards = Array.from(document.getElementsByTagName("*")).filter(
(element): element is HTMLElement => element instanceof htmlElement && element.id === "card",
);
const rootCard = cards[0];
const nestedCard = cards[1];
const tiles = Array.from(document.getElementsByTagName("*")).filter(
(element): element is HTMLElement =>
element instanceof htmlElement && element.classList.contains("tile"),
);
const nestedSecondTile = tiles[2];
if (!rootCard || !nestedCard || !nestedSecondTile) {
throw new Error("source-scoped fixture missing");
}
const manifest = parseStudioManualEditManifest(`{
"version": 1,
"edits": [
{
"kind": "path-offset",
"target": {
"sourceFile": "scenes/nested.html",
"selector": "#card",
"id": "card"
},
"x": 48,
"y": 16
},
{
"kind": "box-size",
"target": {
"sourceFile": "scenes/nested.html",
"selector": ".tile",
"selectorIndex": 1
},
"width": 220,
"height": 80
}
]
}`);
expect(applyStudioManualEditManifest(document, manifest, "index.html")).toBe(2);
expect(readStudioPathOffset(rootCard)).toEqual({ x: 0, y: 0 });
expect(readStudioPathOffset(nestedCard)).toEqual({ x: 48, y: 16 });
expect(readStudioBoxSize(nestedSecondTile)).toEqual({ width: 220, height: 80 });
});
it("resolves manifest targets inside composition-file hosts without composition ids", () => {
const document = createDocument(`
<div data-composition-id="root">
<div id="card"></div>
<div data-composition-file="scenes/anonymous.html">
<div id="card"></div>
</div>
</div>
`);
const htmlElement = document.defaultView?.HTMLElement;
if (!htmlElement) throw new Error("HTMLElement fixture missing");
const cards = Array.from(document.getElementsByTagName("*")).filter(
(element): element is HTMLElement => element instanceof htmlElement && element.id === "card",
);
const rootCard = cards[0];
const nestedCard = cards[1];
if (!rootCard || !nestedCard) {
throw new Error("anonymous composition fixture missing");
}
const manifest = parseStudioManualEditManifest(`{
"version": 1,
"edits": [
{
"kind": "path-offset",
"target": {
"sourceFile": "scenes/anonymous.html",
"selector": "#card",
"id": "card"
},
"x": 24,
"y": 12
}
]
}`);
expect(applyStudioManualEditManifest(document, manifest, "index.html")).toBe(1);
expect(readStudioPathOffset(rootCard)).toEqual({ x: 0, y: 0 });
expect(readStudioPathOffset(nestedCard)).toEqual({ x: 24, y: 12 });
});
it("applies nested source edits while previewing a non-index parent composition", () => {
const document = createDocument(`
<div data-composition-id="parent">
<div id="parent-card"></div>
<div data-composition-file="scenes/child.html">
<div id="child-card"></div>
</div>
</div>
`);
const parentCard = document.getElementById("parent-card") as HTMLElement;
const childCard = document.getElementById("child-card") as HTMLElement;
const manifest = parseStudioManualEditManifest(`{
"version": 1,
"edits": [
{
"kind": "path-offset",
"target": {
"sourceFile": "scenes/parent.html",
"selector": "#parent-card",
"id": "parent-card"
},
"x": 12,
"y": 8
},
{
"kind": "path-offset",
"target": {
"sourceFile": "scenes/child.html",
"selector": "#child-card",
"id": "child-card"
},
"x": 36,
"y": 18
}
]
}`);
expect(applyStudioManualEditManifest(document, manifest, "scenes/parent.html")).toBe(2);
expect(readStudioPathOffset(parentCard)).toEqual({ x: 12, y: 8 });
expect(readStudioPathOffset(childCard)).toEqual({ x: 36, y: 18 });
});
it("applies and clears manifest box sizes while restoring authored inline size", () => {
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 manifest = parseStudioManualEditManifest(`{
"version": 1,
"edits": [
{
"kind": "box-size",
"target": { "sourceFile": "index.html", "selector": "#card", "id": "card" },
"width": 320,
"height": 180
}
]
}`);
const card = document.getElementById("card") as HTMLElement;
mockBoundingRect(card, 160, 90);
expect(applyStudioManualEditManifest(document, manifest, "index.html")).toBe(1);
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("height")).toBe("180px");
expect(card.style.getPropertyValue("flex-basis")).toBe("320px");
expect(
applyStudioManualEditManifest(document, emptyStudioManualEditManifest(), "index.html"),
).toBe(0);
clearStudioBoxSize(card);
expect(readStudioBoxSize(card)).toEqual({ width: 0, height: 0 });
expect(card.style.getPropertyValue("width")).toBe("160px");
expect(card.style.getPropertyValue("height")).toBe("90px");
@@ -737,93 +364,39 @@ describe("studio manual edits", () => {
expect(card.style.getPropertyValue("scale")).toBe("");
});
it("applies and clears manifest rotations while restoring authored inline rotation", () => {
it("clears rotations and restores authored inline rotation", () => {
const document = createDocument(
`<div id="card" style="rotate: 8deg; transform-origin: left top"></div>`,
);
const manifest = parseStudioManualEditManifest(`{
"version": 1,
"edits": [
{
"kind": "rotation",
"target": { "sourceFile": "index.html", "selector": "#card", "id": "card" },
"angle": 37.5
}
]
}`);
const card = document.getElementById("card") as HTMLElement;
expect(applyStudioManualEditManifest(document, manifest, "index.html")).toBe(1);
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");
expect(
applyStudioManualEditManifest(document, emptyStudioManualEditManifest(), "index.html"),
).toBe(0);
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("clears stale preview offsets that are no longer in the manifest", () => {
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: 24, y: 12 });
expect(readStudioPathOffset(card)).toEqual({ x: 24, y: 12 });
expect(
applyStudioManualEditManifest(document, emptyStudioManualEditManifest(), "index.html"),
).toBe(0);
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("restores authored inline translate when clearing offsets", () => {
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);
expect(
applyStudioManualEditManifest(document, emptyStudioManualEditManifest(), "index.html"),
).toBe(0);
expect(card.style.getPropertyValue("translate")).toBe("10px 20px");
});
it("does not replay the manifest over an active manual edit gesture", () => {
const document = createDocument(`<div id="card"></div>`);
const card = document.getElementById("card") as HTMLElement;
const manifest = parseStudioManualEditManifest(`{
"version": 1,
"edits": [
{
"kind": "path-offset",
"target": { "sourceFile": "index.html", "selector": "#card", "id": "card" },
"x": 8,
"y": 4
}
]
}`);
applyStudioPathOffset(card, { x: 40, y: 24 });
const firstToken = beginStudioManualEditGesture(card);
const secondToken = beginStudioManualEditGesture(card);
endStudioManualEditGesture(card, firstToken);
expect(applyStudioManualEditManifest(document, manifest, "index.html")).toBe(0);
// Gesture still active — offset should remain
expect(readStudioPathOffset(card)).toEqual({ x: 40, y: 24 });
endStudioManualEditGesture(card, secondToken);
expect(applyStudioManualEditManifest(document, manifest, "index.html")).toBe(1);
expect(readStudioPathOffset(card)).toEqual({ x: 8, y: 4 });
// 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", () => {
@@ -1,34 +1,17 @@
// Public re-exports — consumers import from this file as before.
export {
STUDIO_MANUAL_EDITS_PATH,
STUDIO_OFFSET_X_PROP,
STUDIO_OFFSET_Y_PROP,
STUDIO_WIDTH_PROP,
STUDIO_HEIGHT_PROP,
STUDIO_ROTATION_PROP,
type StudioManualEditTarget,
type StudioPathOffsetEdit,
type StudioBoxSizeEdit,
type StudioRotationEdit,
type StudioManualEdit,
type StudioManualEditManifest,
type StudioManualEditSeekWindow,
type StudioBoxSizeSnapshot,
type StudioRotationSnapshot,
type StudioPathOffsetSnapshot,
} from "./manualEditsTypes";
export {
emptyStudioManualEditManifest,
parseStudioManualEditManifest,
serializeStudioManualEditManifest,
readStudioFileChangePath,
isStudioManualEditManifestPath,
upsertStudioPathOffsetEdit,
upsertStudioBoxSizeEdit,
upsertStudioRotationEdit,
removeStudioManualEditsForSelection,
} from "./manualEditsParsing";
export { readStudioFileChangePath } from "./manualEditsParsing";
export {
beginStudioManualEditGesture,
@@ -46,6 +29,7 @@ export {
clearStudioPathOffset,
clearStudioRotation,
clearStudioBoxSize,
reapplyPositionEditsAfterSeek,
} from "./manualEditsDom";
export {
@@ -57,27 +41,14 @@ export {
restoreStudioPathOffset,
} from "./manualEditsSnapshot";
import type {
StudioManualEdit,
StudioManualEditManifest,
StudioManualEditSeekWindow,
} from "./manualEditsTypes";
import type { StudioManualEditSeekWindow } from "./manualEditsTypes";
import {
STUDIO_MANUAL_EDITS_APPLY_PROP,
STUDIO_MANUAL_EDITS_WRAPPED_PROP,
STUDIO_MANUAL_EDITS_PLAYBACK_FRAME_PROP,
} from "./manualEditsTypes";
import { finiteNumber } from "./manualEditsParsing";
import {
applyStudioPathOffset,
applyStudioBoxSize,
applyStudioRotation,
isStudioManualEditGestureActive,
clearStudioPathOffset,
clearStudioBoxSize,
clearStudioRotation,
} from "./manualEditsDom";
import { collectStudioManualEditElements } from "./manualEditsSnapshot";
import { isStudioManualEditGestureActive } from "./manualEditsDom";
/* ── Seek/play reapply wrappers ───────────────────────────────────── */
function markWrapped(fn: (...args: unknown[]) => unknown): void {
@@ -307,138 +278,5 @@ export function installStudioManualEditSeekReapply(win: Window, apply: () => voi
);
}
/* ── DOM target resolution ────────────────────────────────────────── */
function getManualEditSourceFileForElement(
el: HTMLElement,
activeCompositionPath: string | null,
): string {
let current: HTMLElement | null = el;
while (current) {
const sourceFile =
current.getAttribute("data-composition-file") ?? current.getAttribute("data-composition-src");
if (sourceFile) return sourceFile;
current = current.parentElement;
}
return activeCompositionPath ?? "index.html";
}
function elementMatchesManualEditSourceFile(
element: HTMLElement,
sourceFile: string,
activeCompositionPath: string | null,
): boolean {
return getManualEditSourceFileForElement(element, activeCompositionPath) === sourceFile;
}
function queryManualEditSelectorCandidates(
doc: Document,
selector: string,
htmlElement: typeof HTMLElement,
): HTMLElement[] {
const isCandidate = (element: Element): element is HTMLElement => element instanceof htmlElement;
const className = selector.match(/^\.([A-Za-z0-9_-]+)$/)?.[1];
if (className) {
return Array.from(doc.getElementsByTagName("*")).filter(
(element): element is HTMLElement =>
isCandidate(element) && element.classList.contains(className),
);
}
if (/^[A-Za-z][A-Za-z0-9-]*$/.test(selector)) {
return Array.from(doc.getElementsByTagName(selector)).filter(isCandidate);
}
return Array.from(doc.querySelectorAll(selector)).filter(isCandidate);
}
function resolveManualEditTarget(
doc: Document,
edit: StudioManualEdit,
activeCompositionPath: string | null,
): HTMLElement | null {
const htmlElement = doc.defaultView?.HTMLElement;
if (!htmlElement) return null;
if (edit.target.id) {
const byId = doc.getElementById(edit.target.id);
if (
byId instanceof htmlElement &&
elementMatchesManualEditSourceFile(byId, edit.target.sourceFile, activeCompositionPath)
) {
return byId;
}
const matchesById = [doc.documentElement, ...Array.from(doc.getElementsByTagName("*"))].filter(
(element): element is HTMLElement =>
element instanceof htmlElement &&
element.id === edit.target.id &&
elementMatchesManualEditSourceFile(element, edit.target.sourceFile, activeCompositionPath),
);
if (matchesById[0]) return matchesById[0];
}
if (!edit.target.selector) return null;
try {
const matches = queryManualEditSelectorCandidates(
doc,
edit.target.selector,
htmlElement,
).filter((element) =>
elementMatchesManualEditSourceFile(element, edit.target.sourceFile, activeCompositionPath),
);
return matches[edit.target.selectorIndex ?? 0] ?? null;
} catch {
return null;
}
}
/* ── Manifest application ─────────────────────────────────────────── */
export function applyStudioManualEditManifest(
doc: Document,
manifest: StudioManualEditManifest,
activeCompositionPath: string | null,
): number {
const resolvedEdits: Array<{ edit: StudioManualEdit; element: HTMLElement }> = [];
const pathOffsetTargets = new Set<HTMLElement>();
const boxSizeTargets = new Set<HTMLElement>();
const rotationTargets = new Set<HTMLElement>();
for (const edit of manifest.edits) {
const element = resolveManualEditTarget(doc, edit, activeCompositionPath);
if (!element) continue;
if (isStudioManualEditGestureActive(element)) {
continue;
}
resolvedEdits.push({ edit, element });
if (edit.kind === "path-offset") pathOffsetTargets.add(element);
if (edit.kind === "box-size") boxSizeTargets.add(element);
if (edit.kind === "rotation") rotationTargets.add(element);
}
for (const element of collectStudioManualEditElements(doc)) {
if (isStudioManualEditGestureActive(element)) continue;
if (!pathOffsetTargets.has(element)) {
clearStudioPathOffset(element);
}
if (!boxSizeTargets.has(element)) {
clearStudioBoxSize(element);
}
if (!rotationTargets.has(element)) {
clearStudioRotation(element);
}
}
let applied = 0;
for (const { edit, element } of resolvedEdits) {
if (edit.kind === "path-offset") {
applyStudioPathOffset(element, { x: edit.x, y: edit.y });
} else if (edit.kind === "box-size") {
applyStudioBoxSize(element, { width: edit.width, height: edit.height });
} else {
applyStudioRotation(element, { angle: edit.angle });
}
applied += 1;
}
return applied;
}
// Re-export for internal use (seek hooks need this)
export { isStudioManualEditGestureActive };
@@ -209,12 +209,39 @@ function writeStudioPathOffsetVars(
}
/* ── Path offset apply ────────────────────────────────────────────── */
// GSAP 3.x reads the resolved CSS `translate` individual property at initialization and bakes it
// into element.style.transform (as a matrix) on every seek. When the studio's reapply hook also
// writes `translate`, both properties compose additively, doubling the visual offset. This helper
// zeroes out only the translate component (m41/m42) so the `translate` prop isn't double-counted.
function stripGsapTranslateFromTransform(element: HTMLElement): void {
const transform = element.style.getPropertyValue("transform");
if (!transform || transform === "none") return;
const win = element.ownerDocument.defaultView as (Window & typeof globalThis) | null;
const DOMMatrixCtor = (win as unknown as { DOMMatrix?: typeof DOMMatrix })?.DOMMatrix;
if (!DOMMatrixCtor) return;
try {
const m = new DOMMatrixCtor(transform);
if (m.m41 === 0 && m.m42 === 0) return;
m.m41 = 0;
m.m42 = 0;
if (m.is2D && m.a === 1 && m.b === 0 && m.c === 0 && m.d === 1) {
element.style.removeProperty("transform");
} else {
element.style.setProperty("transform", m.toString());
}
} catch {
// non-parseable transform or DOMMatrix unavailable — leave as-is
}
}
export function applyStudioPathOffset(
element: HTMLElement,
offset: { x: number; y: number },
options: { updateBase?: boolean } = {},
): void {
promoteInlineForTransform(element);
writeStudioPathOffsetVars(element, offset);
writeStudioPathOffsetVars(element, offset, { updateBase: options.updateBase ?? true });
element.style.setProperty(
"translate",
composeTranslateValue(
@@ -223,6 +250,7 @@ export function applyStudioPathOffset(
`var(${STUDIO_OFFSET_Y_PROP}, 0px)`,
),
);
stripGsapTranslateFromTransform(element);
}
export function applyStudioPathOffsetDraft(
@@ -235,6 +263,7 @@ export function applyStudioPathOffsetDraft(
"translate",
composeTranslateValue(element, `${Math.round(offset.x)}px`, `${Math.round(offset.y)}px`),
);
stripGsapTranslateFromTransform(element);
}
/* ── Box size apply ───────────────────────────────────────────────── */
@@ -434,3 +463,334 @@ export {
clearStudioRotation,
clearStudioBoxSize,
} from "./manualEditsSnapshot";
/* ── HTML patch builders ──────────────────────────────────────────── */
import type { PatchOperation } from "../../utils/sourcePatcher";
export function buildPathOffsetPatches(element: HTMLElement): PatchOperation[] {
const x = element.style.getPropertyValue(STUDIO_OFFSET_X_PROP);
const y = element.style.getPropertyValue(STUDIO_OFFSET_Y_PROP);
const translate = element.style.getPropertyValue("translate");
const originalTranslate = element.getAttribute(STUDIO_ORIGINAL_TRANSLATE_ATTR);
const originalInlineTranslate = element.getAttribute(STUDIO_ORIGINAL_INLINE_TRANSLATE_ATTR);
const displayVal = element.style.getPropertyValue("display");
const transformDisplayAttr = element.getAttribute(STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR);
const ops: PatchOperation[] = [];
if (x) ops.push({ type: "inline-style", property: STUDIO_OFFSET_X_PROP, value: x });
if (y) ops.push({ type: "inline-style", property: STUDIO_OFFSET_Y_PROP, value: y });
if (translate) ops.push({ type: "inline-style", property: "translate", value: translate });
ops.push({ type: "attribute", property: STUDIO_PATH_OFFSET_ATTR, value: "true" });
if (originalTranslate !== null)
ops.push({
type: "attribute",
property: STUDIO_ORIGINAL_TRANSLATE_ATTR,
value: originalTranslate,
});
if (originalInlineTranslate !== null)
ops.push({
type: "attribute",
property: STUDIO_ORIGINAL_INLINE_TRANSLATE_ATTR,
value: originalInlineTranslate,
});
if (displayVal) ops.push({ type: "inline-style", property: "display", value: displayVal });
if (transformDisplayAttr !== null)
ops.push({
type: "attribute",
property: STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR,
value: transformDisplayAttr,
});
return ops;
}
export function buildClearPathOffsetPatches(element: HTMLElement): PatchOperation[] {
const originalInlineTranslate = element.getAttribute(STUDIO_ORIGINAL_INLINE_TRANSLATE_ATTR);
const ops: PatchOperation[] = [
{ type: "inline-style", property: STUDIO_OFFSET_X_PROP, value: null },
{ type: "inline-style", property: STUDIO_OFFSET_Y_PROP, value: null },
{
type: "inline-style",
property: "translate",
value: originalInlineTranslate || null,
},
{ type: "attribute", property: STUDIO_PATH_OFFSET_ATTR, value: null },
{ type: "attribute", property: STUDIO_ORIGINAL_TRANSLATE_ATTR, value: null },
{ type: "attribute", property: STUDIO_ORIGINAL_INLINE_TRANSLATE_ATTR, value: null },
];
const origDisplay = element.getAttribute(STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR);
if (origDisplay !== null) {
ops.push({ type: "inline-style", property: "display", value: origDisplay || null });
ops.push({ type: "attribute", property: STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR, value: null });
}
return ops;
}
export function buildBoxSizePatches(element: HTMLElement): PatchOperation[] {
const ops: PatchOperation[] = [];
const studioWidth = element.style.getPropertyValue(STUDIO_WIDTH_PROP);
const studioHeight = element.style.getPropertyValue(STUDIO_HEIGHT_PROP);
if (studioWidth)
ops.push({ type: "inline-style", property: STUDIO_WIDTH_PROP, value: studioWidth });
if (studioHeight)
ops.push({ type: "inline-style", property: STUDIO_HEIGHT_PROP, value: studioHeight });
const width = element.style.getPropertyValue("width");
const height = element.style.getPropertyValue("height");
const minWidth = element.style.getPropertyValue("min-width");
const minHeight = element.style.getPropertyValue("min-height");
const maxWidth = element.style.getPropertyValue("max-width");
const maxHeight = element.style.getPropertyValue("max-height");
const flexBasis = element.style.getPropertyValue("flex-basis");
const flexGrow = element.style.getPropertyValue("flex-grow");
const flexShrink = element.style.getPropertyValue("flex-shrink");
const boxSizing = element.style.getPropertyValue("box-sizing");
const scale = element.style.getPropertyValue("scale");
const transformOrigin = element.style.getPropertyValue("transform-origin");
const displayVal = element.style.getPropertyValue("display");
if (width) ops.push({ type: "inline-style", property: "width", value: width });
if (height) ops.push({ type: "inline-style", property: "height", value: height });
if (minWidth) ops.push({ type: "inline-style", property: "min-width", value: minWidth });
if (minHeight) ops.push({ type: "inline-style", property: "min-height", value: minHeight });
if (maxWidth) ops.push({ type: "inline-style", property: "max-width", value: maxWidth });
if (maxHeight) ops.push({ type: "inline-style", property: "max-height", value: maxHeight });
if (flexBasis) ops.push({ type: "inline-style", property: "flex-basis", value: flexBasis });
if (flexGrow) ops.push({ type: "inline-style", property: "flex-grow", value: flexGrow });
if (flexShrink) ops.push({ type: "inline-style", property: "flex-shrink", value: flexShrink });
if (boxSizing) ops.push({ type: "inline-style", property: "box-sizing", value: boxSizing });
if (scale) ops.push({ type: "inline-style", property: "scale", value: scale });
if (transformOrigin)
ops.push({ type: "inline-style", property: "transform-origin", value: transformOrigin });
if (displayVal) ops.push({ type: "inline-style", property: "display", value: displayVal });
ops.push({ type: "attribute", property: STUDIO_BOX_SIZE_ATTR, value: "true" });
const origWidth = element.getAttribute(STUDIO_ORIGINAL_WIDTH_ATTR);
const origHeight = element.getAttribute(STUDIO_ORIGINAL_HEIGHT_ATTR);
const origMinWidth = element.getAttribute(STUDIO_ORIGINAL_MIN_WIDTH_ATTR);
const origMinHeight = element.getAttribute(STUDIO_ORIGINAL_MIN_HEIGHT_ATTR);
const origMaxWidth = element.getAttribute(STUDIO_ORIGINAL_MAX_WIDTH_ATTR);
const origMaxHeight = element.getAttribute(STUDIO_ORIGINAL_MAX_HEIGHT_ATTR);
const origFlexBasis = element.getAttribute(STUDIO_ORIGINAL_FLEX_BASIS_ATTR);
const origFlexGrow = element.getAttribute(STUDIO_ORIGINAL_FLEX_GROW_ATTR);
const origFlexShrink = element.getAttribute(STUDIO_ORIGINAL_FLEX_SHRINK_ATTR);
const origBoxSizing = element.getAttribute(STUDIO_ORIGINAL_BOX_SIZING_ATTR);
const origScale = element.getAttribute(STUDIO_ORIGINAL_SCALE_ATTR);
const origTransformOrigin = element.getAttribute(STUDIO_ORIGINAL_TRANSFORM_ORIGIN_ATTR);
const origDisplay = element.getAttribute(STUDIO_ORIGINAL_DISPLAY_ATTR);
const origTransformDisplay = element.getAttribute(STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR);
if (origWidth !== null)
ops.push({ type: "attribute", property: STUDIO_ORIGINAL_WIDTH_ATTR, value: origWidth });
if (origHeight !== null)
ops.push({ type: "attribute", property: STUDIO_ORIGINAL_HEIGHT_ATTR, value: origHeight });
if (origMinWidth !== null)
ops.push({ type: "attribute", property: STUDIO_ORIGINAL_MIN_WIDTH_ATTR, value: origMinWidth });
if (origMinHeight !== null)
ops.push({
type: "attribute",
property: STUDIO_ORIGINAL_MIN_HEIGHT_ATTR,
value: origMinHeight,
});
if (origMaxWidth !== null)
ops.push({ type: "attribute", property: STUDIO_ORIGINAL_MAX_WIDTH_ATTR, value: origMaxWidth });
if (origMaxHeight !== null)
ops.push({
type: "attribute",
property: STUDIO_ORIGINAL_MAX_HEIGHT_ATTR,
value: origMaxHeight,
});
if (origFlexBasis !== null)
ops.push({
type: "attribute",
property: STUDIO_ORIGINAL_FLEX_BASIS_ATTR,
value: origFlexBasis,
});
if (origFlexGrow !== null)
ops.push({ type: "attribute", property: STUDIO_ORIGINAL_FLEX_GROW_ATTR, value: origFlexGrow });
if (origFlexShrink !== null)
ops.push({
type: "attribute",
property: STUDIO_ORIGINAL_FLEX_SHRINK_ATTR,
value: origFlexShrink,
});
if (origBoxSizing !== null)
ops.push({
type: "attribute",
property: STUDIO_ORIGINAL_BOX_SIZING_ATTR,
value: origBoxSizing,
});
if (origScale !== null)
ops.push({ type: "attribute", property: STUDIO_ORIGINAL_SCALE_ATTR, value: origScale });
if (origTransformOrigin !== null)
ops.push({
type: "attribute",
property: STUDIO_ORIGINAL_TRANSFORM_ORIGIN_ATTR,
value: origTransformOrigin,
});
if (origDisplay !== null)
ops.push({ type: "attribute", property: STUDIO_ORIGINAL_DISPLAY_ATTR, value: origDisplay });
if (origTransformDisplay !== null)
ops.push({
type: "attribute",
property: STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR,
value: origTransformDisplay,
});
return ops;
}
export function buildClearBoxSizePatches(element: HTMLElement): PatchOperation[] {
const ops: PatchOperation[] = [
{ type: "inline-style", property: STUDIO_WIDTH_PROP, value: null },
{ type: "inline-style", property: STUDIO_HEIGHT_PROP, value: null },
{ type: "attribute", property: STUDIO_BOX_SIZE_ATTR, value: null },
];
const origAttrs: Array<[string, string]> = [
[STUDIO_ORIGINAL_WIDTH_ATTR, "width"],
[STUDIO_ORIGINAL_HEIGHT_ATTR, "height"],
[STUDIO_ORIGINAL_MIN_WIDTH_ATTR, "min-width"],
[STUDIO_ORIGINAL_MIN_HEIGHT_ATTR, "min-height"],
[STUDIO_ORIGINAL_MAX_WIDTH_ATTR, "max-width"],
[STUDIO_ORIGINAL_MAX_HEIGHT_ATTR, "max-height"],
[STUDIO_ORIGINAL_FLEX_BASIS_ATTR, "flex-basis"],
[STUDIO_ORIGINAL_FLEX_GROW_ATTR, "flex-grow"],
[STUDIO_ORIGINAL_FLEX_SHRINK_ATTR, "flex-shrink"],
[STUDIO_ORIGINAL_BOX_SIZING_ATTR, "box-sizing"],
[STUDIO_ORIGINAL_SCALE_ATTR, "scale"],
[STUDIO_ORIGINAL_TRANSFORM_ORIGIN_ATTR, "transform-origin"],
[STUDIO_ORIGINAL_DISPLAY_ATTR, "display"],
];
for (const [attrName, styleProp] of origAttrs) {
const origVal = element.getAttribute(attrName);
if (origVal !== null) {
ops.push({ type: "inline-style", property: styleProp, value: origVal || null });
}
ops.push({ type: "attribute", property: attrName, value: null });
}
const origTransformDisplay = element.getAttribute(STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR);
if (origTransformDisplay !== null) {
ops.push({ type: "inline-style", property: "display", value: origTransformDisplay || null });
ops.push({ type: "attribute", property: STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR, value: null });
}
return ops;
}
export function buildRotationPatches(element: HTMLElement): PatchOperation[] {
const ops: PatchOperation[] = [];
const studioRotation = element.style.getPropertyValue(STUDIO_ROTATION_PROP);
const rotate = element.style.getPropertyValue("rotate");
const transformOrigin = element.style.getPropertyValue("transform-origin");
const displayVal = element.style.getPropertyValue("display");
if (studioRotation)
ops.push({ type: "inline-style", property: STUDIO_ROTATION_PROP, value: studioRotation });
if (rotate) ops.push({ type: "inline-style", property: "rotate", value: rotate });
if (transformOrigin)
ops.push({ type: "inline-style", property: "transform-origin", value: transformOrigin });
if (displayVal) ops.push({ type: "inline-style", property: "display", value: displayVal });
ops.push({ type: "attribute", property: STUDIO_ROTATION_ATTR, value: "true" });
const origRotate = element.getAttribute(STUDIO_ORIGINAL_ROTATE_ATTR);
const origInlineRotate = element.getAttribute(STUDIO_ORIGINAL_INLINE_ROTATE_ATTR);
const origRotationTransformOrigin = element.getAttribute(
STUDIO_ORIGINAL_ROTATION_TRANSFORM_ORIGIN_ATTR,
);
const origTransformDisplay = element.getAttribute(STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR);
if (origRotate !== null)
ops.push({ type: "attribute", property: STUDIO_ORIGINAL_ROTATE_ATTR, value: origRotate });
if (origInlineRotate !== null)
ops.push({
type: "attribute",
property: STUDIO_ORIGINAL_INLINE_ROTATE_ATTR,
value: origInlineRotate,
});
if (origRotationTransformOrigin !== null)
ops.push({
type: "attribute",
property: STUDIO_ORIGINAL_ROTATION_TRANSFORM_ORIGIN_ATTR,
value: origRotationTransformOrigin,
});
if (origTransformDisplay !== null)
ops.push({
type: "attribute",
property: STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR,
value: origTransformDisplay,
});
return ops;
}
export function buildClearRotationPatches(element: HTMLElement): PatchOperation[] {
const origInlineRotate = element.getAttribute(STUDIO_ORIGINAL_INLINE_ROTATE_ATTR);
const origRotationTransformOrigin = element.getAttribute(
STUDIO_ORIGINAL_ROTATION_TRANSFORM_ORIGIN_ATTR,
);
const ops: PatchOperation[] = [
{ type: "inline-style", property: STUDIO_ROTATION_PROP, value: null },
{ type: "inline-style", property: "rotate", value: origInlineRotate || null },
{
type: "inline-style",
property: "transform-origin",
value: origRotationTransformOrigin !== null ? origRotationTransformOrigin || null : null,
},
{ type: "attribute", property: STUDIO_ROTATION_ATTR, value: null },
{ type: "attribute", property: STUDIO_ROTATION_DRAFT_ATTR, value: null },
{ type: "attribute", property: STUDIO_ORIGINAL_ROTATE_ATTR, value: null },
{ type: "attribute", property: STUDIO_ORIGINAL_INLINE_ROTATE_ATTR, value: null },
{ type: "attribute", property: STUDIO_ORIGINAL_ROTATION_TRANSFORM_ORIGIN_ATTR, value: null },
];
const origTransformDisplay = element.getAttribute(STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR);
if (origTransformDisplay !== null) {
ops.push({ type: "inline-style", property: "display", value: origTransformDisplay || null });
ops.push({ type: "attribute", property: STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR, value: null });
}
return ops;
}
export function reapplyPositionEditsAfterSeek(doc: Document): void {
const htmlElement = doc.defaultView?.HTMLElement;
if (!htmlElement) return;
const offsetEls = Array.from(doc.querySelectorAll(`[${STUDIO_PATH_OFFSET_ATTR}="true"]`)).filter(
(el): el is HTMLElement => el instanceof htmlElement,
);
for (const el of offsetEls) {
const x = el.style.getPropertyValue(STUDIO_OFFSET_X_PROP);
const y = el.style.getPropertyValue(STUDIO_OFFSET_Y_PROP);
if (x || y) {
applyStudioPathOffset(el, {
x: Number.parseFloat(x) || 0,
y: Number.parseFloat(y) || 0,
});
}
}
const boxSizeEls = Array.from(doc.querySelectorAll(`[${STUDIO_BOX_SIZE_ATTR}="true"]`)).filter(
(el): el is HTMLElement => el instanceof htmlElement,
);
for (const el of boxSizeEls) {
const w = Number.parseFloat(el.style.getPropertyValue(STUDIO_WIDTH_PROP));
const h = Number.parseFloat(el.style.getPropertyValue(STUDIO_HEIGHT_PROP));
if (Number.isFinite(w) && Number.isFinite(h) && w > 0 && h > 0) {
applyStudioBoxSize(el, { width: w, height: h });
}
}
const rotationEls = Array.from(doc.querySelectorAll(`[${STUDIO_ROTATION_ATTR}="true"]`)).filter(
(el): el is HTMLElement => el instanceof htmlElement,
);
for (const el of rotationEls) {
const angle = Number.parseFloat(el.style.getPropertyValue(STUDIO_ROTATION_PROP));
if (Number.isFinite(angle)) {
applyStudioRotation(el, { angle });
}
}
}
@@ -1,142 +1,10 @@
import type { DomEditSelection } from "./domEditing";
import type {
StudioManualEdit,
StudioManualEditManifest,
StudioManualEditTarget,
StudioPathOffsetEdit,
StudioBoxSizeEdit,
StudioRotationEdit,
} from "./manualEditsTypes";
import { STUDIO_MANUAL_EDITS_PATH } from "./manualEditsTypes";
/* ── Helpers ──────────────────────────────────────────────────────── */
export function finiteNumber(value: unknown): number | null {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
/* ── Manifest factory ─────────────────────────────────────────────── */
export function emptyStudioManualEditManifest(): StudioManualEditManifest {
return { version: 1, edits: [] };
}
/* ── Parsing ──────────────────────────────────────────────────────── */
function parsePathOffsetEdit(value: unknown): StudioPathOffsetEdit | null {
if (!value || typeof value !== "object") return null;
const record = value as Record<string, unknown>;
if (record.kind !== "path-offset") return null;
const target = record.target;
if (!target || typeof target !== "object") return null;
const targetRecord = target as Record<string, unknown>;
const sourceFile = typeof targetRecord.sourceFile === "string" ? targetRecord.sourceFile : "";
if (!sourceFile) return null;
const selector = typeof targetRecord.selector === "string" ? targetRecord.selector : undefined;
const id = typeof targetRecord.id === "string" ? targetRecord.id : undefined;
if (!selector && !id) return null;
const x = finiteNumber(record.x);
const y = finiteNumber(record.y);
if (x == null || y == null) return null;
return {
kind: "path-offset",
target: {
sourceFile,
selector,
selectorIndex: finiteNumber(targetRecord.selectorIndex) ?? undefined,
id,
},
x,
y,
updatedAt: typeof record.updatedAt === "string" ? record.updatedAt : undefined,
};
}
function parseBoxSizeEdit(value: unknown): StudioBoxSizeEdit | null {
if (!value || typeof value !== "object") return null;
const record = value as Record<string, unknown>;
if (record.kind !== "box-size") return null;
const target = record.target;
if (!target || typeof target !== "object") return null;
const targetRecord = target as Record<string, unknown>;
const sourceFile = typeof targetRecord.sourceFile === "string" ? targetRecord.sourceFile : "";
if (!sourceFile) return null;
const selector = typeof targetRecord.selector === "string" ? targetRecord.selector : undefined;
const id = typeof targetRecord.id === "string" ? targetRecord.id : undefined;
if (!selector && !id) return null;
const width = finiteNumber(record.width);
const height = finiteNumber(record.height);
if (width == null || height == null || width <= 0 || height <= 0) return null;
return {
kind: "box-size",
target: {
sourceFile,
selector,
selectorIndex: finiteNumber(targetRecord.selectorIndex) ?? undefined,
id,
},
width,
height,
updatedAt: typeof record.updatedAt === "string" ? record.updatedAt : undefined,
};
}
function parseRotationEdit(value: unknown): StudioRotationEdit | null {
if (!value || typeof value !== "object") return null;
const record = value as Record<string, unknown>;
if (record.kind !== "rotation") return null;
const target = record.target;
if (!target || typeof target !== "object") return null;
const targetRecord = target as Record<string, unknown>;
const sourceFile = typeof targetRecord.sourceFile === "string" ? targetRecord.sourceFile : "";
if (!sourceFile) return null;
const selector = typeof targetRecord.selector === "string" ? targetRecord.selector : undefined;
const id = typeof targetRecord.id === "string" ? targetRecord.id : undefined;
if (!selector && !id) return null;
const angle = finiteNumber(record.angle);
if (angle == null) return null;
return {
kind: "rotation",
target: {
sourceFile,
selector,
selectorIndex: finiteNumber(targetRecord.selectorIndex) ?? undefined,
id,
},
angle,
updatedAt: typeof record.updatedAt === "string" ? record.updatedAt : undefined,
};
}
function parseManualEdit(value: unknown): StudioManualEdit | null {
return parsePathOffsetEdit(value) ?? parseBoxSizeEdit(value) ?? parseRotationEdit(value);
}
export function parseStudioManualEditManifest(content: string): StudioManualEditManifest {
if (!content.trim()) return emptyStudioManualEditManifest();
try {
const parsed = JSON.parse(content) as unknown;
if (!parsed || typeof parsed !== "object") return emptyStudioManualEditManifest();
const edits = (parsed as { edits?: unknown }).edits;
if (!Array.isArray(edits)) return emptyStudioManualEditManifest();
return {
version: 1,
edits: edits.map(parseManualEdit).filter((edit): edit is StudioManualEdit => edit !== null),
};
} catch {
return emptyStudioManualEditManifest();
}
}
export function serializeStudioManualEditManifest(manifest: StudioManualEditManifest): string {
return `${JSON.stringify(manifest, null, 2)}\n`;
export function roundRotationAngle(angle: number): number {
return Math.round(angle * 10) / 10;
}
/* ── File path utilities ──────────────────────────────────────────── */
@@ -172,109 +40,3 @@ function readStudioFileChangePathFromValue(value: unknown): string | null {
export function readStudioFileChangePath(payload: unknown): string | null {
return readStudioFileChangePathFromValue(payload);
}
export function isStudioManualEditManifestPath(path: string | null): boolean {
if (!path) return false;
const normalized = normalizeStudioFileChangePath(path);
return (
normalized === STUDIO_MANUAL_EDITS_PATH || normalized.endsWith(`/${STUDIO_MANUAL_EDITS_PATH}`)
);
}
/* ── Target / upsert helpers ──────────────────────────────────────── */
function selectionTarget(selection: DomEditSelection): StudioManualEditTarget {
return {
sourceFile: selection.sourceFile || "index.html",
selector: selection.selector,
selectorIndex: selection.selectorIndex,
id: selection.id ?? undefined,
};
}
function targetKey(target: StudioManualEditTarget): string {
return [
target.sourceFile,
target.id ?? "",
target.selector ?? "",
target.selectorIndex ?? "",
].join("|");
}
export function roundRotationAngle(angle: number): number {
return Math.round(angle * 10) / 10;
}
export function upsertStudioPathOffsetEdit(
manifest: StudioManualEditManifest,
selection: DomEditSelection,
offset: { x: number; y: number },
): StudioManualEditManifest {
const target = selectionTarget(selection);
const key = targetKey(target);
const nextEdit: StudioPathOffsetEdit = {
kind: "path-offset",
target,
x: Math.round(offset.x),
y: Math.round(offset.y),
updatedAt: new Date().toISOString(),
};
const edits = manifest.edits.filter(
(edit) => edit.kind !== "path-offset" || targetKey(edit.target) !== key,
);
edits.push(nextEdit);
return { version: 1, edits };
}
export function upsertStudioBoxSizeEdit(
manifest: StudioManualEditManifest,
selection: DomEditSelection,
size: { width: number; height: number },
): StudioManualEditManifest {
const target = selectionTarget(selection);
const key = targetKey(target);
const nextEdit: StudioBoxSizeEdit = {
kind: "box-size",
target,
width: Math.round(Math.max(1, size.width)),
height: Math.round(Math.max(1, size.height)),
updatedAt: new Date().toISOString(),
};
const edits = manifest.edits.filter(
(edit) => edit.kind !== "box-size" || targetKey(edit.target) !== key,
);
edits.push(nextEdit);
return { version: 1, edits };
}
export function upsertStudioRotationEdit(
manifest: StudioManualEditManifest,
selection: DomEditSelection,
rotation: { angle: number },
): StudioManualEditManifest {
const target = selectionTarget(selection);
const key = targetKey(target);
const nextEdit: StudioRotationEdit = {
kind: "rotation",
target,
angle: roundRotationAngle(rotation.angle),
updatedAt: new Date().toISOString(),
};
const edits = manifest.edits.filter(
(edit) => edit.kind !== "rotation" || targetKey(edit.target) !== key,
);
edits.push(nextEdit);
return { version: 1, edits };
}
export function removeStudioManualEditsForSelection(
manifest: StudioManualEditManifest,
selection: DomEditSelection,
): StudioManualEditManifest {
const key = targetKey(selectionTarget(selection));
const edits = manifest.edits.filter((edit) => targetKey(edit.target) !== key);
if (edits.length === manifest.edits.length) return manifest;
return { version: 1, edits };
}
@@ -1,5 +1,4 @@
/* ── Public constants ──────────────────────────────────────────────── */
export const STUDIO_MANUAL_EDITS_PATH = ".hyperframes/studio-manual-edits.json";
export const STUDIO_OFFSET_X_PROP = "--hf-studio-offset-x";
export const STUDIO_OFFSET_Y_PROP = "--hf-studio-offset-y";
export const STUDIO_WIDTH_PROP = "--hf-studio-width";
@@ -40,44 +39,6 @@ export const STUDIO_MANUAL_EDITS_PLAYBACK_FRAME_PROP = "__hfStudioManualEditsPla
export const STUDIO_ROTATION_TRANSFORM_ORIGIN = "center center";
/* ── Edit types ───────────────────────────────────────────────────── */
export interface StudioManualEditTarget {
sourceFile: string;
selector?: string;
selectorIndex?: number;
id?: string;
}
export interface StudioPathOffsetEdit {
kind: "path-offset";
target: StudioManualEditTarget;
x: number;
y: number;
updatedAt?: string;
}
export interface StudioBoxSizeEdit {
kind: "box-size";
target: StudioManualEditTarget;
width: number;
height: number;
updatedAt?: string;
}
export interface StudioRotationEdit {
kind: "rotation";
target: StudioManualEditTarget;
angle: number;
updatedAt?: string;
}
export type StudioManualEdit = StudioPathOffsetEdit | StudioBoxSizeEdit | StudioRotationEdit;
export interface StudioManualEditManifest {
version: 1;
edits: StudioManualEdit[];
}
export type StudioManualEditSeekWindow = Window & {
__hf?: Record<string, unknown>;
__player?: Record<string, unknown>;
@@ -87,7 +48,7 @@ export type StudioManualEditSeekWindow = Window & {
__hfStudioManualEditsPlaybackFrame?: number | null;
};
/* ── Snapshot types ───────────────────────────────────────────────── */
/* ── Snapshot types (used by drag/drop restore) ───────────────────── */
export interface StudioBoxSizeSnapshot {
width: string;
height: string;
@@ -23,7 +23,7 @@ import {
restoreStudioPathOffset,
restoreStudioRotation,
} from "./manualEdits";
import { type GroupOverlayItem, type OverlayRect } from "./domEditOverlayGeometry";
import { type GroupOverlayItem, type OverlayRect, toOverlayRect } from "./domEditOverlayGeometry";
import {
BLOCKED_MOVE_THRESHOLD_PX,
type BlockedMoveState,
@@ -43,6 +43,7 @@ import {
// Refs are stable across renders; values are read via .current.
export type UseDomEditOverlayGesturesOptions = {
overlayRef: RefObject<HTMLDivElement | null>;
iframeRef: RefObject<HTMLIFrameElement | null>;
boxRef: RefObject<HTMLDivElement | null>;
selectionRef: RefObject<DomEditSelection | null>;
overlayRectRef: RefObject<OverlayRect | null>;
@@ -194,17 +195,31 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
dy,
uniform: e.shiftKey,
});
applyStudioBoxSizeDraft(sel.element, nextSize);
// Re-read BCR after applying dimensions. For elements with a GSAP
// scale transform and centered transform-origin the visual top-left
// drifts and the visual size diverges from the raw CSS size, so BCR
// is the only accurate source for both.
const overlayEl = opts.overlayRef.current;
const iframe = opts.iframeRef.current;
const refreshed = overlayEl && iframe ? toOverlayRect(overlayEl, iframe, sel.element) : null;
const overlayLeft = refreshed ? refreshed.left : g.originLeft;
const overlayTop = refreshed ? refreshed.top : g.originTop;
const overlayWidth = refreshed ? refreshed.width : nextSize.overlayWidth;
const overlayHeight = refreshed ? refreshed.height : nextSize.overlayHeight;
box.style.left = `${overlayLeft}px`;
box.style.top = `${overlayTop}px`;
box.style.width = `${overlayWidth}px`;
box.style.height = `${overlayHeight}px`;
setDraftOverlayRect({
left: g.originLeft,
top: g.originTop,
width: nextSize.overlayWidth,
height: nextSize.overlayHeight,
left: overlayLeft,
top: overlayTop,
width: overlayWidth,
height: overlayHeight,
editScaleX: g.editScaleX,
editScaleY: g.editScaleY,
});
box.style.width = `${nextSize.overlayWidth}px`;
box.style.height = `${nextSize.overlayHeight}px`;
applyStudioBoxSizeDraft(sel.element, nextSize);
}
};
@@ -281,6 +296,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
box.style.height = `${g.originHeight}px`;
}
restoreGestureOverlayRect(g);
opts.suppressNextBoxClickRef.current = true;
return;
}
@@ -341,6 +357,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
if (g.pathOffsetMember) endManualOffsetDragMembers([g.pathOffsetMember]);
});
} else {
opts.suppressNextBoxClickRef.current = true;
const finalSize = readStudioBoxSize(sel.element);
applyStudioBoxSize(sel.element, finalSize);
void Promise.resolve(opts.onBoxSizeCommitRef.current(sel, finalSize))