"use client"; import { ChevronDown, ChevronUp, Download, Eye, EyeOff, Frame, Image as ImageIcon, Layers, Lock, Type, Unlock, X } from "lucide-react"; import { Fragment, useEffect, useMemo, useRef, useState } from "react"; import type { AgentMessage, CanvasNode, CanvasViewport } from "@/domain/design"; import { useI18n } from "@/i18n/i18n"; import { thumbnailImageUrl } from "@/ui/lib/imageDelivery"; import { ShapeNodePreview } from "@/ui/pages/CanvasWorkspace/canvas/ShapeNodePreview"; import { isBooleanShapeGroupNode, isBooleanShapeOperandNode, isShapeNode } from "@/ui/pages/CanvasWorkspace/canvas/shapeNode"; type LayerPanelItem = | { type: "node"; node: CanvasNode; } | { type: "booleanGroup"; node: CanvasNode; layers: CanvasNode[]; } | { type: "group"; id: string; nodes: CanvasNode[]; layers: CanvasNode[]; }; type Translate = (key: string, values?: Record) => string; export function CanvasGrid({ viewport }: { viewport: CanvasViewport }) { const gridSize = 48 * viewport.k; return (
); } export function CanvasGeneratedFilesPanel({ nodes, messages, selectedIds, onSelect, onDownload, onClose }: { nodes: CanvasNode[]; messages: AgentMessage[]; selectedIds: Set; onSelect: (nodeId: string) => void; onDownload: (node: CanvasNode) => void; onClose: () => void; }) { const { t } = useI18n(); const generatedFileURLs = useMemo(() => generatedFileURLSet(messages), [messages]); const files = useMemo(() => nodes.filter((node) => isGeneratedFileNode(node, generatedFileURLs)).reverse(), [generatedFileURLs, nodes]); return ( ); } export function CanvasLayerPanel({ nodes, selectedIds, hiddenNodeIds, lockedNodeIds, onSelect, onSelectMany, onRenameNode, onToggleHidden, onToggleLocked, onToggleHiddenMany, onToggleLockedMany, onClose }: { nodes: CanvasNode[]; selectedIds: Set; hiddenNodeIds: Set; lockedNodeIds: Set; onSelect: (nodeId: string) => void; onSelectMany: (nodeIds: string[]) => void; onRenameNode: (nodeId: string, title: string) => void; onToggleHidden: (nodeId: string) => void; onToggleLocked: (nodeId: string) => void; onToggleHiddenMany: (nodeIds: string[]) => void; onToggleLockedMany: (nodeIds: string[]) => void; onClose: () => void; }) { const { t } = useI18n(); const layers = useMemo(() => buildLayerPanelItems(nodes), [nodes]); return ( ); } function LayerNodeRow({ node, selected, hidden, locked, nested = false, onSelect, onRenameNode, onToggleHidden, onToggleLocked, controlsVisible = true }: { node: CanvasNode; selected: boolean; hidden: boolean; locked: boolean; nested?: boolean; onSelect: (nodeId: string) => void; onRenameNode: (nodeId: string, title: string) => void; onToggleHidden: (nodeId: string) => void; onToggleLocked: (nodeId: string) => void; controlsVisible?: boolean; }) { const { t } = useI18n(); const inputRef = useRef(null); const [editingName, setEditingName] = useState(false); const [draftName, setDraftName] = useState(node.title); const title = layerTitle(node, t); const canRename = node.type === "image" && !locked; useEffect(() => { if (!editingName) setDraftName(node.title); }, [editingName, node.title]); useEffect(() => { if (!editingName) return; const input = inputRef.current; if (!input) return; input.focus(); input.select(); }, [editingName]); const startEditingName = () => { if (!canRename) return; setDraftName(node.title.trim() || title); setEditingName(true); }; const cancelEditingName = () => { setDraftName(node.title); setEditingName(false); }; const commitEditingName = () => { const nextName = draftName.trim(); if (!nextName || nextName === node.title.trim()) { cancelEditingName(); return; } onRenameNode(node.id, nextName); setEditingName(false); }; return (
onSelect(node.id)}> {layerThumb(node)} {editingName ? ( event.stopPropagation()} onClick={(event) => event.stopPropagation()}> setDraftName(event.target.value)} onBlur={commitEditingName} onKeyDown={(event) => { if (event.key === "Enter") { event.preventDefault(); commitEditingName(); } if (event.key === "Escape") { event.preventDefault(); cancelEditingName(); } }} /> ) : ( )} {controlsVisible ? ( <> ) : ( )}
); } function buildLayerPanelItems(nodes: CanvasNode[]): LayerPanelItem[] { const groups = new Map(); nodes.forEach((node) => { if (!node.parentId) return; groups.set(node.parentId, [...(groups.get(node.parentId) ?? []), node]); }); const emittedGroups = new Set(); const items: LayerPanelItem[] = []; [...nodes].reverse().forEach((node) => { if (isBooleanShapeOperandNode(node, nodes)) return; if (isBooleanShapeGroupNode(node)) { items.push({ type: "booleanGroup", node, layers: [...(groups.get(node.id) ?? [])].reverse() }); return; } if (!node.parentId) { items.push({ type: "node", node }); return; } if (emittedGroups.has(node.parentId)) return; const groupNodes = groups.get(node.parentId) ?? [node]; emittedGroups.add(node.parentId); items.push({ type: "group", id: node.parentId, nodes: groupNodes, layers: [...groupNodes].reverse() }); }); return items; } function layerTitle(node: CanvasNode, t: Translate) { const title = node.title.trim(); if (title) return title; if (node.type === "text") return node.content.trim().slice(0, 24) || t("textNode"); if (node.type === "frame" && node.layerRole === "shape") return t("shapeTool"); if (node.type === "frame") return t("frameNode"); if (node.type === "image") return t("imageNode"); return t("canvasUntitled"); } function layerKindLabel(node: CanvasNode, t: Translate) { if (node.type === "text") return t("textNode"); if (node.type === "frame" && node.layerRole === "shape") return t("shapeTool"); if (node.type === "frame") return t("frameNode"); if (node.type === "image") return t("imageNode"); return node.type; } function layerThumb(node: CanvasNode) { if (isBooleanShapeGroupNode(node) || isShapeNode(node)) { return ; } if (node.type === "image" && isImageUrl(node.content)) { return ; } if (node.type === "text") return ; if (node.type === "frame") return ; return ; } function isGeneratedFileNode(node: CanvasNode, generatedFileURLs: Set) { return ( node.type === "image" && isImageUrl(node.content) && node.status !== "generating" && node.status !== "uploading" && node.status !== "error" && !nonModelGeneratedLayerRoles.has(node.layerRole ?? "") && assetURLSetHas(generatedFileURLs, node.content) ); } function isImageUrl(value: string) { return /^(https?:\/\/|data:image\/|blob:|\/)/.test(value.trim()); } const nonModelGeneratedLayerRoles = new Set(["pen-stroke", "background-removed", "clean-image", "mockup-print", "merge-layer", "manual-frame", "image-generator"]); function generatedFileURLSet(messages: AgentMessage[]) { const urls = new Set(); messages.forEach((message) => { if (message.name !== "generate_media" && message.title !== "generate_media" && message.type !== "tool_use") return; try { const events = JSON.parse(message.content) as Array<{ event_data?: { artifact?: Array<{ image_url?: string; }>; }; }>; events.forEach((event) => { event.event_data?.artifact?.forEach((artifact) => { if (artifact.image_url) addAssetURLKeys(urls, artifact.image_url); }); }); } catch { // Tool messages that are not artifact payloads are ignored here. } }); return urls; } function assetURLSetHas(items: Set, value: string) { return assetURLKeys(value).some((key) => items.has(key)); } function addAssetURLKeys(items: Set, value: string) { assetURLKeys(value).forEach((key) => items.add(key)); } function assetURLKeys(value: string) { const trimmed = value.trim(); if (!trimmed || trimmed.startsWith("data:image/") || trimmed.startsWith("blob:")) return []; const keys = new Set([trimmed]); const queryIndex = trimmed.indexOf("?"); if (queryIndex > 0) keys.add(trimmed.slice(0, queryIndex)); try { const url = new URL(trimmed, window.location.origin); keys.add(url.pathname); if (url.search) keys.add(`${url.pathname}${url.search}`); } catch { // Keep exact and queryless keys above. } return Array.from(keys); }