Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
64cbfa4
feat(studio): add pasteboard background to preview viewport
miguel-heygen May 14, 2026
726ae54
feat(studio): pasteboard background and canvas outline around preview
miguel-heygen May 14, 2026
cbaaa41
feat(studio): disable manual positioning JSON by default, add toggle
miguel-heygen May 14, 2026
91e71dc
feat(studio): enable manual positioning by default (opt-out)
miguel-heygen May 14, 2026
fe505ed
feat(studio): allow absolute elements to drag without toggle; gate JS…
miguel-heygen May 14, 2026
40c5279
feat(studio): persist positions directly to HTML; remove JSON sidecar…
miguel-heygen May 14, 2026
6cfd1b4
fix(studio): sync keyboard shortcut handler with main; fix keepPlayin…
miguel-heygen May 14, 2026
3e5fbb6
fix(studio): strip GSAP-cached translate from transform on path offse…
miguel-heygen May 14, 2026
1dba421
fix(studio): remove Reset edits button from design panel
miguel-heygen May 14, 2026
55805ef
feat(studio): wire reloadPreview into manifest persistence; drop stal…
miguel-heygen May 15, 2026
3f64ade
fix(studio): prevent root composition from being selected; correct ov…
miguel-heygen May 15, 2026
db2c508
fix(studio): correct resize overlay for scaled elements; block invisi…
miguel-heygen May 15, 2026
042e0b7
fix(studio): reload preview on external file changes via SSE/HMR
miguel-heygen May 15, 2026
90cbe9f
fix(studio): suppress post-resize click to keep selection on resized …
miguel-heygen May 15, 2026
18f79dc
fix(studio): serve registry blocks without index.html in preview
miguel-heygen May 15, 2026
1b7293a
fix(render): preserve studio drag/resize/rotation offsets in rendered…
miguel-heygen May 15, 2026
414320a
fix(studio): select elements with pointer-events: none in preview
miguel-heygen May 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .filesize-allowlist
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions packages/core/src/inline-scripts/parityContract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ export const MEDIA_VISUAL_STYLE_PROPERTIES = [
"mask-repeat",
"transform",
"transform-origin",
"translate",
"rotate",
"scale",
"box-sizing",
] as const;

Expand Down
52 changes: 52 additions & 0 deletions packages/core/src/lint/rules/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
},
];
211 changes: 211 additions & 0 deletions packages/core/src/studio-api/helpers/manualEditsRenderScript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/studio-api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
35 changes: 26 additions & 9 deletions packages/core/src/studio-api/routes/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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)
Expand All @@ -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),
Expand Down
Loading
Loading