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