mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 12:54:29 +00:00
## Summary - **NLELayout**: Add toolbar slot, composition breadcrumb navigation, improved responsive layout - **Vite config**: Add full project API (preview, thumbnail, render, file CRUD) for standalone dev mode - Remove AgentActivityTrack component (replaced by timeline clips) - Add HTML editor utilities for composition source editing - Guard setInterval cleanup to dev-only to prevent `vite build` from hanging in CI 🤖 Generated with [Claude Code](https://claude.com/claude-code)
356 lines
11 KiB
TypeScript
356 lines
11 KiB
TypeScript
import { useState, useCallback, useRef } from "react";
|
|
import { useMountEffect } from "./useMountEffect";
|
|
import { resolveSourceFile, applyPatch } from "../utils/sourcePatcher";
|
|
|
|
export interface PickedElement {
|
|
id: string | null;
|
|
tagName: string;
|
|
selector: string;
|
|
label: string;
|
|
boundingBox: { x: number; y: number; width: number; height: number };
|
|
textContent: string | null;
|
|
src: string | null;
|
|
dataAttributes: Record<string, string>;
|
|
computedStyles: Record<string, string>;
|
|
}
|
|
|
|
interface UseElementPickerReturn {
|
|
isPickMode: boolean;
|
|
pickedElement: PickedElement | null;
|
|
enablePick: () => void;
|
|
disablePick: () => void;
|
|
clearPick: () => void;
|
|
/** Update a CSS property on the picked element live + persist to source */
|
|
setStyle: (prop: string, value: string) => void;
|
|
/** Update a data attribute on the picked element + persist to source */
|
|
setDataAttr: (attr: string, value: string) => void;
|
|
/** Update the text content of the picked element + persist to source */
|
|
setTextContent: (text: string) => void;
|
|
/** Override the active iframe (for zoomed canvas view). Pass null to restore primary. */
|
|
setActiveIframe: (el: HTMLIFrameElement | null) => void;
|
|
/** Ref that always points to the active iframe (focused canvas frame or preview panel) */
|
|
activeIframeRef: React.RefObject<HTMLIFrameElement | null>;
|
|
}
|
|
|
|
interface PickerOptions {
|
|
/** Workspace files for source patching */
|
|
workspaceFiles?: Record<string, string>;
|
|
/** Callback to sync patched files to the project */
|
|
onSyncFiles?: (files: Record<string, string>) => void;
|
|
}
|
|
|
|
/**
|
|
* Hook for element picking via the HyperFrame runtime's picker API.
|
|
* Communicates with the iframe via postMessage.
|
|
*/
|
|
export function useElementPicker(
|
|
iframeRef: React.RefObject<HTMLIFrameElement | null>,
|
|
options?: PickerOptions,
|
|
): UseElementPickerReturn {
|
|
const [isPickMode, setIsPickMode] = useState(false);
|
|
const [pickedElement, setPickedElement] = useState<PickedElement | null>(null);
|
|
|
|
// Secondary/override iframe ref — set when a zoomed frame is active.
|
|
// When set, all postMessage sends and DOM reads go to this ref instead.
|
|
const activeOverrideRef = useRef<HTMLIFrameElement | null>(null);
|
|
|
|
const getActiveIframe = useCallback((): HTMLIFrameElement | null => {
|
|
return activeOverrideRef.current ?? iframeRef.current;
|
|
}, [iframeRef]);
|
|
|
|
// Exposed so the host page can wire the focused view's iframe into the picker
|
|
const setActiveIframe = useCallback((el: HTMLIFrameElement | null) => {
|
|
activeOverrideRef.current = el;
|
|
}, []);
|
|
|
|
const enablePick = useCallback(() => {
|
|
try {
|
|
getActiveIframe()?.contentWindow?.postMessage(
|
|
{ source: "hf-parent", type: "control", action: "enable-pick-mode" },
|
|
"*",
|
|
);
|
|
setIsPickMode(true);
|
|
} catch {
|
|
/* cross-origin */
|
|
}
|
|
}, [getActiveIframe]);
|
|
|
|
const disablePick = useCallback(() => {
|
|
try {
|
|
getActiveIframe()?.contentWindow?.postMessage(
|
|
{ source: "hf-parent", type: "control", action: "disable-pick-mode" },
|
|
"*",
|
|
);
|
|
} catch {
|
|
/* cross-origin */
|
|
}
|
|
setIsPickMode(false);
|
|
}, [getActiveIframe]);
|
|
|
|
const clearPick = useCallback(() => {
|
|
setPickedElement(null);
|
|
}, []);
|
|
|
|
// Listen for picker messages from the iframe
|
|
useMountEffect(() => {
|
|
const handleMessage = (e: MessageEvent) => {
|
|
const data = e.data;
|
|
if (data?.source !== "hf-preview") return;
|
|
// Accept events from either the primary iframe or the active override
|
|
const activeIframe = getActiveIframe();
|
|
if (!activeIframe) return;
|
|
if (e.source !== activeIframe.contentWindow && e.source !== iframeRef.current?.contentWindow)
|
|
return;
|
|
|
|
if (data.type === "element-picked") {
|
|
const el = data.elementInfo;
|
|
if (el) {
|
|
const styles = readComputedStyles(activeIframe, el.selector);
|
|
setPickedElement({
|
|
id: el.id ?? null,
|
|
tagName: el.tagName ?? "div",
|
|
selector: el.selector ?? "",
|
|
label: el.label ?? el.tagName ?? "Element",
|
|
boundingBox: el.boundingBox ?? { x: 0, y: 0, width: 0, height: 0 },
|
|
textContent: el.textContent ?? null,
|
|
src: el.src ?? null,
|
|
dataAttributes: el.dataAttributes ?? {},
|
|
computedStyles: styles,
|
|
});
|
|
setIsPickMode(false);
|
|
}
|
|
} else if (data.type === "element-pick-candidates") {
|
|
// Multiple candidates at click point — pick the first one
|
|
const el = data.candidates?.[data.selectedIndex ?? 0];
|
|
if (el) {
|
|
const styles = readComputedStyles(activeIframe, el.selector);
|
|
setPickedElement({
|
|
id: el.id ?? null,
|
|
tagName: el.tagName ?? "div",
|
|
selector: el.selector ?? "",
|
|
label: el.label ?? el.tagName ?? "Element",
|
|
boundingBox: el.boundingBox ?? { x: 0, y: 0, width: 0, height: 0 },
|
|
textContent: el.textContent ?? null,
|
|
src: el.src ?? null,
|
|
dataAttributes: el.dataAttributes ?? {},
|
|
computedStyles: styles,
|
|
});
|
|
}
|
|
}
|
|
|
|
if (data.type === "pick-mode-cancelled") {
|
|
setIsPickMode(false);
|
|
}
|
|
};
|
|
|
|
window.addEventListener("message", handleMessage);
|
|
return () => window.removeEventListener("message", handleMessage);
|
|
});
|
|
|
|
// Ref for options to avoid stale closures in debounced callback
|
|
const optionsRef = useRef(options);
|
|
optionsRef.current = options;
|
|
|
|
// Sync immediately (not debounced) — save on every change for reliability
|
|
const syncToSource = useCallback(
|
|
(
|
|
elementId: string,
|
|
selector: string,
|
|
op: {
|
|
type: "inline-style" | "attribute" | "text-content";
|
|
property: string;
|
|
value: string;
|
|
},
|
|
) => {
|
|
const opts = optionsRef.current;
|
|
if (!opts?.workspaceFiles || !opts.onSyncFiles || !elementId) return;
|
|
const files = opts.workspaceFiles;
|
|
const sourceFile = resolveSourceFile(elementId, selector, files);
|
|
if (!sourceFile || !files[sourceFile]) {
|
|
return;
|
|
}
|
|
const patched = applyPatch(files[sourceFile], elementId, op);
|
|
if (patched !== files[sourceFile]) {
|
|
opts.onSyncFiles({ [sourceFile]: patched });
|
|
}
|
|
},
|
|
[],
|
|
);
|
|
|
|
const setStyle = useCallback(
|
|
(prop: string, value: string) => {
|
|
const activeIframe = getActiveIframe();
|
|
if (!pickedElement?.selector || !activeIframe) return;
|
|
try {
|
|
const doc = activeIframe.contentDocument;
|
|
const el = doc?.querySelector(pickedElement.selector) as HTMLElement | null;
|
|
if (el) {
|
|
el.style.setProperty(prop, value);
|
|
setPickedElement((prev) =>
|
|
prev
|
|
? {
|
|
...prev,
|
|
computedStyles: { ...prev.computedStyles, [prop]: value },
|
|
}
|
|
: null,
|
|
);
|
|
// Persist to source file
|
|
if (pickedElement.id) {
|
|
// ID-based patching — surgical edit of just the element's style
|
|
syncToSource(pickedElement.id, pickedElement.selector, {
|
|
type: "inline-style",
|
|
property: prop,
|
|
value,
|
|
});
|
|
} else {
|
|
// No ID — save the full composition HTML from the iframe
|
|
// This captures ALL inline style changes, not just the targeted one
|
|
try {
|
|
const fullHtml = activeIframe.contentDocument?.documentElement.outerHTML;
|
|
if (fullHtml && optionsRef.current?.onSyncFiles) {
|
|
// Determine which file this iframe represents
|
|
const src = activeIframe.getAttribute("src") ?? "";
|
|
const compMatch = src.match(/\/comp\/(.+?)(?:\?|$)/);
|
|
const filePath = compMatch ? compMatch[1] : "index.html";
|
|
optionsRef.current.onSyncFiles({
|
|
[filePath]: `<!DOCTYPE html>\n<html>${fullHtml.replace(/<html[^>]*>/, "")}`,
|
|
});
|
|
}
|
|
} catch {
|
|
/* cross-origin */
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
/* cross-origin */
|
|
}
|
|
},
|
|
[pickedElement, getActiveIframe, syncToSource],
|
|
);
|
|
|
|
const setDataAttr = useCallback(
|
|
(attr: string, value: string) => {
|
|
const activeIframe = getActiveIframe();
|
|
if (!pickedElement?.selector || !activeIframe) return;
|
|
try {
|
|
const doc = activeIframe.contentDocument;
|
|
const el = doc?.querySelector(pickedElement.selector);
|
|
if (el) {
|
|
el.setAttribute(`data-${attr}`, value);
|
|
setPickedElement((prev) =>
|
|
prev
|
|
? {
|
|
...prev,
|
|
dataAttributes: { ...prev.dataAttributes, [attr]: value },
|
|
}
|
|
: null,
|
|
);
|
|
// Persist to source file immediately
|
|
if (pickedElement.id) {
|
|
syncToSource(pickedElement.id, pickedElement.selector, {
|
|
type: "attribute",
|
|
property: attr,
|
|
value,
|
|
});
|
|
}
|
|
}
|
|
} catch {
|
|
/* cross-origin */
|
|
}
|
|
},
|
|
[pickedElement, getActiveIframe, syncToSource],
|
|
);
|
|
|
|
const setTextContent = useCallback(
|
|
(text: string) => {
|
|
const activeIframe = getActiveIframe();
|
|
if (!pickedElement?.selector || !activeIframe) return;
|
|
try {
|
|
const doc = activeIframe.contentDocument;
|
|
const el = doc?.querySelector(pickedElement.selector);
|
|
if (el) {
|
|
el.textContent = text;
|
|
setPickedElement((prev) => (prev ? { ...prev, textContent: text } : null));
|
|
// Persist to source file
|
|
if (pickedElement.id) {
|
|
syncToSource(pickedElement.id, pickedElement.selector, {
|
|
type: "text-content",
|
|
property: "textContent",
|
|
value: text,
|
|
});
|
|
}
|
|
}
|
|
} catch {
|
|
/* cross-origin */
|
|
}
|
|
},
|
|
[pickedElement, getActiveIframe, syncToSource],
|
|
);
|
|
|
|
// Ref-like object that always points to the active iframe (override or primary)
|
|
const activeIframeRef = useRef<HTMLIFrameElement | null>(null);
|
|
activeIframeRef.current = getActiveIframe();
|
|
|
|
return {
|
|
isPickMode,
|
|
pickedElement,
|
|
enablePick,
|
|
disablePick,
|
|
clearPick,
|
|
setStyle,
|
|
setDataAttr,
|
|
setTextContent,
|
|
setActiveIframe,
|
|
/** Ref that always points to the active iframe (focused canvas frame or preview panel) */
|
|
activeIframeRef,
|
|
};
|
|
}
|
|
|
|
/** Read a subset of computed styles from an element in the iframe */
|
|
function readComputedStyles(iframe: HTMLIFrameElement, selector: string): Record<string, string> {
|
|
const styles: Record<string, string> = {};
|
|
try {
|
|
const doc = iframe.contentDocument;
|
|
const el = doc?.querySelector(selector);
|
|
if (!el) return styles;
|
|
const computed = iframe.contentWindow?.getComputedStyle(el);
|
|
if (!computed) return styles;
|
|
|
|
const props = [
|
|
"position",
|
|
"top",
|
|
"left",
|
|
"right",
|
|
"bottom",
|
|
"width",
|
|
"height",
|
|
"margin-top",
|
|
"margin-right",
|
|
"margin-bottom",
|
|
"margin-left",
|
|
"padding-top",
|
|
"padding-right",
|
|
"padding-bottom",
|
|
"padding-left",
|
|
"font-size",
|
|
"font-weight",
|
|
"font-family",
|
|
"color",
|
|
"background-color",
|
|
"background",
|
|
"opacity",
|
|
"border-radius",
|
|
"transform",
|
|
"z-index",
|
|
];
|
|
|
|
for (const prop of props) {
|
|
const val = computed.getPropertyValue(prop);
|
|
if (val) styles[prop] = val;
|
|
}
|
|
} catch {
|
|
/* cross-origin */
|
|
}
|
|
return styles;
|
|
}
|