feat: add detail surface overlay renderer
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-03-22 01:34:50 +08:00
parent 54f02be0f5
commit f40489d4d7
4 changed files with 330 additions and 35 deletions

View File

@ -7,6 +7,7 @@ import { useSession } from "next-auth/react";
import { createPortal } from "react-dom";
import type { DetailUiProtocol } from "@/lib/detail-ui-protocol";
import type { DetailUiScreenReviewPatch } from "@/lib/detail-ui-protocol";
import { buildDetailUiScreenOverlaySnapshot } from "@/lib/detail-ui-surface-adapter";
import {
buildDetailTextLayerNodes,
buildDetailTextLayerNodesFromReviewPatch,
@ -6240,22 +6241,20 @@ export function CanvasChatPanel({
}) => {
if (!isAgentLayoutMode) return;
const storeState = useCanvasStore.getState();
if (params.surfaceSnapshot) {
replaceDetailTextLayers({
nodeId: params.nodeId,
drafts: [],
});
return;
}
const imageNode = storeState.getNode(params.nodeId);
if (!imageNode) return;
const x = Number(imageNode.position?.x || 0);
const y = Number(imageNode.position?.y || 0);
const width = Number(imageNode.size?.width || DETAIL_TARGET_WIDTH);
const height = Number(imageNode.size?.height || DETAIL_TARGET_HEIGHT);
const drafts =
params.surfaceSnapshot
? buildDetailTextLayerNodesFromSurfaceScreen({
parentNodeId: params.nodeId,
parentPosition: { x, y },
parentSize: { width, height },
surface: params.surfaceSnapshot,
screenIndex: params.screenIndex + 1,
})
: [];
const drafts: Array<Omit<CanvasNode, "id" | "createdAt" | "updatedAt">> = [];
const finalDrafts =
drafts.length > 0
? drafts
@ -6749,6 +6748,10 @@ export function CanvasChatPanel({
detailScreenReview: screenReviewResult || null,
detailUiSurfaceId: currentDetailSurfaceSnapshot?.surfaceId || null,
detailUiSurfaceType: currentDetailSurfaceSnapshot?.surfaceType || null,
detailUiScreenOverlay: buildDetailUiScreenOverlaySnapshot({
surface: currentDetailSurfaceSnapshot,
screenIndex: index + 1,
}),
detailGeneratingPlaceholder: false,
detailProgressLabel: `该屏完成 ${index + 1}/${prompts.length}`,
},

View File

@ -0,0 +1,207 @@
"use client";
import type { CSSProperties } from "react";
import { cn } from "@/lib/utils";
import type { DetailUiScreenOverlaySnapshot } from "@/lib/detail-ui-surface-adapter";
type RenderableOverlayComponent =
DetailUiScreenOverlaySnapshot["components"][number];
const OVERLAY_RENDERABLE_TYPES = new Set([
"detail_text_block",
"detail_card",
"detail_badge",
"detail_feature_card",
"detail_feature_tag",
"detail_spec_table",
"detail_table_label",
"detail_table_value",
"detail_comparison_strip",
"detail_compare_before",
"detail_compare_after",
"detail_proof_bar",
"detail_cta_banner",
"detail_cta_button",
"detail_cta_support",
]);
function collectRenderableComponents(snapshot: DetailUiScreenOverlaySnapshot) {
return snapshot.components
.filter((component) => OVERLAY_RENDERABLE_TYPES.has(component.type))
.sort((left, right) => {
const leftZ = Number(left.layout?.zIndex || 0);
const rightZ = Number(right.layout?.zIndex || 0);
return leftZ - rightZ;
});
}
function resolveOverlayText(
snapshot: DetailUiScreenOverlaySnapshot,
component: RenderableOverlayComponent,
) {
const binding = component.bindings?.content;
if (!binding || binding.kind !== "text") {
return String(component.props?.content || "");
}
const path = String(binding.path || "").trim();
const screen = snapshot.dataModel.screen;
if (!path || !screen) {
return String(component.props?.content || "");
}
if (path.endsWith("/title")) return String(screen.title || "");
if (path.endsWith("/subtitle")) return String(screen.subtitle || "");
if (path.endsWith("/copy")) return String(screen.copy || "");
if (path.endsWith("/layoutNote")) return String(screen.layoutNote || "");
return String(component.props?.content || "");
}
function getOverlayComponentStyle(params: {
component: RenderableOverlayComponent;
snapshot: DetailUiScreenOverlaySnapshot;
content: string;
}) {
const frame = (params.component.layout?.frame || {}) as {
x?: number;
y?: number;
width?: number;
height?: number;
};
const style = (params.component.style || {}) as Record<string, unknown>;
const widthScale = 100 / Math.max(1, params.snapshot.screenSize.width);
const heightScale = 100 / Math.max(1, params.snapshot.screenSize.height);
const overlayStyle: CSSProperties = {
position: "absolute",
left: `${Number(frame.x || 0) * widthScale}%`,
top: `${Number(frame.y || 0) * heightScale}%`,
width: `${Number(frame.width || 0) * widthScale}%`,
height: `${Number(frame.height || 0) * heightScale}%`,
zIndex: Number(params.component.layout?.zIndex || 1),
fontSize: typeof style.fontSize === "number" ? `${style.fontSize}px` : undefined,
fontFamily: typeof style.fontFamily === "string" ? style.fontFamily : undefined,
fontWeight: typeof style.fontWeight === "string" ? style.fontWeight : undefined,
lineHeight: typeof style.lineHeight === "number" ? style.lineHeight : undefined,
letterSpacing:
typeof style.letterSpacing === "number" ? `${style.letterSpacing}px` : undefined,
color: typeof style.color === "string" ? style.color : undefined,
textAlign:
style.textAlign === "center" || style.textAlign === "right"
? style.textAlign
: "left",
textTransform:
style.textTransform === "uppercase" ||
style.textTransform === "lowercase" ||
style.textTransform === "capitalize"
? style.textTransform
: undefined,
background: typeof style.backgroundColor === "string" ? style.backgroundColor : undefined,
borderColor: typeof style.borderColor === "string" ? style.borderColor : undefined,
borderWidth:
typeof style.borderWidth === "number" ? `${style.borderWidth}px` : undefined,
borderStyle:
typeof style.borderWidth === "number" && style.borderWidth > 0 ? "solid" : undefined,
borderRadius:
typeof style.borderRadius === "number" ? `${style.borderRadius}px` : undefined,
padding: typeof style.padding === "number" ? `${style.padding}px` : undefined,
boxShadow: typeof style.boxShadow === "string" ? style.boxShadow : undefined,
backdropFilter:
typeof style.backdropBlur === "number" ? `blur(${style.backdropBlur}px)` : undefined,
WebkitBackdropFilter:
typeof style.backdropBlur === "number" ? `blur(${style.backdropBlur}px)` : undefined,
overflow: "hidden",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
display: "flex",
alignItems: "center",
justifyContent:
style.textAlign === "center"
? "center"
: style.textAlign === "right"
? "flex-end"
: "flex-start",
};
if (
params.component.type === "detail_feature_card" ||
params.component.type === "detail_spec_table" ||
params.component.type === "detail_compare_before" ||
params.component.type === "detail_compare_after" ||
params.component.type === "detail_proof_bar" ||
params.component.type === "detail_cta_banner" ||
params.component.type === "detail_cta_support"
) {
overlayStyle.alignItems = "flex-start";
}
if (params.component.type === "detail_cta_button") {
overlayStyle.justifyContent = "center";
overlayStyle.cursor = "pointer";
}
if (params.component.type === "detail_table_label") {
overlayStyle.justifyContent = "center";
}
return overlayStyle;
}
export function DetailSurfaceOverlayRenderer(params: {
overlay: DetailUiScreenOverlaySnapshot | null;
imageMetrics?: {
offsetX: number;
offsetY: number;
renderedWidth: number;
renderedHeight: number;
} | null;
className?: string;
}) {
if (!params.overlay) return null;
const components = collectRenderableComponents(params.overlay);
if (components.length === 0) return null;
const containerStyle =
params.imageMetrics && params.imageMetrics.renderedWidth > 0 && params.imageMetrics.renderedHeight > 0
? {
position: "absolute" as const,
left: `${params.imageMetrics.offsetX}px`,
top: `${params.imageMetrics.offsetY}px`,
width: `${params.imageMetrics.renderedWidth}px`,
height: `${params.imageMetrics.renderedHeight}px`,
}
: {
position: "absolute" as const,
inset: "0px",
};
return (
<div
className={cn("pointer-events-none z-[120]", params.className)}
data-canvas-no-drag="true"
style={containerStyle}
>
{components.map((component) => {
const content = resolveOverlayText(params.overlay!, component);
return (
<div
key={component.id}
className={cn(
"select-none",
component.type === "detail_cta_button" && "pointer-events-auto",
)}
style={getOverlayComponentStyle({
component,
snapshot: params.overlay!,
content,
})}
>
{content}
</div>
);
})}
</div>
);
}

View File

@ -35,7 +35,9 @@ import { AngleEditDialog } from "@/components/angle-edit-dialog";
import { SceneFusionDialog } from "@/components/scene-fusion-dialog";
import { ImageAnnotator } from "@/components/image-annotator";
import { AgentExecutionSnapshotDialog } from "@/components/agents/AgentExecutionSnapshotDialog";
import { DetailSurfaceOverlayRenderer } from "@/components/canvas/DetailSurfaceOverlayRenderer";
import type { PickedObject } from "@/store/canvas";
import type { DetailUiScreenOverlaySnapshot } from "@/lib/detail-ui-surface-adapter";
// 已标注的物品标记(本地状态)
interface PickedMarker {
@ -367,6 +369,13 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
typeof data.metadata?.agentExecutionSnapshotId === "string"
? data.metadata.agentExecutionSnapshotId.trim()
: "";
const detailUiScreenOverlay = useMemo(() => {
const raw = data.metadata?.detailUiScreenOverlay;
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
return raw as DetailUiScreenOverlaySnapshot;
}, [data.metadata]);
const shouldRenderDetailOverlay =
data.metadata?.detailGenerationMode === "agent_layout" && Boolean(detailUiScreenOverlay);
const shortAgentExecutionSnapshotId = agentExecutionSnapshotId
? agentExecutionSnapshotId.slice(0, 8)
: "";
@ -2502,31 +2511,39 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
</div>
</div>
) : (
<img
ref={displayImageRef}
src={displayImageSrc}
alt={displayTitle}
className={cn(
"w-full h-full object-contain bg-muted/30",
boxCutoutMode ? "cursor-crosshair" : isThisNodePicking ? "cursor-crosshair" : "cursor-pointer"
)}
draggable={false}
loading="lazy"
decoding="async"
onLoad={refreshBoxCutoutMetrics}
onError={(event) => {
if (event.currentTarget.src !== data.src) {
event.currentTarget.src = data.src;
}
}}
onClick={isThisNodePicking && !boxCutoutMode ? handlePickerClick : undefined}
onDoubleClick={(e) => {
if (!isThisNodePicking && !boxCutoutMode) {
e.stopPropagation();
setFullscreenPreviewOpen(true);
}
}}
/>
<>
<img
ref={displayImageRef}
src={displayImageSrc}
alt={displayTitle}
className={cn(
"w-full h-full object-contain bg-muted/30",
boxCutoutMode ? "cursor-crosshair" : isThisNodePicking ? "cursor-crosshair" : "cursor-pointer"
)}
draggable={false}
loading="lazy"
decoding="async"
onLoad={refreshBoxCutoutMetrics}
onError={(event) => {
if (event.currentTarget.src !== data.src) {
event.currentTarget.src = data.src;
}
}}
onClick={isThisNodePicking && !boxCutoutMode ? handlePickerClick : undefined}
onDoubleClick={(e) => {
if (!isThisNodePicking && !boxCutoutMode) {
e.stopPropagation();
setFullscreenPreviewOpen(true);
}
}}
/>
{shouldRenderDetailOverlay ? (
<DetailSurfaceOverlayRenderer
overlay={detailUiScreenOverlay}
imageMetrics={boxCutoutMetrics}
/>
) : null}
</>
)}
{boxCutoutMode && !isThisNodePicking && !isCompareMode && (

View File

@ -11,6 +11,10 @@ import type {
UiSurfacePatch,
UiSurfaceSnapshot,
} from "@/lib/ui-surface-protocol";
import {
collectUiSurfaceDescendantIds,
getUiSurfaceChildren,
} from "@/lib/ui-surface-protocol";
function createDetailSurfaceId(params: {
generationMode: DetailGenerationMode;
@ -974,6 +978,70 @@ function buildDetailSurfaceDataModel(params: {
};
}
export interface DetailUiScreenOverlaySnapshot {
version: "detail-screen-overlay/v1";
surfaceId: string;
surfaceType: "detail_page";
generationMode: DetailGenerationMode;
screenIndex: number;
screenSize: {
width: number;
height: number;
};
rootComponentId: string;
components: UiSurfaceComponent[];
dataModel: {
globalStyle?: DetailUiGlobalStyle;
screen: {
index: number;
title: string;
subtitle: string;
copy: string;
screenType?: string;
objective?: string;
layoutNote?: string;
} | null;
};
validation?: UiSurfaceSnapshot["validation"];
}
export function buildDetailUiScreenOverlaySnapshot(params: {
surface?: UiSurfaceSnapshot | null;
screenIndex: number;
}) {
const surface = params.surface;
if (!surface) return null;
const screenId = `detail-screen-${params.screenIndex}`;
const screenComponent =
surface.components.find((component) => component.id === screenId) || null;
if (!screenComponent) return null;
const descendantIds = collectUiSurfaceDescendantIds(surface, screenId);
const keepIds = new Set<string>([screenId, ...descendantIds]);
const components = surface.components.filter((component) => keepIds.has(component.id));
const screenData = Array.isArray((surface.dataModel as any)?.screens)
? (surface.dataModel as any).screens.find(
(item: any) => Number(item?.index || 0) === params.screenIndex,
) || null
: null;
return {
version: "detail-screen-overlay/v1",
surfaceId: surface.surfaceId,
surfaceType: surface.surfaceType,
generationMode: surface.generationMode,
screenIndex: params.screenIndex,
screenSize: surface.screenSize,
rootComponentId: screenId,
components,
dataModel: {
globalStyle: (surface.dataModel as any)?.globalStyle || {},
screen: screenData,
},
validation: surface.validation,
} satisfies DetailUiScreenOverlaySnapshot;
}
export function buildDetailUiSurfaceSnapshot(params: {
surfaceId?: string;
generationMode: DetailGenerationMode;