Skip to content

Commit 2250108

Browse files
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.
1 parent 83c29fa commit 2250108

28 files changed

Lines changed: 1064 additions & 1303 deletions

.filesize-allowlist

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
packages/studio/src/player/hooks/useTimelinePlayer.ts
22
packages/studio/src/hooks/useManifestPersistence.ts
33
packages/studio/src/player/components/PlayerControls.tsx
4+
packages/studio/src/components/editor/manualEdits.test.ts
5+
packages/studio/src/components/editor/manualEditsDom.ts

packages/core/src/inline-scripts/parityContract.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ export const MEDIA_VISUAL_STYLE_PROPERTIES = [
2424
"mask-repeat",
2525
"transform",
2626
"transform-origin",
27+
"translate",
28+
"rotate",
29+
"scale",
2730
"box-sizing",
2831
] as const;
2932

packages/core/src/lint/rules/core.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -319,4 +319,56 @@ export const coreRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
319319
}
320320
return findings;
321321
},
322+
323+
// pointer_events_none
324+
({ tags, styles }) => {
325+
const findings: HyperframeLintFinding[] = [];
326+
const reported = new Set<string>();
327+
328+
for (const tag of tags) {
329+
if (["script", "style", "link", "meta", "template", "noscript"].includes(tag.name)) continue;
330+
const inlineStyle = readAttr(tag.raw, "style") ?? "";
331+
if (!/pointer-events\s*:\s*none/i.test(inlineStyle)) continue;
332+
const id = readAttr(tag.raw, "id");
333+
const key = id ?? tag.raw;
334+
if (reported.has(key)) continue;
335+
reported.add(key);
336+
findings.push({
337+
code: "pointer_events_none",
338+
severity: "info",
339+
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.`,
340+
elementId: id || undefined,
341+
fixHint:
342+
"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.",
343+
snippet: truncateSnippet(tag.raw),
344+
});
345+
}
346+
347+
for (const style of styles) {
348+
let root: postcss.Root;
349+
try {
350+
root = postcss.parse(style.content);
351+
} catch {
352+
continue;
353+
}
354+
root.walkDecls("pointer-events", (decl) => {
355+
if (decl.value.trim().toLowerCase() !== "none") return;
356+
const rule = decl.parent;
357+
if (!rule || rule.type !== "rule") return;
358+
const selector = (rule as postcss.Rule).selector;
359+
if (reported.has(selector)) return;
360+
reported.add(selector);
361+
findings.push({
362+
code: "pointer_events_none",
363+
severity: "info",
364+
message: `\`${selector}\` sets \`pointer-events: none\`. Elements matching this selector are harder to select in the Studio preview.`,
365+
selector,
366+
fixHint:
367+
"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.",
368+
});
369+
});
370+
}
371+
372+
return findings;
373+
},
322374
];

packages/core/src/studio-api/helpers/manualEditsRenderScript.ts

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,217 @@ export function createStudioManualEditsRenderBodyScript(
1212
return `(${studioManualEditsRenderRuntime.toString()})(${JSON.stringify(manifestContent)}, ${JSON.stringify(options.activeCompositionPath ?? null)});`;
1313
}
1414

