mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-04 13:07:11 +00:00
fix(studio): handle raster inspector clicks
This commit is contained in:
+163
-19
@@ -4,6 +4,7 @@ import {
|
||||
useRef,
|
||||
useEffect,
|
||||
useMemo,
|
||||
type CSSProperties,
|
||||
type MouseEvent,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
@@ -91,10 +92,12 @@ import {
|
||||
findElementForTimelineElement,
|
||||
getDomEditLayerKey,
|
||||
getDomEditTargetKey,
|
||||
isLargeRasterDomEditSelection,
|
||||
isTextEditableSelection,
|
||||
resolveVisualDomEditSelectionTarget,
|
||||
serializeDomEditTextFields,
|
||||
resolveDomEditSelection,
|
||||
type DomEditViewport,
|
||||
type DomEditLayerItem,
|
||||
type DomEditTextField,
|
||||
type DomEditSelection,
|
||||
@@ -359,6 +362,59 @@ function isManualGeometryStyleProperty(property: string): boolean {
|
||||
return property === "left" || property === "top" || property === "width" || property === "height";
|
||||
}
|
||||
|
||||
interface PreviewLocalPointer {
|
||||
x: number;
|
||||
y: number;
|
||||
viewport: DomEditViewport;
|
||||
}
|
||||
|
||||
interface AgentModalAnchorPoint {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
function resolvePreviewLocalPointer(
|
||||
iframe: HTMLIFrameElement,
|
||||
doc: Document,
|
||||
win: Window,
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
): PreviewLocalPointer | null {
|
||||
const iframeRect = iframe.getBoundingClientRect();
|
||||
const root =
|
||||
doc.querySelector<HTMLElement>("[data-composition-id]") ?? doc.documentElement ?? null;
|
||||
const rootRect = root?.getBoundingClientRect();
|
||||
const rootWidth = rootRect?.width || win.innerWidth;
|
||||
const rootHeight = rootRect?.height || win.innerHeight;
|
||||
if (!rootWidth || !rootHeight) return null;
|
||||
|
||||
const scaleX = iframeRect.width / rootWidth;
|
||||
const scaleY = iframeRect.height / rootHeight;
|
||||
return {
|
||||
x: (clientX - iframeRect.left) / scaleX,
|
||||
y: (clientY - iframeRect.top) / scaleY,
|
||||
viewport: { width: rootWidth, height: rootHeight },
|
||||
};
|
||||
}
|
||||
|
||||
function getPreviewLocalPointer(
|
||||
iframe: HTMLIFrameElement,
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
): PreviewLocalPointer | null {
|
||||
let doc: Document | null = null;
|
||||
let win: Window | null = null;
|
||||
try {
|
||||
doc = iframe.contentDocument;
|
||||
win = iframe.contentWindow;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!doc || !win) return null;
|
||||
|
||||
return resolvePreviewLocalPointer(iframe, doc, win, clientX, clientY);
|
||||
}
|
||||
|
||||
function getPreviewTargetFromPointer(
|
||||
iframe: HTMLIFrameElement,
|
||||
clientX: number,
|
||||
@@ -375,22 +431,12 @@ function getPreviewTargetFromPointer(
|
||||
}
|
||||
if (!doc || !win) return null;
|
||||
|
||||
const iframeRect = iframe.getBoundingClientRect();
|
||||
const root =
|
||||
doc.querySelector<HTMLElement>("[data-composition-id]") ?? doc.documentElement ?? null;
|
||||
const rootRect = root?.getBoundingClientRect();
|
||||
const rootWidth = rootRect?.width || win.innerWidth;
|
||||
const rootHeight = rootRect?.height || win.innerHeight;
|
||||
if (!rootWidth || !rootHeight) return null;
|
||||
|
||||
const scaleX = iframeRect.width / rootWidth;
|
||||
const scaleY = iframeRect.height / rootHeight;
|
||||
const localX = (clientX - iframeRect.left) / scaleX;
|
||||
const localY = (clientY - iframeRect.top) / scaleY;
|
||||
const localPointer = resolvePreviewLocalPointer(iframe, doc, win, clientX, clientY);
|
||||
if (!localPointer) return null;
|
||||
|
||||
if (typeof doc.elementsFromPoint === "function") {
|
||||
const visualTarget = resolveVisualDomEditSelectionTarget(
|
||||
doc.elementsFromPoint(localX, localY),
|
||||
doc.elementsFromPoint(localPointer.x, localPointer.y),
|
||||
{
|
||||
activeCompositionPath,
|
||||
},
|
||||
@@ -398,7 +444,22 @@ function getPreviewTargetFromPointer(
|
||||
if (visualTarget) return visualTarget;
|
||||
}
|
||||
|
||||
return getEventTargetElement(doc.elementFromPoint(localX, localY));
|
||||
return getEventTargetElement(doc.elementFromPoint(localPointer.x, localPointer.y));
|
||||
}
|
||||
|
||||
function buildRasterClickSelectionContext(
|
||||
selection: DomEditSelection,
|
||||
localPointer: PreviewLocalPointer,
|
||||
): string {
|
||||
return [
|
||||
"The user clicked a large raster/background element in the Studio preview.",
|
||||
`Preview click: x=${Math.round(localPointer.x)}px, y=${Math.round(localPointer.y)}px in a ${Math.round(
|
||||
localPointer.viewport.width,
|
||||
)}x${Math.round(localPointer.viewport.height)} composition.`,
|
||||
`Selected target: <${selection.tagName}> ${selection.selector ?? selection.id ?? selection.label}.`,
|
||||
"Visible copy or artwork at that point may be baked into the selected image/background rather than a selectable DOM text layer.",
|
||||
"If the request mentions text seen at the click location, inspect or replace the image asset, or recreate that visible copy as editable DOM.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function domEditSelectionsTargetSame(
|
||||
@@ -604,17 +665,47 @@ function pauseStudioPreviewPlayback(iframe: HTMLIFrameElement | null): number |
|
||||
|
||||
// ── Ask Agent Modal ──
|
||||
|
||||
function clampNumber(value: number, min: number, max: number): number {
|
||||
if (max < min) return min;
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
function getAgentModalPositionStyle(
|
||||
anchorPoint: AgentModalAnchorPoint | null,
|
||||
): CSSProperties | undefined {
|
||||
if (!anchorPoint || typeof window === "undefined") return undefined;
|
||||
|
||||
const modalWidth = 480;
|
||||
const estimatedModalHeight = 270;
|
||||
const margin = 16;
|
||||
const left = clampNumber(
|
||||
anchorPoint.x,
|
||||
margin + modalWidth / 2,
|
||||
window.innerWidth - margin - modalWidth / 2,
|
||||
);
|
||||
const top = clampNumber(
|
||||
anchorPoint.y + 12,
|
||||
margin,
|
||||
window.innerHeight - margin - estimatedModalHeight,
|
||||
);
|
||||
|
||||
return { left, top, transform: "translateX(-50%)" };
|
||||
}
|
||||
|
||||
function AskAgentModal({
|
||||
selectionLabel,
|
||||
anchorPoint = null,
|
||||
onSubmit,
|
||||
onClose,
|
||||
}: {
|
||||
selectionLabel: string;
|
||||
anchorPoint?: AgentModalAnchorPoint | null;
|
||||
onSubmit: (instruction: string) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [value, setValue] = useState("");
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const modalPositionStyle = getAgentModalPositionStyle(anchorPoint);
|
||||
|
||||
useMountEffect(() => {
|
||||
requestAnimationFrame(() => inputRef.current?.focus());
|
||||
@@ -627,11 +718,18 @@ function AskAgentModal({
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
||||
className={
|
||||
anchorPoint
|
||||
? "fixed inset-0 z-[100] bg-black/60 backdrop-blur-sm"
|
||||
: "fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
||||
}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="w-[480px] rounded-2xl border border-neutral-800 bg-neutral-950 shadow-2xl"
|
||||
className={`w-[480px] rounded-2xl border border-neutral-800 bg-neutral-950 shadow-2xl ${
|
||||
anchorPoint ? "fixed" : ""
|
||||
}`}
|
||||
style={modalPositionStyle}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-neutral-800/60">
|
||||
@@ -786,6 +884,12 @@ export function StudioApp() {
|
||||
const [domEditGroupSelections, setDomEditGroupSelections] = useState<DomEditSelection[]>([]);
|
||||
const [domEditHoverSelection, setDomEditHoverSelection] = useState<DomEditSelection | null>(null);
|
||||
const [agentPromptTagSnippet, setAgentPromptTagSnippet] = useState<string | undefined>();
|
||||
const [agentPromptSelectionContext, setAgentPromptSelectionContext] = useState<
|
||||
string | undefined
|
||||
>();
|
||||
const [agentModalAnchorPoint, setAgentModalAnchorPoint] = useState<AgentModalAnchorPoint | null>(
|
||||
null,
|
||||
);
|
||||
const [copiedAgentPrompt, setCopiedAgentPrompt] = useState(false);
|
||||
const [agentModalOpen, setAgentModalOpen] = useState(false);
|
||||
const [previewIframe, setPreviewIframe] = useState<HTMLIFrameElement | null>(null);
|
||||
@@ -1780,6 +1884,8 @@ export function StudioApp() {
|
||||
options?: { revealPanel?: boolean; additive?: boolean; preserveGroup?: boolean },
|
||||
) => {
|
||||
setAgentPromptTagSnippet(undefined);
|
||||
setAgentPromptSelectionContext(undefined);
|
||||
setAgentModalAnchorPoint(null);
|
||||
setCopiedAgentPrompt(false);
|
||||
if (!selection) {
|
||||
domEditSelectionRef.current = null;
|
||||
@@ -2992,6 +3098,8 @@ export function StudioApp() {
|
||||
const handleAskAgent = useCallback(() => {
|
||||
if (!domEditSelection) return;
|
||||
setAgentPromptTagSnippet(undefined);
|
||||
setAgentPromptSelectionContext(undefined);
|
||||
setAgentModalAnchorPoint(null);
|
||||
void preloadAgentPromptSnippet(domEditSelection);
|
||||
setAgentModalOpen(true);
|
||||
}, [domEditSelection, preloadAgentPromptSnippet]);
|
||||
@@ -3006,6 +3114,7 @@ export function StudioApp() {
|
||||
selection: domEditSelection,
|
||||
currentTime,
|
||||
tagSnippet,
|
||||
selectionContext: agentPromptSelectionContext,
|
||||
userInstruction,
|
||||
sourceFilePath: toProjectAbsolutePath(projectDir, targetPath),
|
||||
});
|
||||
@@ -3017,11 +3126,21 @@ export function StudioApp() {
|
||||
}
|
||||
|
||||
setAgentModalOpen(false);
|
||||
setAgentPromptSelectionContext(undefined);
|
||||
setAgentModalAnchorPoint(null);
|
||||
if (copiedAgentTimerRef.current) clearTimeout(copiedAgentTimerRef.current);
|
||||
setCopiedAgentPrompt(true);
|
||||
copiedAgentTimerRef.current = setTimeout(() => setCopiedAgentPrompt(false), 1600);
|
||||
},
|
||||
[activeCompPath, agentPromptTagSnippet, currentTime, domEditSelection, projectDir, showToast],
|
||||
[
|
||||
activeCompPath,
|
||||
agentPromptSelectionContext,
|
||||
agentPromptTagSnippet,
|
||||
currentTime,
|
||||
domEditSelection,
|
||||
projectDir,
|
||||
showToast,
|
||||
],
|
||||
);
|
||||
|
||||
const handlePreviewIframeRef = useCallback(
|
||||
@@ -3049,9 +3168,29 @@ export function StudioApp() {
|
||||
}
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const localPointer = previewIframeRef.current
|
||||
? getPreviewLocalPointer(previewIframeRef.current, e.clientX, e.clientY)
|
||||
: null;
|
||||
applyDomSelection(nextSelection, { additive: e.shiftKey });
|
||||
if (
|
||||
!e.shiftKey &&
|
||||
localPointer &&
|
||||
isLargeRasterDomEditSelection(nextSelection, localPointer.viewport)
|
||||
) {
|
||||
setAgentPromptSelectionContext(
|
||||
buildRasterClickSelectionContext(nextSelection, localPointer),
|
||||
);
|
||||
setAgentModalAnchorPoint({ x: e.clientX, y: e.clientY });
|
||||
void preloadAgentPromptSnippet(nextSelection);
|
||||
setAgentModalOpen(true);
|
||||
}
|
||||
},
|
||||
[applyDomSelection, captionEditMode, resolveDomSelectionFromPreviewPoint],
|
||||
[
|
||||
applyDomSelection,
|
||||
captionEditMode,
|
||||
preloadAgentPromptSnippet,
|
||||
resolveDomSelectionFromPreviewPoint,
|
||||
],
|
||||
);
|
||||
|
||||
const handlePreviewCanvasPointerMove = useCallback(
|
||||
@@ -4117,8 +4256,13 @@ export function StudioApp() {
|
||||
{agentModalOpen && domEditSelection && (
|
||||
<AskAgentModal
|
||||
selectionLabel={domEditSelection.label}
|
||||
anchorPoint={agentModalAnchorPoint}
|
||||
onSubmit={handleAgentModalSubmit}
|
||||
onClose={() => setAgentModalOpen(false)}
|
||||
onClose={() => {
|
||||
setAgentModalOpen(false);
|
||||
setAgentPromptSelectionContext(undefined);
|
||||
setAgentModalAnchorPoint(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
findElementForTimelineElement,
|
||||
getDomEditNonEditableReason,
|
||||
getDomEditTargetKey,
|
||||
isLargeRasterDomEditSelection,
|
||||
isTextEditableSelection,
|
||||
resolveVisualDomEditSelectionTarget,
|
||||
serializeDomEditTextFields,
|
||||
@@ -322,6 +323,56 @@ describe("resolveVisualDomEditSelectionTarget", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("isLargeRasterDomEditSelection", () => {
|
||||
it("flags large image and background targets for raster click fallback", () => {
|
||||
expect(
|
||||
isLargeRasterDomEditSelection(
|
||||
{
|
||||
tagName: "img",
|
||||
boundingBox: { x: 0, y: 0, width: 1920, height: 1080 },
|
||||
computedStyles: {},
|
||||
},
|
||||
{ width: 1920, height: 1080 },
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
isLargeRasterDomEditSelection(
|
||||
{
|
||||
tagName: "div",
|
||||
boundingBox: { x: 0, y: 0, width: 1280, height: 720 },
|
||||
computedStyles: { "background-image": 'url("hero.png")' },
|
||||
},
|
||||
{ width: 1920, height: 1080 },
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not flag small media or text selections", () => {
|
||||
expect(
|
||||
isLargeRasterDomEditSelection(
|
||||
{
|
||||
tagName: "img",
|
||||
boundingBox: { x: 80, y: 80, width: 96, height: 96 },
|
||||
computedStyles: {},
|
||||
},
|
||||
{ width: 1920, height: 1080 },
|
||||
),
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
isLargeRasterDomEditSelection(
|
||||
{
|
||||
tagName: "h1",
|
||||
boundingBox: { x: 0, y: 0, width: 1600, height: 300 },
|
||||
computedStyles: {},
|
||||
},
|
||||
{ width: 1920, height: 1080 },
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveDomEditSelection", () => {
|
||||
it("keeps composition host transforms disabled in master view", () => {
|
||||
expect(
|
||||
@@ -936,6 +987,50 @@ describe("patch builders and prompt builder", () => {
|
||||
expect(prompt).not.toContain("Source file: index.html");
|
||||
});
|
||||
|
||||
it("includes raster click context in copied agent prompts", () => {
|
||||
const selection = {
|
||||
element: {} as HTMLElement,
|
||||
id: undefined,
|
||||
selector: ".hero-bg",
|
||||
selectorIndex: undefined,
|
||||
sourceFile: "index.html",
|
||||
compositionPath: "index.html",
|
||||
compositionSrc: undefined,
|
||||
isCompositionHost: false,
|
||||
label: "Hero Bg",
|
||||
tagName: "img",
|
||||
boundingBox: { x: 0, y: 0, width: 1920, height: 1080 },
|
||||
textContent: null,
|
||||
dataAttributes: {},
|
||||
inlineStyles: {},
|
||||
computedStyles: {},
|
||||
textFields: [],
|
||||
capabilities: {
|
||||
canSelect: true,
|
||||
canEditStyles: true,
|
||||
canMove: true,
|
||||
canResize: true,
|
||||
canApplyManualOffset: true,
|
||||
canApplyManualSize: true,
|
||||
canApplyManualRotation: true,
|
||||
},
|
||||
} satisfies DomEditSelection;
|
||||
|
||||
const prompt = buildElementAgentPrompt({
|
||||
selection,
|
||||
currentTime: 3,
|
||||
selectionContext:
|
||||
"The user clicked visible text that is baked into the selected image/background.",
|
||||
userInstruction: "Change the title copy.",
|
||||
});
|
||||
|
||||
expect(prompt).toContain("Selection context:");
|
||||
expect(prompt).toContain(
|
||||
"The user clicked visible text that is baked into the selected image/background.",
|
||||
);
|
||||
expect(prompt).toContain("Change the title copy.");
|
||||
});
|
||||
|
||||
it("serializes child text fields back into HTML", () => {
|
||||
expect(
|
||||
serializeDomEditTextFields([
|
||||
|
||||
@@ -105,6 +105,11 @@ export interface DomEditContextOptions {
|
||||
preferClipAncestor?: boolean;
|
||||
}
|
||||
|
||||
export interface DomEditViewport {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface TimelineElementDomTarget {
|
||||
id?: string;
|
||||
domId?: string;
|
||||
@@ -497,6 +502,31 @@ export function resolveVisualDomEditSelectionTarget(
|
||||
return best?.element ?? null;
|
||||
}
|
||||
|
||||
function hasRasterBackground(selection: Pick<DomEditSelection, "computedStyles">): boolean {
|
||||
const backgroundImage = selection.computedStyles["background-image"]?.trim();
|
||||
return Boolean(backgroundImage && backgroundImage !== "none");
|
||||
}
|
||||
|
||||
export function isLargeRasterDomEditSelection(
|
||||
selection: Pick<DomEditSelection, "boundingBox" | "computedStyles" | "tagName">,
|
||||
viewport?: DomEditViewport | null,
|
||||
): boolean {
|
||||
const tagName = selection.tagName.toLowerCase();
|
||||
const isRasterLike = tagName === "img" || hasRasterBackground(selection);
|
||||
if (!isRasterLike) return false;
|
||||
|
||||
const { width, height } = selection.boundingBox;
|
||||
if (width <= 1 || height <= 1) return false;
|
||||
if (!viewport || viewport.width <= 1 || viewport.height <= 1) {
|
||||
return width >= 960 && height >= 540;
|
||||
}
|
||||
|
||||
const areaRatio = (width * height) / (viewport.width * viewport.height);
|
||||
const widthRatio = width / viewport.width;
|
||||
const heightRatio = height / viewport.height;
|
||||
return areaRatio >= 0.4 || (widthRatio >= 0.7 && heightRatio >= 0.5);
|
||||
}
|
||||
|
||||
function getDirectLayerChildren(el: HTMLElement, options: DomEditContextOptions): HTMLElement[] {
|
||||
return Array.from(el.children).filter(
|
||||
(child): child is HTMLElement =>
|
||||
@@ -1013,12 +1043,14 @@ export function buildElementAgentPrompt({
|
||||
selection,
|
||||
currentTime,
|
||||
tagSnippet,
|
||||
selectionContext,
|
||||
userInstruction,
|
||||
sourceFilePath,
|
||||
}: {
|
||||
selection: DomEditSelection;
|
||||
currentTime: number;
|
||||
tagSnippet?: string;
|
||||
selectionContext?: string;
|
||||
userInstruction?: string;
|
||||
sourceFilePath?: string;
|
||||
}): string {
|
||||
@@ -1043,6 +1075,11 @@ export function buildElementAgentPrompt({
|
||||
lines.push(`Text: ${selection.textContent}`);
|
||||
}
|
||||
|
||||
const trimmedSelectionContext = selectionContext?.trim();
|
||||
if (trimmedSelectionContext) {
|
||||
lines.push("", "Selection context:", trimmedSelectionContext);
|
||||
}
|
||||
|
||||
const textFieldsBlock = formatTextFields(selection.textFields);
|
||||
if (textFieldsBlock) {
|
||||
lines.push("", "Text fields:", textFieldsBlock);
|
||||
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 196 KiB |
BIN
Binary file not shown.
Reference in New Issue
Block a user