mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-07 18:17:29 +00:00
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:
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user