15+
/**
16+
* Returns a self-contained IIFE string that re-applies studio position edits
17+
* (translate, rotate) after every GSAP seek by querying data attributes baked
18+
* into the HTML. Works without a JSON manifest — positions are already inlined
19+
* as CSS custom properties on the elements.
20+
*/
21+
export function createStudioPositionSeekReapplyScript(): string {
22+
return `(${studioPositionSeekReapplyRuntime.toString()})();`;
23+
}
24+
25+
function studioPositionSeekReapplyRuntime(): void {
26+
const OFFSET_X_PROP = "--hf-studio-offset-x";
27+
const OFFSET_Y_PROP = "--hf-studio-offset-y";
28+
const ROTATION_PROP = "--hf-studio-rotation";
29+
const PATH_OFFSET_ATTR = "data-hf-studio-path-offset";
30+
const ROTATION_ATTR = "data-hf-studio-rotation";
31+
const ORIGINAL_TRANSLATE_ATTR = "data-hf-studio-original-translate";
32+
const ORIGINAL_ROTATE_ATTR = "data-hf-studio-original-rotate";
33+
const WRAPPED_PROP = "__hfStudioPositionSeekReapplyWrapped";
34+
35+
if (
36+
!document.querySelector("[" + PATH_OFFSET_ATTR + '="true"]') &&
37+
!document.querySelector("[" + ROTATION_ATTR + '="true"]')
38+
)
39+
return;
40+
41+
const splitTopLevelWhitespace = (value: string): string[] => {
42+
const parts: string[] = [];
43+
let depth = 0;
44+
let current = "";
45+
for (const char of value.trim()) {
46+
if (char === "(") depth += 1;
47+
if (char === ")") depth = Math.max(0, depth - 1);
48+
if (/\s/.test(char) && depth === 0) {
49+
if (current) parts.push(current);
50+
current = "";
51+
} else {
52+
current += char;
53+
}
54+
}
55+
if (current) parts.push(current);
56+
return parts;
57+
};
58+
59+
const composeTranslate = (element: HTMLElement, x: string, y: string): string => {
60+
const original = element.getAttribute(ORIGINAL_TRANSLATE_ATTR)?.trim();
61+
if (!original || original === "none") return x + " " + y;
62+
const parts = splitTopLevelWhitespace(original);
63+
if (parts.length === 1) return "calc(" + parts[0] + " + " + x + ") " + y;
64+
if (parts.length >= 2) {
65+
const z = parts.length >= 3 ? " " + parts[2] : "";
66+
return "calc(" + parts[0] + " + " + x + ") calc(" + parts[1] + " + " + y + ")" + z;
67+
}
68+
return x + " " + y;
69+
};
70+
71+
const isSimpleRotateAngle = (value: string): boolean =>
72+
/^-?(?:\d+(?:\.\d+)?|\.\d+)(?:deg|rad|turn|grad)$/.test(value.trim());
73+
74+
const composeRotation = (element: HTMLElement, rotationValue: string): string => {
75+
const original = element.getAttribute(ORIGINAL_ROTATE_ATTR)?.trim();
76+
if (!original || original === "none" || !isSimpleRotateAngle(original)) return rotationValue;
77+
return "calc(" + original + " + " + rotationValue + ")";
78+
};
79+
80+
const reapplyAll = (): void => {
81+
const offsetEls = document.querySelectorAll("[" + PATH_OFFSET_ATTR + '="true"]');
82+
for (let i = 0; i < offsetEls.length; i++) {
83+
const el = offsetEls[i] as HTMLElement;
84+
if (!(el instanceof HTMLElement)) continue;
85+
const x = el.style.getPropertyValue(OFFSET_X_PROP);
86+
const y = el.style.getPropertyValue(OFFSET_Y_PROP);
87+
if (x || y) {
88+
el.style.setProperty(
89+
"translate",
90+
composeTranslate(
91+
el,
92+
"var(" + OFFSET_X_PROP + ", 0px)",
93+
"var(" + OFFSET_Y_PROP + ", 0px)",
94+
),
95+
);
96+
}
97+
}
98+
const rotEls = document.querySelectorAll("[" + ROTATION_ATTR + '="true"]');
99+
for (let i = 0; i < rotEls.length; i++) {
100+
const el = rotEls[i] as HTMLElement;
101+
if (!(el instanceof HTMLElement)) continue;
102+
const rot = el.style.getPropertyValue(ROTATION_PROP);
103+
if (rot) {
104+
el.style.setProperty("rotate", composeRotation(el, "var(" + ROTATION_PROP + ", 0deg)"));
105+
}
106+
}
107+
};
108+
109+
const runtimeWindow = window as Window & {
110+
__hf?: Record<string, unknown>;
111+
__player?: Record<string, unknown>;
112+
};
113+
114+
const isWrapped = (fn: (time: number) => unknown): boolean =>
115+
Boolean((fn as unknown as Record<string, unknown>)[WRAPPED_PROP]);
116+
117+
const markWrapped = (fn: (time: number) => unknown): void => {
118+
try {
119+
Object.defineProperty(fn, WRAPPED_PROP, {
120+
configurable: false,
121+
enumerable: false,
122+
value: true,
123+
});
124+
} catch {
125+
try {
126+
(fn as unknown as Record<string, unknown>)[WRAPPED_PROP] = true;
127+
} catch {
128+
/* ignore */
129+
}
130+
}
131+
};
132+
133+
const wrapFn = (get: () => unknown, set: (fn: (time: number) => unknown) => void): boolean => {
134+
const fn = get();
135+
if (typeof fn !== "function") return false;
136+
const seek = fn as (time: number) => unknown;
137+
if (isWrapped(seek)) {
138+
reapplyAll();
139+
return true;
140+
}
141+
const wrapped = function (this: unknown, time: number): unknown {
142+
const result = seek.call(this, time);
143+
reapplyAll();
144+
return result;
145+
};
146+
markWrapped(wrapped);
147+
set(wrapped);
148+
reapplyAll();
149+
return true;
150+
};
151+
152+
const wrapSeekFunctions = (): boolean => {
153+
const a = wrapFn(
154+
() => runtimeWindow.__hf?.["seek"],
155+
(fn) => {
156+
if (runtimeWindow.__hf) runtimeWindow.__hf["seek"] = fn;
157+
},
158+
);
159+
const b = wrapFn(
160+
() => runtimeWindow.__player?.["renderSeek"],
161+
(fn) => {
162+
if (runtimeWindow.__player) runtimeWindow.__player["renderSeek"] = fn;
163+
},
164+
);
165+
return a || b;
166+
};
167+
168+
const installSeekTrap = (
169+
obj: Record<string, unknown> | undefined,
170+
key: string,
171+
getter: () => unknown,
172+
setter: (fn: (time: number) => unknown) => void,
173+
): void => {
174+
if (!obj) return;
175+
try {
176+
let current = obj[key];
177+
Object.defineProperty(obj, key, {
178+
configurable: true,
179+
enumerable: true,
180+
get() {
181+
return current;
182+
},
183+
set(value: unknown) {
184+
current = value;
185+
if (typeof value === "function" && !isWrapped(value as (time: number) => unknown)) {
186+
wrapFn(getter, setter);
187+
}
188+
},
189+
});
190+
} catch {
191+
/* non-configurable — fall back to polling */
192+
}
193+
};
194+
195+
if (document.readyState === "loading") {
196+
document.addEventListener("DOMContentLoaded", () => reapplyAll(), { once: true });
197+
} else {
198+
reapplyAll();
199+
}
200+
201+
wrapSeekFunctions();
202+
installSeekTrap(
203+
runtimeWindow.__hf,
204+
"seek",
205+
() => runtimeWindow.__hf?.["seek"],
206+
(fn) => {
207+
if (runtimeWindow.__hf) runtimeWindow.__hf["seek"] = fn;
208+
},
209+
);
210+
installSeekTrap(
211+
runtimeWindow.__player as Record<string, unknown> | undefined,
212+
"renderSeek",
213+
() => runtimeWindow.__player?.["renderSeek"],
214+
(fn) => {
215+
if (runtimeWindow.__player) runtimeWindow.__player["renderSeek"] = fn;
216+
},
217+
);
218+
let remaining = 120;
219+
const interval = setInterval(() => {
220+
wrapSeekFunctions();
221+
remaining -= 1;
222+
if (remaining <= 0) clearInterval(interval);
223+
}, 50);
224+
}
225+
15226
function studioManualEditsRenderRuntime(
16227
manifestContent: string,
17228
activeCompositionPath: string | null,

packages/core/src/studio-api/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export { getElementScreenshotClip, type ScreenshotClip } from "./helpers/screens
88
export {
99
STUDIO_MANUAL_EDITS_PATH,
1010
createStudioManualEditsRenderBodyScript,
11+
createStudioPositionSeekReapplyScript,
1112
type StudioManualEditsRenderScriptOptions,
1213
} from "./helpers/manualEditsRenderScript.js";
1314
export {

packages/core/src/studio-api/routes/preview.ts

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,21 @@ async function transformPreviewHtml(
143143
}
144144
}
145145

146+
function resolveProjectMainHtml(
147+
projectDir: string,
148+
projectId: string,
149+
): { html: string; compositionPath: string } | null {
150+
const indexPath = join(projectDir, "index.html");
151+
if (existsSync(indexPath)) {
152+
return { html: readFileSync(indexPath, "utf-8"), compositionPath: "index.html" };
153+
}
154+
const blockHtmlPath = join(projectDir, `${projectId}.html`);
155+
if (existsSync(blockHtmlPath)) {
156+
return { html: readFileSync(blockHtmlPath, "utf-8"), compositionPath: `${projectId}.html` };
157+
}
158+
return null;
159+
}
160+
146161
export function registerPreviewRoutes(api: Hono, adapter: StudioApiAdapter): void {
147162
const previewCacheHeaders = (etag: string) => ({
148163
"Cache-Control": "private, no-cache",
@@ -163,10 +178,12 @@ export function registerPreviewRoutes(api: Hono, adapter: StudioApiAdapter): voi
163178

164179
try {
165180
let bundled = await adapter.bundle(project.dir);
181+
let mainCompositionPath = "index.html";
166182
if (!bundled) {
167-
const indexPath = resolve(project.dir, "index.html");
168-
if (!existsSync(indexPath)) return c.text("not found", 404);
169-
bundled = readFileSync(indexPath, "utf-8");
183+
const main = resolveProjectMainHtml(project.dir, project.id);
184+
if (!main) return c.text("not found", 404);
185+
bundled = main.html;
186+
mainCompositionPath = main.compositionPath;
170187
}
171188

172189
// Inject runtime if not already present (check URL pattern and bundler attribute)
@@ -187,21 +204,21 @@ export function registerPreviewRoutes(api: Hono, adapter: StudioApiAdapter): voi
187204
}
188205

189206
bundled = injectStudioPreviewAugmentations(
190-
await transformPreviewHtml(bundled, adapter, project, "index.html"),
207+
await transformPreviewHtml(bundled, adapter, project, mainCompositionPath),
191208
adapter,
192209
project.dir,
193-
"index.html",
210+
mainCompositionPath,
194211
);
195212
return c.html(bundled, 200, previewCacheHeaders(etag));
196213
} catch {
197-
const file = resolve(project.dir, "index.html");
198-
if (existsSync(file)) {
214+
const main = resolveProjectMainHtml(project.dir, project.id);
215+
if (main) {
199216
return c.html(
200217
injectStudioPreviewAugmentations(
201-
await transformPreviewHtml(readFileSync(file, "utf-8"), adapter, project, "index.html"),
218+
await transformPreviewHtml(main.html, adapter, project, main.compositionPath),
202219
adapter,
203220
project.dir,
204-
"index.html",
221+
main.compositionPath,
205222
),
206223
200,
207224
previewCacheHeaders(etag),

0 commit comments

Comments
 (0)