mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
feat(studio): persist element positions in HTML, fix resize overlay drift and GSAP double-translation (#829)
* feat(studio): add pasteboard background to preview viewport Adds bg-neutral-800 to the preview viewport so the area outside the canvas is visually distinct from the composition content — consistent with professional video editors (Premiere, DaVinci, Figma). * feat(studio): pasteboard background and canvas outline around preview - NLEPreview: viewport gets bg-neutral-700 (#404040) as the pasteboard color surrounding the canvas — distinct from the app chrome (#0a0a0a) - Player wrapper: drop bg-black so the pasteboard shows around the canvas (loading overlays still cover the area with bg-black during load) - Player: set host background to transparent via inline style (overrides :host { background: #000 } in shadow DOM), and inject a style rule into the open shadow root so .hfp-container has overflow:visible and the canvas iframe gets a thin white ring + soft drop-shadow — making the canvas boundary legible against the pasteboard * feat(studio): disable manual positioning JSON by default, add toggle Manual edits were always stored in `.hyperframes/studio-manual-edits.json`, making it hard to share source without the sidecar file and easy to accidentally reposition elements via drag. Changes: - `enabled` field added to `StudioManualEditManifest` (defaults to `false` when absent — existing projects are unaffected until they opt in) - Drag handles, resize, and rotation handles are hidden when disabled - Layout X/Y/W/H/R fields in the Design panel are read-only when disabled - "Manual positioning" toggle added at the bottom of the Design panel, visible whether or not an element is selected - Toggle state is persisted to `.hyperframes/studio-manual-edits.json` so each project can opt in independently - `STUDIO_PREVIEW_MANUAL_EDITING_ENABLED` env flag still acts as a hard cap (env off → feature off regardless of project setting) * feat(studio): enable manual positioning by default (opt-out) * feat(studio): allow absolute elements to drag without toggle; gate JSON-backed drag behind toggle * feat(studio): persist positions directly to HTML; remove JSON sidecar and manual positioning toggle Replace the `.hyperframes/studio-manual-edits.json` sidecar with inline-style persistence baked directly into the HTML source. Drag/resize/rotation values are written as CSS custom properties (`--hf-studio-offset-x/y`, `--hf-studio-width/height`, `--hf-studio-rotation`) plus `translate`/`width`/`height`/`rotate` inline styles via `persistDomEditOperations` — no re-apply step needed on load. Key changes: - `sourcePatcher`: add `value: string | null` to `PatchOperation` — null removes the property/attribute from the HTML tag instead of setting it - `manualEditsDom`: add `build*Patches` / `buildClear*Patches` helpers that capture live element state into `PatchOperation[]` for HTML source writes; add `reapplyPositionEditsAfterSeek` (DOM-query-based seek hook, queries data-attribute markers) - `manualEdits.ts`: remove `applyStudioManualEditManifest` and all manifest target resolution; export `reapplyPositionEditsAfterSeek`; keep seek/play wrap infrastructure - `useManifestPersistence`: remove all JSON I/O — no disk read on load, no manifest state, no toggle state; `applyCurrentStudioManualEditsToPreview` now only installs seek hooks via `reapplyPositionEditsAfterSeek` - `useDomEditCommits`: replace `commitStudioManualEditManifestOptimistically` calls with direct DOM apply + `commitPositionPatchToHtml` (queued HTML patch write, skipRefresh) - `DomEditOverlay`: remove `manualEditsEnabled` prop; revert all `canMove || manualEditsEnabled` gates to just `canApplyManualOffset` — every draggable element is always draggable - `PropertyPanel`: remove `ManualPositioningToggle` component and all toggle props - `manualEditsParsing/manualEditsTypes`: remove manifest types, upsert functions, and `STUDIO_MANUAL_EDITS_PATH`; keep `finiteNumber`, `readStudioFileChangePath`, `roundRotationAngle`, and snapshot/CSS-property types * fix(studio): sync keyboard shortcut handler with main; fix keepPlaying seek assertions in test * fix(studio): strip GSAP-cached translate from transform on path offset apply * fix(studio): remove Reset edits button from design panel * feat(studio): wire reloadPreview into manifest persistence; drop stale group-selection refresh - Pass `reloadPreview` into `useManifestPersistence` so undo/redo reloads via the refresh-key path instead of directly touching the iframe. - Remove `refreshDomEditGroupSelectionsFromPreview` from commit handlers; HTML is now the source of truth so no stale-ref refresh is needed. - Add `manualEditsRenderScript` helper; export via studio-api and apply it in `htmlCompiler` during HTML compilation. * fix(studio): prevent root composition from being selected; correct overlay drift on resize - Guard `getDomLayerPatchTarget` against elements with `data-composition-id` so the root composition div is never returned as a visual selection target. - Apply the same guard to the raw `elementFromPoint` fallback in `getPreviewTargetFromPointer`, which was the actual escape path. - Thread `iframeRef` into gesture handler opts; after applying draft dimensions during resize, re-read the element BCR via `toOverlayRect` and update the overlay box position to compensate for visual drift on elements with centered transform-origin (e.g. GSAP scale tweens). * fix(studio): correct resize overlay for scaled elements; block invisible element selection - Resize: use BCR from `toOverlayRect` for both position and size after applying draft dimensions — GSAP scale makes visual size diverge from raw CSS size, BCR is the only accurate source during a gesture. - Click selection: add `isElementComputedVisible` guard to the `elementFromPoint` fallback so opacity-0 / autoAlpha-hidden elements cannot be selected even though the browser hit-test returns them. * fix(studio): reload preview on external file changes via SSE/HMR Share the app-level domEditSaveTimestampRef with useManifestPersistence so the SSE/HMR handler can suppress echoes from all studio saves (code tab, timeline, DOM edits), then call reloadPreview() for non-motion external changes that aren't echoes of our own saves. * fix(studio): suppress post-resize click to keep selection on resized element * fix(studio): serve registry blocks without index.html in preview Blocks ship as {id}.html + assets/ with no index.html. The preview route hard-coded index.html so these projects returned 404 and their assets (e.g. korea-map.png, map-nyc-paris.png) were never served. Add resolveProjectMainHtml() that falls back to {id}.html, thread the resolved compositionPath through transformPreviewHtml and injectStudioPreviewAugmentations, and update listProjects() in the vite adapter to surface block directories in the project list. * fix(render): preserve studio drag/resize/rotation offsets in rendered video Three issues caused studio-edited positions to be lost during rendering: 1. The seek-reapply script used setInterval to wrap window.__hf.seek, but Puppeteer's page.evaluate() calls don't yield the event loop for macrotasks — the interval never fired, so reapplyAll() never ran after GSAP seeks. Fix: use Object.defineProperty to trap writes to the seek property, wrapping it synchronously the instant the bridge assigns it. 2. MEDIA_VISUAL_STYLE_PROPERTIES (copied from <video> to proxy <img> during render) included "transform" but not "translate", "rotate", or "scale" — the CSS Transforms Level 2 individual properties used by studio drag/resize/rotation. The proxy was positioned at offsetLeft/ offsetTop without the translate offset. 3. getViewportMatrix (HDR compositor) only read cs.transform, missing individual transform properties entirely. Added composeIndividualTransforms to build the translate × rotate × scale matrix and compose it before the legacy transform matrix. * fix(studio): select elements with pointer-events: none in preview Compositions often set pointer-events: none on scenes, avatar wrappers, and decorative layers. elementsFromPoint() skips these elements entirely, making them unselectable in the Studio. Fix: temporarily inject a * { pointer-events: auto !important } stylesheet during hit-testing, then remove it immediately after. Also adds a pointer_events_none lint rule (info severity, visible with --verbose) so authors know which selectors may affect Studio selection.
This commit is contained in:
@@ -24,6 +24,9 @@ export const MEDIA_VISUAL_STYLE_PROPERTIES = [
|
||||
"mask-repeat",
|
||||
"transform",
|
||||
"transform-origin",
|
||||
"translate",
|
||||
"rotate",
|
||||
"scale",
|
||||
"box-sizing",
|
||||
] as const;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user