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
+2
View File
@@ -1,3 +1,5 @@
packages/studio/src/player/hooks/useTimelinePlayer.ts
packages/studio/src/hooks/useManifestPersistence.ts
packages/studio/src/player/components/PlayerControls.tsx
packages/studio/src/components/editor/manualEdits.test.ts
packages/studio/src/components/editor/manualEditsDom.ts
@@ -24,6 +24,9 @@ export const MEDIA_VISUAL_STYLE_PROPERTIES = [
"mask-repeat",
"transform",
"transform-origin",
"translate",
"rotate",
"scale",
"box-sizing",
] as const;
+52
View File
@@ -319,4 +319,56 @@ export const coreRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
}
return findings;
},
// pointer_events_none
({ tags, styles }) => {
const findings: HyperframeLintFinding[] = [];
const reported = new Set<string>();
for (const tag of tags) {
if (["script", "style", "link", "meta", "template", "noscript"].includes(tag.name)) continue;
const inlineStyle = readAttr(tag.raw, "style") ?? "";
if (!/pointer-events\s*:\s*none/i.test(inlineStyle)) continue;
const id = readAttr(tag.raw, "id");
const key = id ?? tag.raw;
if (reported.has(key)) continue;
reported.add(key);
findings.push({
code: "pointer_events_none",
severity: "info",
message: `<${tag.name}${id ? ` id="${id}"` : ""}> has \`pointer-events: none\` in its inline style. Elements with this property are harder to select in the Studio preview.`,
elementId: id || undefined,
fixHint:
"If this element should be selectable in the Studio, remove `pointer-events: none` or move it to a wrapper that doesn't contain editable content.",
snippet: truncateSnippet(tag.raw),
});
}
for (const style of styles) {
let root: postcss.Root;
try {
root = postcss.parse(style.content);
} catch {
continue;
}
root.walkDecls("pointer-events", (decl) => {
if (decl.value.trim().toLowerCase() !== "none") return;
const rule = decl.parent;
if (!rule || rule.type !== "rule") return;
const selector = (rule as postcss.Rule).selector;
if (reported.has(selector)) return;
reported.add(selector);
findings.push({
code: "pointer_events_none",
severity: "info",
message: `\`${selector}\` sets \`pointer-events: none\`. Elements matching this selector are harder to select in the Studio preview.`,
selector,
fixHint:
"If these elements should be selectable in the Studio, remove `pointer-events: none` or move it to a wrapper that doesn't contain editable content.",
});
});
}
return findings;
},
];
@@ -12,6 +12,217 @@ export function createStudioManualEditsRenderBodyScript(
return `(${studioManualEditsRenderRuntime.toString()})(${JSON.stringify(manifestContent)}, ${JSON.stringify(options.activeCompositionPath ?? null)});`;
}
/**
* Returns a self-contained IIFE string that re-applies studio position edits
* (translate, rotate) after every GSAP seek by querying data attributes baked
* into the HTML. Works without a JSON manifest — positions are already inlined
* as CSS custom properties on the elements.
*/
export function createStudioPositionSeekReapplyScript(): string {
return `(${studioPositionSeekReapplyRuntime.toString()})();`;
}
function studioPositionSeekReapplyRuntime(): void {
const OFFSET_X_PROP = "--hf-studio-offset-x";
const OFFSET_Y_PROP = "--hf-studio-offset-y";
const ROTATION_PROP = "--hf-studio-rotation";
const PATH_OFFSET_ATTR = "data-hf-studio-path-offset";
const ROTATION_ATTR = "data-hf-studio-rotation";
const ORIGINAL_TRANSLATE_ATTR = "data-hf-studio-original-translate";
const ORIGINAL_ROTATE_ATTR = "data-hf-studio-original-rotate";
const WRAPPED_PROP = "__hfStudioPositionSeekReapplyWrapped";
if (
!document.querySelector("[" + PATH_OFFSET_ATTR + '="true"]') &&
!document.querySelector("[" + ROTATION_ATTR + '="true"]')
)
return;
const splitTopLevelWhitespace = (value: string): string[] => {
const parts: string[] = [];
let depth = 0;
let current = "";
for (const char of value.trim()) {
if (char === "(") depth += 1;
if (char === ")") depth = Math.max(0, depth - 1);
if (/\s/.test(char) && depth === 0) {
if (current) parts.push(current);
current = "";
} else {
current += char;
}
}
if (current) parts.push(current);
return parts;
};
const composeTranslate = (element: HTMLElement, x: string, y: string): string => {
const original = element.getAttribute(ORIGINAL_TRANSLATE_ATTR)?.trim();
if (!original || original === "none") return x + " " + y;
const parts = splitTopLevelWhitespace(original);
if (parts.length === 1) return "calc(" + parts[0] + " + " + x + ") " + y;
if (parts.length >= 2) {
const z = parts.length >= 3 ? " " + parts[2] : "";
return "calc(" + parts[0] + " + " + x + ") calc(" + parts[1] + " + " + y + ")" + z;
}
return x + " " + y;
};
const isSimpleRotateAngle = (value: string): boolean =>
/^-?(?:\d+(?:\.\d+)?|\.\d+)(?:deg|rad|turn|grad)$/.test(value.trim());
const composeRotation = (element: HTMLElement, rotationValue: string): string => {
const original = element.getAttribute(ORIGINAL_ROTATE_ATTR)?.trim();
if (!original || original === "none" || !isSimpleRotateAngle(original)) return rotationValue;
return "calc(" + original + " + " + rotationValue + ")";
};
const reapplyAll = (): void => {
const offsetEls = document.querySelectorAll("[" + PATH_OFFSET_ATTR + '="true"]');
for (let i = 0; i < offsetEls.length; i++) {
const el = offsetEls[i] as HTMLElement;
if (!(el instanceof HTMLElement)) continue;
const x = el.style.getPropertyValue(OFFSET_X_PROP);
const y = el.style.getPropertyValue(OFFSET_Y_PROP);
if (x || y) {
el.style.setProperty(
"translate",
composeTranslate(
el,
"var(" + OFFSET_X_PROP + ", 0px)",
"var(" + OFFSET_Y_PROP + ", 0px)",
),
);
}
}
const rotEls = document.querySelectorAll("[" + ROTATION_ATTR + '="true"]');
for (let i = 0; i < rotEls.length; i++) {
const el = rotEls[i] as HTMLElement;
if (!(el instanceof HTMLElement)) continue;
const rot = el.style.getPropertyValue(ROTATION_PROP);
if (rot) {
el.style.setProperty("rotate", composeRotation(el, "var(" + ROTATION_PROP + ", 0deg)"));
}
}
};
const runtimeWindow = window as Window & {
__hf?: Record<string, unknown>;
__player?: Record<string, unknown>;
};
const isWrapped = (fn: (time: number) => unknown): boolean =>
Boolean((fn as unknown as Record<string, unknown>)[WRAPPED_PROP]);
const markWrapped = (fn: (time: number) => unknown): void => {
try {
Object.defineProperty(fn, WRAPPED_PROP, {
configurable: false,
enumerable: false,
value: true,
});
} catch {
try {
(fn as unknown as Record<string, unknown>)[WRAPPED_PROP] = true;
} catch {
/* ignore */
}
}
};
const wrapFn = (get: () => unknown, set: (fn: (time: number) => unknown) => void): boolean => {
const fn = get();
if (typeof fn !== "function") return false;
const seek = fn as (time: number) => unknown;
if (isWrapped(seek)) {
reapplyAll();
return true;
}
const wrapped = function (this: unknown, time: number): unknown {
const result = seek.call(this, time);
reapplyAll();
return result;
};
markWrapped(wrapped);
set(wrapped);
reapplyAll();
return true;
};
const wrapSeekFunctions = (): boolean => {
const a = wrapFn(
() => runtimeWindow.__hf?.["seek"],
(fn) => {
if (runtimeWindow.__hf) runtimeWindow.__hf["seek"] = fn;
},
);
const b = wrapFn(
() => runtimeWindow.__player?.["renderSeek"],
(fn) => {
if (runtimeWindow.__player) runtimeWindow.__player["renderSeek"] = fn;
},
);
return a || b;
};
const installSeekTrap = (
obj: Record<string, unknown> | undefined,
key: string,
getter: () => unknown,
setter: (fn: (time: number) => unknown) => void,
): void => {
if (!obj) return;
try {
let current = obj[key];
Object.defineProperty(obj, key, {
configurable: true,
enumerable: true,
get() {
return current;
},
set(value: unknown) {
current = value;
if (typeof value === "function" && !isWrapped(value as (time: number) => unknown)) {
wrapFn(getter, setter);
}
},
});
} catch {
/* non-configurable — fall back to polling */
}
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", () => reapplyAll(), { once: true });
} else {
reapplyAll();
}
wrapSeekFunctions();
installSeekTrap(
runtimeWindow.__hf,
"seek",
() => runtimeWindow.__hf?.["seek"],
(fn) => {
if (runtimeWindow.__hf) runtimeWindow.__hf["seek"] = fn;
},
);
installSeekTrap(
runtimeWindow.__player as Record<string, unknown> | undefined,
"renderSeek",
() => runtimeWindow.__player?.["renderSeek"],
(fn) => {
if (runtimeWindow.__player) runtimeWindow.__player["renderSeek"] = fn;
},
);
let remaining = 120;
const interval = setInterval(() => {
wrapSeekFunctions();
remaining -= 1;
if (remaining <= 0) clearInterval(interval);
}, 50);
}
function studioManualEditsRenderRuntime(
manifestContent: string,
activeCompositionPath: string | null,
+1
View File
@@ -8,6 +8,7 @@ export { getElementScreenshotClip, type ScreenshotClip } from "./helpers/screens
export {
STUDIO_MANUAL_EDITS_PATH,
createStudioManualEditsRenderBodyScript,
createStudioPositionSeekReapplyScript,
type StudioManualEditsRenderScriptOptions,
} from "./helpers/manualEditsRenderScript.js";
export {
+26 -9
View File
@@ -143,6 +143,21 @@ async function transformPreviewHtml(
}
}
function resolveProjectMainHtml(
projectDir: string,
projectId: string,
): { html: string; compositionPath: string } | null {
const indexPath = join(projectDir, "index.html");
if (existsSync(indexPath)) {
return { html: readFileSync(indexPath, "utf-8"), compositionPath: "index.html" };
}
const blockHtmlPath = join(projectDir, `${projectId}.html`);
if (existsSync(blockHtmlPath)) {
return { html: readFileSync(blockHtmlPath, "utf-8"), compositionPath: `${projectId}.html` };
}
return null;
}
export function registerPreviewRoutes(api: Hono, adapter: StudioApiAdapter): void {
const previewCacheHeaders = (etag: string) => ({
"Cache-Control": "private, no-cache",
@@ -163,10 +178,12 @@ export function registerPreviewRoutes(api: Hono, adapter: StudioApiAdapter): voi
try {
let bundled = await adapter.bundle(project.dir);
let mainCompositionPath = "index.html";
if (!bundled) {
const indexPath = resolve(project.dir, "index.html");
if (!existsSync(indexPath)) return c.text("not found", 404);
bundled = readFileSync(indexPath, "utf-8");
const main = resolveProjectMainHtml(project.dir, project.id);
if (!main) return c.text("not found", 404);
bundled = main.html;
mainCompositionPath = main.compositionPath;
}
// Inject runtime if not already present (check URL pattern and bundler attribute)
@@ -187,21 +204,21 @@ export function registerPreviewRoutes(api: Hono, adapter: StudioApiAdapter): voi
}
bundled = injectStudioPreviewAugmentations(
await transformPreviewHtml(bundled, adapter, project, "index.html"),
await transformPreviewHtml(bundled, adapter, project, mainCompositionPath),
adapter,
project.dir,
"index.html",
mainCompositionPath,
);
return c.html(bundled, 200, previewCacheHeaders(etag));
} catch {
const file = resolve(project.dir, "index.html");
if (existsSync(file)) {
const main = resolveProjectMainHtml(project.dir, project.id);
if (main) {
return c.html(
injectStudioPreviewAugmentations(
await transformPreviewHtml(readFileSync(file, "utf-8"), adapter, project, "index.html"),
await transformPreviewHtml(main.html, adapter, project, main.compositionPath),
adapter,
project.dir,
"index.html",
main.compositionPath,
),
200,
previewCacheHeaders(etag),
@@ -525,10 +525,16 @@ export async function queryElementStacking(
if (!htmlEl) continue;
mat = mat.translate(htmlEl.offsetLeft, htmlEl.offsetTop);
const cs = window.getComputedStyle(htmlEl);
if (cs.transform && cs.transform !== "none") {
const origin = cs.transformOrigin.split(" ");
const ox = resolveLength(origin[0] ?? "0", htmlEl.offsetWidth);
const oy = resolveLength(origin[1] ?? "0", htmlEl.offsetHeight);
const individualTransform = composeIndividualTransforms(cs);
const hasIndividual = individualTransform !== null;
const hasTransform = cs.transform && cs.transform !== "none";
if (hasIndividual || hasTransform) {
mat = mat.translate(ox, oy);
if (hasIndividual) mat = mat.multiply(individualTransform);
if (hasTransform) {
try {
const t = new DOMMatrix(cs.transform);
if (
@@ -539,16 +545,46 @@ export async function queryElementStacking(
Number.isFinite(t.e) &&
Number.isFinite(t.f)
) {
mat = mat.translate(ox, oy).multiply(t).translate(-ox, -oy);
mat = mat.multiply(t);
}
} catch {
// DOMMatrix constructor throws on malformed input — skip ancestor.
// DOMMatrix constructor throws on malformed input — skip.
}
}
mat = mat.translate(-ox, -oy);
}
}
return mat.toString();
}
function composeIndividualTransforms(cs: CSSStyleDeclaration): DOMMatrix | null {
const translate = cs.getPropertyValue("translate").trim();
const rotate = cs.getPropertyValue("rotate").trim();
const scale = cs.getPropertyValue("scale").trim();
const hasTranslate = translate && translate !== "none";
const hasRotate = rotate && rotate !== "none";
const hasScale = scale && scale !== "none";
if (!hasTranslate && !hasRotate && !hasScale) return null;
let m = new DOMMatrix();
if (hasTranslate) {
const parts = translate.split(/\s+/);
const tx = parseFloat(parts[0] ?? "0") || 0;
const ty = parseFloat(parts[1] ?? "0") || 0;
if (tx !== 0 || ty !== 0) m = m.translate(tx, ty);
}
if (hasRotate) {
const deg = parseFloat(rotate) || 0;
if (deg !== 0) m = m.rotate(deg);
}
if (hasScale) {
const parts = scale.split(/\s+/);
const sx = parseFloat(parts[0] ?? "1") || 1;
const sy = parseFloat(parts[1] ?? String(sx)) || sx;
if (sx !== 1 || sy !== 1) m = m.scale(sx, sy);
}
return m;
}
function resolveLength(value: string, basis: number): number {
if (value.endsWith("%")) {
const pct = parseFloat(value) / 100;
+15 -1
View File
@@ -38,6 +38,7 @@ import {
import { downloadToTemp, isHttpUrl } from "../utils/urlDownloader.js";
import type { Page } from "puppeteer-core";
import { injectDeterministicFontFaces } from "./deterministicFonts.js";
import { createStudioPositionSeekReapplyScript } from "@hyperframes/core/studio-api/manual-edits-render-script";
export interface CompiledComposition {
html: string;
@@ -993,7 +994,20 @@ export async function compileForRender(
// Collect assets that resolve outside projectDir (e.g. ../shared-assets/hero.png).
// These can't be served by the file server, so we map them to paths the
// orchestrator will copy into the compiled output directory.
const { html, externalAssets } = collectExternalAssets(assembledHtml, projectDir);
const { html: htmlWithAssets, externalAssets } = collectExternalAssets(assembledHtml, projectDir);
// Inject studio position seek re-apply script when positions are baked into HTML.
// GSAP overwrites the `translate` CSS property on every frame seek; this script
// re-asserts the CSS custom property var() form after each seek so dragged
// positions survive frame-by-frame rendering without a JSON sidecar.
const HF_POSITION_ATTRS = ['data-hf-studio-path-offset="true"', 'data-hf-studio-rotation="true"'];
const hasPositionEdits = HF_POSITION_ATTRS.some((attr) => htmlWithAssets.includes(attr));
const html = hasPositionEdits
? htmlWithAssets.replace(
/<\/body>/i,
`<script>${createStudioPositionSeekReapplyScript()}</script></body>`,
)
: htmlWithAssets;
// Parse main HTML elements
const mainVideos = parseVideoElements(html);
+3 -4
View File
@@ -144,6 +144,8 @@ export function StudioApp() {
recordEdit: editHistory.recordEdit,
previewIframeRef,
activeCompPathRef,
domEditSaveTimestampRef,
reloadPreview: () => setRefreshKey((k) => k + 1),
});
const timelineEditing = useTimelineEditing({
@@ -197,12 +199,9 @@ export function StudioApp() {
setRightPanelTab: panelLayout.setRightPanelTab,
showToast,
refreshPreviewDocumentVersion,
commitStudioManualEditManifestOptimistically:
manifestPersistence.commitStudioManualEditManifestOptimistically,
queueDomEditSave: manifestPersistence.queueDomEditSave,
commitStudioMotionManifestOptimistically:
manifestPersistence.commitStudioMotionManifestOptimistically,
applyCurrentStudioManualEditsToPreview:
manifestPersistence.applyCurrentStudioManualEditsToPreview,
applyCurrentStudioMotionToPreview: manifestPersistence.applyCurrentStudioMotionToPreview,
readProjectFile: fileManager.readProjectFile,
writeProjectFile: fileManager.writeProjectFile,
@@ -59,7 +59,6 @@ export function StudioRightPanel({
handleDomTextFieldStyleCommit,
handleDomAddTextField,
handleDomRemoveTextField,
handleDomManualEditsReset,
handleAskAgent,
handleDomMotionCommit,
handleDomMotionClear,
@@ -173,7 +172,6 @@ export function StudioRightPanel({
onSetTextFieldStyle={handleDomTextFieldStyleCommit}
onAddTextField={handleDomAddTextField}
onRemoveTextField={handleDomRemoveTextField}
onResetManualEdits={handleDomManualEditsReset}
onAskAgent={handleAskAgent}
onImportAssets={handleImportFiles}
fontAssets={fontAssets}
@@ -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,7 +144,8 @@ 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">
<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" />
@@ -154,8 +153,8 @@ export const PropertyPanel = memo(function PropertyPanel({
{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.
Select a single element to edit its properties. Click an element in the preview or
use the timeline layer panel.
</p>
</>
) : (
@@ -165,12 +164,13 @@ export const PropertyPanel = memo(function PropertyPanel({
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.
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))
+1 -4
View File
@@ -3,7 +3,6 @@ import { usePlayerStore } from "../player";
import type { TimelineElement } from "../player";
import type { DomEditSelection } from "../components/editor/domEditing";
import type { LeftSidebarHandle } from "../components/sidebar/LeftSidebar";
import { STUDIO_MANUAL_EDITS_PATH } from "../components/editor/manualEdits";
import { STUDIO_MOTION_PATH } from "../components/editor/studioMotion";
import { shouldHandleTimelineToggleHotkey, isEditableTarget } from "../utils/timelineDiscovery";
import { shouldIgnoreHistoryShortcut } from "../utils/studioHelpers";
@@ -85,9 +84,7 @@ export function useAppHotkeys({
const readHistoryProjectFile = useCallback(
async (path: string): Promise<string> => {
return path === STUDIO_MANUAL_EDITS_PATH || path === STUDIO_MOTION_PATH
? readOptionalProjectFile(path)
: readProjectFile(path);
return path === STUDIO_MOTION_PATH ? readOptionalProjectFile(path) : readProjectFile(path);
},
[readOptionalProjectFile, readProjectFile],
);
+74 -69
View File
@@ -6,12 +6,21 @@ import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
import { primaryFontFamilyValue } from "../utils/studioFontHelpers";
import { getDomEditTargetKey, type DomEditSelection } from "../components/editor/domEditing";
import {
removeStudioManualEditsForSelection,
type StudioManualEditManifest,
upsertStudioBoxSizeEdit,
upsertStudioPathOffsetEdit,
upsertStudioRotationEdit,
applyStudioPathOffset,
applyStudioBoxSize,
applyStudioRotation,
clearStudioPathOffset,
clearStudioBoxSize,
clearStudioRotation,
} from "../components/editor/manualEdits";
import {
buildPathOffsetPatches,
buildBoxSizePatches,
buildRotationPatches,
buildClearPathOffsetPatches,
buildClearBoxSizePatches,
buildClearRotationPatches,
} from "../components/editor/manualEditsDom";
import {
removeStudioMotionForSelection,
type StudioGsapMotion,
@@ -48,15 +57,11 @@ export interface UseDomEditCommitsParams {
activeCompPath: string | null;
previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
showToast: (message: string, tone?: "error" | "info") => void;
commitStudioManualEditManifestOptimistically: (
updateManifest: (manifest: StudioManualEditManifest) => StudioManualEditManifest,
options: { label: string; coalesceKey: string },
) => void;
queueDomEditSave: (save: () => Promise<void>) => Promise<void>;
commitStudioMotionManifestOptimistically: (
updateManifest: (manifest: StudioMotionManifest) => StudioMotionManifest,
options: { label: string; coalesceKey: string },
) => void;
applyCurrentStudioManualEditsToPreview: (iframe: HTMLIFrameElement | null) => void;
applyCurrentStudioMotionToPreview: (iframe: HTMLIFrameElement | null) => void;
writeProjectFile: (path: string, content: string) => Promise<void>;
domEditSaveTimestampRef: React.MutableRefObject<number>;
@@ -69,15 +74,12 @@ export interface UseDomEditCommitsParams {
// From useDomSelection
domEditSelection: DomEditSelection | null;
domEditSelectionRef: React.MutableRefObject<DomEditSelection | null>;
domEditGroupSelectionsRef: React.MutableRefObject<DomEditSelection[]>;
applyDomSelection: (
selection: DomEditSelection | null,
options?: { revealPanel?: boolean; additive?: boolean; preserveGroup?: boolean },
) => void;
clearDomSelection: () => void;
refreshDomEditSelectionFromPreview: (selection: DomEditSelection) => void;
refreshDomEditGroupSelectionsFromPreview: (selections: DomEditSelection[]) => void;
buildDomSelectionFromTarget: (
target: HTMLElement,
options?: { preferClipAncestor?: boolean },
@@ -90,9 +92,8 @@ export function useDomEditCommits({
activeCompPath,
previewIframeRef,
showToast,
commitStudioManualEditManifestOptimistically,
queueDomEditSave,
commitStudioMotionManifestOptimistically,
applyCurrentStudioManualEditsToPreview,
applyCurrentStudioMotionToPreview,
writeProjectFile,
domEditSaveTimestampRef,
@@ -103,11 +104,9 @@ export function useDomEditCommits({
projectIdRef,
reloadPreview,
domEditSelection,
domEditGroupSelectionsRef,
applyDomSelection,
clearDomSelection,
refreshDomEditSelectionFromPreview,
refreshDomEditGroupSelectionsFromPreview,
buildDomSelectionFromTarget,
}: UseDomEditCommitsParams) {
const resolveImportedFontAsset = useCallback(
@@ -211,97 +210,103 @@ export function useDomEditCommits({
resolveImportedFontAsset,
});
// ── Manifest commits ──
// ── Position patch helper ──
const commitPositionPatchToHtml = useCallback(
(
selection: DomEditSelection,
patches: Parameters<typeof applyPatchByTarget>[2][],
options: { label: string; coalesceKey: string; skipRefresh?: boolean },
) => {
void queueDomEditSave(async () => {
await persistDomEditOperations(selection, patches, {
label: options.label,
coalesceKey: options.coalesceKey,
skipRefresh: options.skipRefresh ?? true,
});
}).catch((error) => {
const message = error instanceof Error ? error.message : "Failed to save position";
showToast(message);
});
},
[persistDomEditOperations, queueDomEditSave, showToast],
);
// ── Position commits ──
const handleDomPathOffsetCommit = useCallback(
(selection: DomEditSelection, next: { x: number; y: number }) => {
commitStudioManualEditManifestOptimistically(
(manifest) => upsertStudioPathOffsetEdit(manifest, selection, next),
{
applyStudioPathOffset(selection.element, next);
commitPositionPatchToHtml(selection, buildPathOffsetPatches(selection.element), {
label: "Move layer",
coalesceKey: `path-offset:${getDomEditTargetKey(selection)}`,
});
},
);
refreshDomEditSelectionFromPreview(selection);
},
[commitStudioManualEditManifestOptimistically, refreshDomEditSelectionFromPreview],
[commitPositionPatchToHtml],
);
const handleDomGroupPathOffsetCommit = useCallback(
(updates: DomEditGroupPathOffsetCommit[]) => {
if (updates.length === 0) return;
const coalesceKey = updates
.map((update) => getDomEditTargetKey(update.selection))
.map((u) => getDomEditTargetKey(u.selection))
.sort()
.join(":");
commitStudioManualEditManifestOptimistically(
(manifest) =>
updates.reduce(
(nextManifest, update) =>
upsertStudioPathOffsetEdit(nextManifest, update.selection, update.next),
manifest,
),
{
for (const { selection, next } of updates) {
applyStudioPathOffset(selection.element, next);
commitPositionPatchToHtml(selection, buildPathOffsetPatches(selection.element), {
label: `Move ${updates.length} layers`,
coalesceKey: `group-path-offset:${coalesceKey}`,
});
}
},
);
refreshDomEditGroupSelectionsFromPreview(domEditGroupSelectionsRef.current);
},
[
commitStudioManualEditManifestOptimistically,
domEditGroupSelectionsRef,
refreshDomEditGroupSelectionsFromPreview,
],
[commitPositionPatchToHtml],
);
const handleDomBoxSizeCommit = useCallback(
(selection: DomEditSelection, next: { width: number; height: number }) => {
commitStudioManualEditManifestOptimistically(
(manifest) => upsertStudioBoxSizeEdit(manifest, selection, next),
{
applyStudioBoxSize(selection.element, next);
commitPositionPatchToHtml(selection, buildBoxSizePatches(selection.element), {
label: "Resize layer box",
coalesceKey: `box-size:${getDomEditTargetKey(selection)}`,
});
},
);
refreshDomEditSelectionFromPreview(selection);
},
[commitStudioManualEditManifestOptimistically, refreshDomEditSelectionFromPreview],
[commitPositionPatchToHtml],
);
const handleDomRotationCommit = useCallback(
(selection: DomEditSelection, next: { angle: number }) => {
commitStudioManualEditManifestOptimistically(
(manifest) => upsertStudioRotationEdit(manifest, selection, next),
{
applyStudioRotation(selection.element, next);
commitPositionPatchToHtml(selection, buildRotationPatches(selection.element), {
label: "Rotate layer",
coalesceKey: `rotation:${getDomEditTargetKey(selection)}`,
});
},
);
refreshDomEditSelectionFromPreview(selection);
},
[commitStudioManualEditManifestOptimistically, refreshDomEditSelectionFromPreview],
[commitPositionPatchToHtml],
);
const handleDomManualEditsReset = useCallback(
(selection: DomEditSelection) => {
commitStudioManualEditManifestOptimistically(
(manifest) => removeStudioManualEditsForSelection(manifest, selection),
{
const element = selection.element;
const clearPatches = [
...buildClearPathOffsetPatches(element),
...buildClearBoxSizePatches(element),
...buildClearRotationPatches(element),
];
clearStudioPathOffset(element);
clearStudioBoxSize(element);
clearStudioRotation(element);
// skipRefresh:false triggers reloadPreview() which re-syncs selection on load
commitPositionPatchToHtml(selection, clearPatches, {
label: "Reset layer edits",
coalesceKey: `manual-reset:${getDomEditTargetKey(selection)}`,
skipRefresh: false,
});
},
[commitPositionPatchToHtml],
);
applyCurrentStudioManualEditsToPreview(previewIframeRef.current);
refreshDomEditSelectionFromPreview(selection);
},
[
applyCurrentStudioManualEditsToPreview,
commitStudioManualEditManifestOptimistically,
refreshDomEditSelectionFromPreview,
previewIframeRef,
],
);
// ── Motion commits ──
const handleDomMotionCommit = useCallback(
(
+3 -16
View File
@@ -2,7 +2,6 @@ import { useEffect } from "react";
import type { TimelineElement } from "../player";
import { STUDIO_INSPECTOR_PANELS_ENABLED } from "../components/editor/manualEditingAvailability";
import { findElementForSelection } from "../components/editor/domEditing";
import type { StudioManualEditManifest } from "../components/editor/manualEdits";
import type { StudioMotionManifest } from "../components/editor/studioMotion";
import type { ImportedFontAsset } from "../components/editor/fontAssets";
import type { EditHistoryKind } from "../utils/editHistory";
@@ -36,15 +35,11 @@ export interface UseDomEditSessionParams {
setRightPanelTab: (tab: RightPanelTab) => void;
showToast: (message: string, tone?: "error" | "info") => void;
refreshPreviewDocumentVersion: () => void;
commitStudioManualEditManifestOptimistically: (
updateManifest: (manifest: StudioManualEditManifest) => StudioManualEditManifest,
options: { label: string; coalesceKey: string },
) => void;
queueDomEditSave: (save: () => Promise<void>) => Promise<void>;
commitStudioMotionManifestOptimistically: (
updateManifest: (manifest: StudioMotionManifest) => StudioMotionManifest,
options: { label: string; coalesceKey: string },
) => void;
applyCurrentStudioManualEditsToPreview: (iframe: HTMLIFrameElement | null) => void;
applyCurrentStudioMotionToPreview: (iframe: HTMLIFrameElement | null) => void;
readProjectFile: (path: string) => Promise<string>;
writeProjectFile: (path: string, content: string) => Promise<void>;
@@ -85,9 +80,8 @@ export function useDomEditSession({
setRightPanelTab,
showToast,
refreshPreviewDocumentVersion,
commitStudioManualEditManifestOptimistically,
queueDomEditSave,
commitStudioMotionManifestOptimistically,
applyCurrentStudioManualEditsToPreview,
applyCurrentStudioMotionToPreview,
readProjectFile: _readProjectFile,
writeProjectFile,
@@ -114,7 +108,6 @@ export function useDomEditSession({
domEditGroupSelections,
domEditHoverSelection,
domEditSelectionRef,
domEditGroupSelectionsRef,
applyDomSelection,
clearDomSelection,
buildDomSelectionFromTarget,
@@ -123,7 +116,6 @@ export function useDomEditSession({
buildDomSelectionForTimelineElement,
handleTimelineElementSelect,
refreshDomEditSelectionFromPreview,
refreshDomEditGroupSelectionsFromPreview,
} = useDomSelection({
projectId,
activeCompPath,
@@ -176,7 +168,6 @@ export function useDomEditSession({
captionEditMode,
compositionLoading,
previewIframeRef,
activeCompPath,
showToast,
applyDomSelection,
resolveDomSelectionFromPreviewPoint,
@@ -208,9 +199,8 @@ export function useDomEditSession({
activeCompPath,
previewIframeRef,
showToast,
commitStudioManualEditManifestOptimistically,
queueDomEditSave,
commitStudioMotionManifestOptimistically,
applyCurrentStudioManualEditsToPreview,
applyCurrentStudioMotionToPreview,
writeProjectFile,
domEditSaveTimestampRef,
@@ -221,12 +211,9 @@ export function useDomEditSession({
projectIdRef,
reloadPreview,
domEditSelection,
domEditSelectionRef,
domEditGroupSelectionsRef,
applyDomSelection,
clearDomSelection,
refreshDomEditSelectionFromPreview,
refreshDomEditGroupSelectionsFromPreview,
buildDomSelectionFromTarget,
});
@@ -1,15 +1,9 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useMountEffect } from "./useMountEffect";
import {
STUDIO_MANUAL_EDITS_PATH,
applyStudioManualEditManifest,
emptyStudioManualEditManifest,
installStudioManualEditSeekReapply,
isStudioManualEditManifestPath,
parseStudioManualEditManifest,
reapplyPositionEditsAfterSeek,
readStudioFileChangePath,
serializeStudioManualEditManifest,
type StudioManualEditManifest,
} from "../components/editor/manualEdits";
import {
STUDIO_MOTION_PATH,
@@ -41,6 +35,11 @@ interface UseManifestPersistenceParams {
recordEdit: (entry: RecordEditInput) => Promise<void>;
previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
activeCompPathRef: React.MutableRefObject<string | null>;
/** Shared timestamp ref written by any studio save (code tab, timeline, DOM edits).
* Used to suppress SSE echoes so we don't double-reload after our own saves. */
domEditSaveTimestampRef: React.MutableRefObject<number>;
/** Called to reload the preview after undo/redo or external file changes. */
reloadPreview: () => void;
}
// ── Hook ──
@@ -48,21 +47,19 @@ interface UseManifestPersistenceParams {
export function useManifestPersistence({
projectId,
showToast,
readOptionalProjectFile,
readOptionalProjectFile: _readOptionalProjectFile,
writeProjectFile,
recordEdit,
previewIframeRef,
activeCompPathRef,
domEditSaveTimestampRef,
reloadPreview,
}: UseManifestPersistenceParams) {
const [, setStudioMotionRevision] = useState(0);
void _readOptionalProjectFile;
const domEditSaveTimestampRef = useRef(0);
const [, setStudioMotionRevision] = useState(0);
const domTextCommitVersionRef = useRef(0);
const domEditSaveQueueRef = useRef(Promise.resolve());
const studioManualEditManifestRef = useRef<StudioManualEditManifest>(
emptyStudioManualEditManifest(),
);
const studioManualEditRevisionRef = useRef(0);
const studioMotionManifestRef = useRef<StudioMotionManifest>(emptyStudioMotionManifest());
const studioMotionRevisionRef = useRef(0);
const applyStudioManualEditsToPreviewRef = useRef<
@@ -77,9 +74,7 @@ export function useManifestPersistence({
options?: { forceFromDisk?: boolean; readFromDiskFirst?: boolean },
) => Promise<void>
>(async () => {});
const manifestBootstrappedRef = useRef(false);
const motionBootstrappedRef = useRef(false);
const studioManualEditProjectRef = useRef<string | null>(projectId);
// Keep a ref to the latest projectId so async save callbacks always read the
// current value, even when the callback was captured in a stale closure.
@@ -101,7 +96,7 @@ export function useManifestPersistence({
await domEditSaveQueueRef.current.catch(() => undefined);
}, []);
// ── Apply manual edits ──
// ── Apply manual edits (HTML-baked — just install seek hooks) ──
const applyCurrentStudioManualEditsToPreview = useCallback(
(iframe: HTMLIFrameElement | null = previewIframeRef.current) => {
@@ -113,68 +108,38 @@ export function useManifestPersistence({
return;
}
if (!doc) return;
const previewDoc = doc;
const applyManifest = () => {
applyStudioManualEditManifest(
previewDoc,
studioManualEditManifestRef.current,
activeCompPathRef.current,
);
};
const applyAndInstallSeekHooks = () => {
applyManifest();
if (iframe.contentWindow) {
installStudioManualEditSeekReapply(iframe.contentWindow, applyManifest);
const reapply = () => {
let d: Document | null = null;
try {
d = iframe.contentDocument;
} catch {
return;
}
if (d) reapplyPositionEditsAfterSeek(d);
};
const install = () => {
reapply();
if (iframe.contentWindow) installStudioManualEditSeekReapply(iframe.contentWindow, reapply);
};
const win = iframe.contentWindow;
applyAndInstallSeekHooks();
win?.requestAnimationFrame?.(applyAndInstallSeekHooks);
win?.setTimeout?.(applyAndInstallSeekHooks, 80);
win?.setTimeout?.(applyAndInstallSeekHooks, 250);
win?.setTimeout?.(applyAndInstallSeekHooks, 500);
win?.setTimeout?.(applyAndInstallSeekHooks, 1000);
win?.setTimeout?.(applyAndInstallSeekHooks, 2000);
install();
win?.requestAnimationFrame?.(install);
win?.setTimeout?.(install, 80);
win?.setTimeout?.(install, 250);
win?.setTimeout?.(install, 500);
win?.setTimeout?.(install, 1000);
win?.setTimeout?.(install, 2000);
},
[activeCompPathRef, previewIframeRef],
[previewIframeRef],
);
const applyStudioManualEditsToPreview = useCallback(
async (
iframe: HTMLIFrameElement | null = previewIframeRef.current,
options?: { forceFromDisk?: boolean; readFromDiskFirst?: boolean },
) => {
// Bootstrap from disk on first apply per session; explicit flag avoids
// re-reading disk after the user deletes all edits (async write race).
const needsBootstrap = !manifestBootstrappedRef.current;
if (needsBootstrap) manifestBootstrappedRef.current = true;
const readFromDiskFirst = Boolean(
options?.forceFromDisk || options?.readFromDiskFirst || needsBootstrap,
);
if (!readFromDiskFirst) {
applyCurrentStudioManualEditsToPreview(iframe);
return;
}
const readRevision = studioManualEditRevisionRef.current;
let content: string;
try {
content = await readOptionalProjectFile(STUDIO_MANUAL_EDITS_PATH);
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to read manual edit manifest";
showToast(message);
applyCurrentStudioManualEditsToPreview(iframe);
return;
}
if (options?.forceFromDisk || readRevision === studioManualEditRevisionRef.current) {
studioManualEditManifestRef.current = parseStudioManualEditManifest(content);
if (options?.forceFromDisk) studioManualEditRevisionRef.current += 1;
}
async (iframe: HTMLIFrameElement | null = previewIframeRef.current) => {
applyCurrentStudioManualEditsToPreview(iframe);
},
[applyCurrentStudioManualEditsToPreview, previewIframeRef, readOptionalProjectFile, showToast],
[applyCurrentStudioManualEditsToPreview, previewIframeRef],
);
applyStudioManualEditsToPreviewRef.current = applyStudioManualEditsToPreview;
@@ -230,7 +195,7 @@ export function useManifestPersistence({
const readRevision = studioMotionRevisionRef.current;
let content: string;
try {
content = await readOptionalProjectFile(STUDIO_MOTION_PATH);
content = await _readOptionalProjectFile(STUDIO_MOTION_PATH);
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to read motion manifest";
showToast(message);
@@ -244,80 +209,11 @@ export function useManifestPersistence({
}
applyCurrentStudioMotionToPreview(iframe);
},
[applyCurrentStudioMotionToPreview, previewIframeRef, readOptionalProjectFile, showToast],
[applyCurrentStudioMotionToPreview, previewIframeRef, _readOptionalProjectFile, showToast],
);
applyStudioMotionToPreviewRef.current = applyStudioMotionToPreview;
// ── Optimistic commits ──
const commitStudioManualEditManifestOptimistically = useCallback(
(
updateManifest: (manifest: StudioManualEditManifest) => StudioManualEditManifest,
options: { label: string; coalesceKey: string },
) => {
const previousManifest = studioManualEditManifestRef.current;
const nextManifest = updateManifest(previousManifest);
const previousContent = serializeStudioManualEditManifest(previousManifest);
const nextContent = serializeStudioManualEditManifest(nextManifest);
if (nextContent === previousContent) {
return;
}
const revision = studioManualEditRevisionRef.current + 1;
studioManualEditRevisionRef.current = revision;
studioManualEditManifestRef.current = nextManifest;
applyCurrentStudioManualEditsToPreview(previewIframeRef.current);
const save = async () => {
const originalContent = await readOptionalProjectFile(STUDIO_MANUAL_EDITS_PATH);
const diskManifest = parseStudioManualEditManifest(originalContent);
const nextDiskManifest = updateManifest(diskManifest);
const nextDiskContent = serializeStudioManualEditManifest(nextDiskManifest);
if (nextDiskContent === originalContent) {
return;
}
const pid = projectIdRef.current;
if (!pid) throw new Error("No active project");
domEditSaveTimestampRef.current = Date.now();
await saveProjectFilesWithHistory({
projectId: pid,
label: options.label,
kind: "manual",
coalesceKey: options.coalesceKey,
files: { [STUDIO_MANUAL_EDITS_PATH]: nextDiskContent },
readFile: async () => originalContent,
writeFile: writeProjectFile,
recordEdit,
});
domEditSaveTimestampRef.current = Date.now();
if (studioManualEditRevisionRef.current === revision) {
studioManualEditManifestRef.current = nextDiskManifest;
applyCurrentStudioManualEditsToPreview(previewIframeRef.current);
}
};
void queueDomEditSave(save).catch((error) => {
if (studioManualEditRevisionRef.current === revision) {
studioManualEditRevisionRef.current += 1;
studioManualEditManifestRef.current = previousManifest;
applyCurrentStudioManualEditsToPreview(previewIframeRef.current);
}
const message = error instanceof Error ? error.message : "Failed to save manual edit";
showToast(message);
});
},
[
applyCurrentStudioManualEditsToPreview,
recordEdit,
queueDomEditSave,
readOptionalProjectFile,
showToast,
writeProjectFile,
previewIframeRef,
],
);
// ── Optimistic motion commit ──
const commitStudioMotionManifestOptimistically = useCallback(
(
@@ -339,7 +235,7 @@ export function useManifestPersistence({
applyCurrentStudioMotionToPreview(previewIframeRef.current);
const save = async () => {
const originalContent = await readOptionalProjectFile(STUDIO_MOTION_PATH);
const originalContent = await _readOptionalProjectFile(STUDIO_MOTION_PATH);
const diskManifest = parseStudioMotionManifest(originalContent);
const nextDiskManifest = updateManifest(diskManifest);
const nextDiskContent = serializeStudioMotionManifest(nextDiskManifest);
@@ -384,10 +280,11 @@ export function useManifestPersistence({
applyCurrentStudioMotionToPreview,
recordEdit,
queueDomEditSave,
readOptionalProjectFile,
_readOptionalProjectFile,
showToast,
writeProjectFile,
previewIframeRef,
domEditSaveTimestampRef,
],
);
@@ -396,69 +293,40 @@ export function useManifestPersistence({
const syncHistoryPreviewAfterApply = useCallback(
async (paths: string[] | undefined) => {
const changedPaths = paths ?? [];
const manualManifestOnly =
changedPaths.length > 0 && changedPaths.every((path) => path === STUDIO_MANUAL_EDITS_PATH);
const motionManifestOnly =
changedPaths.length > 0 && changedPaths.every((path) => path === STUDIO_MOTION_PATH);
if (manualManifestOnly) {
await applyStudioManualEditsToPreview(previewIframeRef.current, { forceFromDisk: true });
return;
}
if (motionManifestOnly) {
await applyStudioMotionToPreview(previewIframeRef.current, { forceFromDisk: true });
return;
}
// Reload the iframe in-place rather than recreating the Player component.
// This preserves the <hyperframes-player> web component and its shader
// transition cache — only the iframe document reloads, so transitions that
// weren't touched by the undo/redo don't need to rebuild from scratch.
const iframe = previewIframeRef.current;
if (iframe?.contentWindow) {
try {
iframe.contentWindow.location.reload();
return;
} catch {
// Cross-origin or detached — fall through to full refresh
}
}
// Reload via refreshKey so NLELayout saves seek position before the iframe reloads.
reloadPreview();
},
[applyStudioManualEditsToPreview, applyStudioMotionToPreview, previewIframeRef],
[applyStudioMotionToPreview, previewIframeRef, reloadPreview],
);
// ── Reset manifests when project changes ──
const projectTrackerRef = useRef<string | null>(projectId);
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
const previousProjectId = studioManualEditProjectRef.current;
studioManualEditProjectRef.current = projectId;
const previousProjectId = projectTrackerRef.current;
projectTrackerRef.current = projectId;
if (!previousProjectId || previousProjectId === projectId) return;
studioManualEditManifestRef.current = emptyStudioManualEditManifest();
studioManualEditRevisionRef.current += 1;
studioMotionManifestRef.current = emptyStudioMotionManifest();
studioMotionRevisionRef.current += 1;
setStudioMotionRevision((revision) => revision + 1);
manifestBootstrappedRef.current = motionBootstrappedRef.current = false;
motionBootstrappedRef.current = false;
}, [projectId]);
// ── Listen for external file changes (HMR / SSE) ──
// In dev: use Vite HMR. In embedded/production: use SSE from /api/events.
// Suppress file-change events that echo back from a recent DOM edit save —
// those changes are already applied to the iframe DOM and a full reload
// would flash the preview.
useMountEffect(() => {
const handler = (payload?: unknown) => {
const changedPath = readStudioFileChangePath(payload);
const recentDomEditSave = Date.now() - domEditSaveTimestampRef.current < 1200;
if (isStudioManualEditManifestPath(changedPath)) {
if (!recentDomEditSave) {
void applyStudioManualEditsToPreviewRef.current(previewIframeRef.current, {
forceFromDisk: true,
});
}
return;
}
if (isStudioMotionManifestPath(changedPath)) {
if (!recentDomEditSave) {
void applyStudioMotionToPreviewRef.current(previewIframeRef.current, {
@@ -467,9 +335,10 @@ export function useManifestPersistence({
}
return;
}
// Non-manifest file changes are not handled here — the caller is
// responsible for triggering a preview refresh via onExternalFileChange
// if needed. This hook only suppresses echoes and handles manifest reloads.
// Non-motion external file change — reload unless it's an echo of our own save.
if (!recentDomEditSave) {
reloadPreview();
}
};
if (import.meta.hot) {
import.meta.hot.on("hf:file-change", handler);
@@ -482,23 +351,18 @@ export function useManifestPersistence({
});
return {
domEditSaveTimestampRef,
domTextCommitVersionRef,
domEditSaveQueueRef,
studioManualEditManifestRef,
studioManualEditRevisionRef,
studioMotionManifestRef,
studioMotionRevisionRef,
applyStudioManualEditsToPreviewRef,
applyStudioMotionToPreviewRef,
studioManualEditProjectRef,
queueDomEditSave,
waitForPendingDomEditSaves,
applyCurrentStudioManualEditsToPreview,
applyStudioManualEditsToPreview,
applyCurrentStudioMotionToPreview,
applyStudioMotionToPreview,
commitStudioManualEditManifestOptimistically,
commitStudioMotionManifestOptimistically,
syncHistoryPreviewAfterApply,
};
@@ -18,7 +18,6 @@ export interface UsePreviewInteractionParams {
captionEditMode: boolean;
compositionLoading: boolean;
previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
activeCompPath: string | null;
showToast: (message: string, tone?: "error" | "info") => void;
// From useDomSelection
+49 -13
View File
@@ -71,7 +71,7 @@ function splitInlineStyleDeclarations(style: string): string[] {
export interface PatchOperation {
type: "inline-style" | "attribute" | "text-content";
property: string;
value: string;
value: string | null;
}
export interface PatchTarget {
@@ -133,7 +133,12 @@ export function resolveSourceFile(
/**
* Apply a style property change to an element's inline style in the HTML source.
*/
function patchInlineStyle(html: string, elementId: string, prop: string, value: string): string {
function patchInlineStyle(
html: string,
elementId: string,
prop: string,
value: string | null,
): string {
// Find the element tag with this id
const idPattern = new RegExp(`(<[^>]*\\bid=(["'])${escapeRegex(elementId)}\\2[^>]*)>`, "i");
const match = idPattern.exec(html);
@@ -143,7 +148,12 @@ function patchInlineStyle(html: string, elementId: string, prop: string, value:
return patchInlineStyleInTag(html, tag, prop, value);
}
function patchInlineStyleInTag(html: string, tag: string, prop: string, value: string): string {
function patchInlineStyleInTag(
html: string,
tag: string,
prop: string,
value: string | null,
): string {
if (!tag) return html;
// Check if there's an existing style attribute
@@ -160,16 +170,22 @@ function patchInlineStyleInTag(html: string, tag: string, prop: string, value: s
const val = part.slice(colon + 1).trim();
if (key) props.set(key, val);
}
// Update/add the property
// Update/add or remove the property
if (value === null) {
props.delete(prop);
} else {
props.set(prop, value);
// Rebuild style string
}
// Rebuild style string; keep style="" if empty (harmless)
const newStyle = Array.from(props.entries())
.map(([k, v]) => `${k}: ${escapeStyleAttributeValue(v, quote)}`)
.join("; ");
const newTag = tag.replace(styleMatch[0], `style=${quote}${newStyle}${quote}`);
return html.replace(tag, newTag);
} else {
// No existing style — add one
// No existing style attribute
if (value === null) return html; // nothing to remove
// Add one
const newTag =
tag.replace(/>$/, "") + ` style="${prop}: ${escapeStyleAttributeValue(value, '"')}"`;
return html.replace(tag, newTag);
@@ -180,7 +196,7 @@ function patchInlineStyleByTarget(
html: string,
target: PatchTarget,
prop: string,
value: string,
value: string | null,
): string {
const match = findTagByTarget(html, target);
if (!match) return html;
@@ -277,15 +293,23 @@ function patchAttributeByTarget(
html: string,
target: PatchTarget,
attr: string,
value: string,
value: string | null,
): string {
const match = findTagByTarget(html, target);
if (!match) return html;
const fullAttr = attr.startsWith("data-") ? attr : `data-${attr}`;
const attrPattern = new RegExp(`\\b${fullAttr}=(["'])([^"']*)\\1`);
const attrPattern = new RegExp(`\\b${escapeRegex(fullAttr)}=(["'])([^"']*)\\1`);
const tag = match.tag;
if (value === null) {
// Remove the attribute if present
if (!attrPattern.test(tag)) return html;
const removePattern = new RegExp(`\\s+${escapeRegex(fullAttr)}=(["'])[^"']*\\1`);
const newTag = tag.replace(removePattern, "");
return replaceTagAtMatch(html, match, newTag);
}
if (attrPattern.test(tag)) {
const newTag = tag.replace(attrPattern, `${fullAttr}="${value}"`);
return replaceTagAtMatch(html, match, newTag);
@@ -298,14 +322,26 @@ function patchAttributeByTarget(
/**
* Apply an attribute change to an element in the HTML source.
*/
function patchAttribute(html: string, elementId: string, attr: string, value: string): string {
function patchAttribute(
html: string,
elementId: string,
attr: string,
value: string | null,
): string {
const idPattern = new RegExp(`(<[^>]*\\bid=(["'])${escapeRegex(elementId)}\\2[^>]*)>`, "i");
const match = idPattern.exec(html);
if (!match) return html;
const tag = match[1];
const fullAttr = attr.startsWith("data-") ? attr : `data-${attr}`;
const attrPattern = new RegExp(`\\b${fullAttr}=(["'])([^"']*)\\1`);
const attrPattern = new RegExp(`\\b${escapeRegex(fullAttr)}=(["'])([^"']*)\\1`);
if (value === null) {
if (!attrPattern.test(tag)) return html;
const removePattern = new RegExp(`\\s+${escapeRegex(fullAttr)}=(["'])[^"']*\\1`);
const newTag = tag.replace(removePattern, "");
return html.replace(tag, newTag);
}
if (attrPattern.test(tag)) {
// Update existing attribute
@@ -381,7 +417,7 @@ export function applyPatch(html: string, elementId: string, op: PatchOperation):
case "attribute":
return patchAttribute(html, elementId, op.property, op.value);
case "text-content":
return patchTextContent(html, elementId, op.value);
return op.value !== null ? patchTextContent(html, elementId, op.value) : html;
default:
return html;
}
@@ -401,7 +437,7 @@ export function applyPatchByTarget(html: string, target: PatchTarget, op: PatchO
case "attribute":
return patchAttributeByTarget(html, target, op.property, op.value);
case "text-content":
return patchTextContentByTarget(html, target, op.value);
return op.value !== null ? patchTextContentByTarget(html, target, op.value) : html;
default:
return html;
}
@@ -1,5 +1,9 @@
import type { DomEditViewport, DomEditSelection } from "../components/editor/domEditing";
import { resolveVisualDomEditSelectionTarget } from "../components/editor/domEditing";
import {
getDomLayerPatchTarget,
isElementComputedVisible,
} from "../components/editor/domEditingElement";
import { usePlayerStore, liveTime } from "../player";
import { getEventTargetElement } from "./studioHelpers";
@@ -56,6 +60,28 @@ export function getPreviewLocalPointer(
return resolvePreviewLocalPointer(iframe, doc, win, clientX, clientY);
}
const POINTER_EVENTS_OVERRIDE_ID = "__hf_studio_pointer_events_override__";
function forcePointerEventsAuto(doc: Document): HTMLStyleElement | null {
try {
const style = doc.createElement("style");
style.id = POINTER_EVENTS_OVERRIDE_ID;
style.textContent = "* { pointer-events: auto !important; }";
doc.head.appendChild(style);
return style;
} catch {
return null;
}
}
function removePointerEventsOverride(style: HTMLStyleElement | null): void {
try {
style?.remove();
} catch {
// cross-origin or detached doc
}
}
export function getPreviewTargetFromPointer(
iframe: HTMLIFrameElement,
clientX: number,
@@ -75,6 +101,8 @@ export function getPreviewTargetFromPointer(
const localPointer = resolvePreviewLocalPointer(iframe, doc, win, clientX, clientY);
if (!localPointer) return null;
const overrideStyle = forcePointerEventsAuto(doc);
try {
if (typeof doc.elementsFromPoint === "function") {
const visualTarget = resolveVisualDomEditSelectionTarget(
doc.elementsFromPoint(localPointer.x, localPointer.y),
@@ -85,7 +113,13 @@ export function getPreviewTargetFromPointer(
if (visualTarget) return visualTarget;
}
return getEventTargetElement(doc.elementFromPoint(localPointer.x, localPointer.y));
const fallback = getEventTargetElement(doc.elementFromPoint(localPointer.x, localPointer.y));
if (!fallback || !getDomLayerPatchTarget(fallback, activeCompositionPath)) return null;
if (!isElementComputedVisible(fallback)) return null;
return fallback;
} finally {
removePointerEventsOverride(overrideStyle);
}
}
export function buildRasterClickSelectionContext(
+2 -1
View File
@@ -106,7 +106,8 @@ export function createViteAdapter(dataDir: string, server: ViteDevServer): Studi
.filter(
(d) =>
(d.isDirectory() || d.isSymbolicLink()) &&
existsSync(join(dataDir, d.name, "index.html")),
(existsSync(join(dataDir, d.name, "index.html")) ||
existsSync(join(dataDir, d.name, `${d.name}.html`))),
)
.map((d) => {
const session = sessionMap.get(d.name);