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
@@ -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),