From f40489d4d7d4581374dd4a4f102024b963e9ddce Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 22 Mar 2026 01:34:50 +0800 Subject: [PATCH] feat: add detail surface overlay renderer --- components/canvas/CanvasChatPanel.tsx | 23 +- .../canvas/DetailSurfaceOverlayRenderer.tsx | 207 ++++++++++++++++++ components/canvas/nodes/ImageNode.tsx | 67 +++--- lib/detail-ui-surface-adapter.ts | 68 ++++++ 4 files changed, 330 insertions(+), 35 deletions(-) create mode 100644 components/canvas/DetailSurfaceOverlayRenderer.tsx diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index 9f21fd8a8..0f3866fef 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -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> = []; 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}`, }, diff --git a/components/canvas/DetailSurfaceOverlayRenderer.tsx b/components/canvas/DetailSurfaceOverlayRenderer.tsx new file mode 100644 index 000000000..b511a7ea8 --- /dev/null +++ b/components/canvas/DetailSurfaceOverlayRenderer.tsx @@ -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; + 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 ( +
+ {components.map((component) => { + const content = resolveOverlayText(params.overlay!, component); + return ( +
+ {content} +
+ ); + })} +
+ ); +} diff --git a/components/canvas/nodes/ImageNode.tsx b/components/canvas/nodes/ImageNode.tsx index 69352040b..9bf4873f5 100644 --- a/components/canvas/nodes/ImageNode.tsx +++ b/components/canvas/nodes/ImageNode.tsx @@ -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) { ) : ( - {displayTitle} { - 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); - } - }} - /> + <> + {displayTitle} { + 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 ? ( + + ) : null} + )} {boxCutoutMode && !isThisNodePicking && !isCompareMode && ( diff --git a/lib/detail-ui-surface-adapter.ts b/lib/detail-ui-surface-adapter.ts index 5adac6355..512810230 100644 --- a/lib/detail-ui-surface-adapter.ts +++ b/lib/detail-ui-surface-adapter.ts @@ -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([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;