"use client"; import { BookOpen, ChevronDown, Circle, Folder, Frame, Globe2, Image as ImageIcon, Layers, Lock, LogOut, Map as MapIcon, Loader2, MessageCircle, MessageSquarePlus, PanelRightClose, PenLine, Search, Share2, Trash2, Type, User, Zap } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react"; import { openHome } from "@/application/route"; import { ContextMenu, ContextMenuTrigger } from "@/components/ui/context-menu"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import type { AgentContent, AgentMessage, AgentThreadSummary, CanvasNode, CanvasViewport, ExtractedTextLayer, ExtractNodeTextResponse, MockupModel, MockupModelMap, MockupPlacement, MockupSurface, NodeActionRequest, Project } from "@/domain/design"; import { useI18n, type Locale } from "@/i18n/i18n"; import { designGateway } from "@/infrastructure/designGateway"; import { useAuth } from "@/ui/auth/AuthProvider"; import { AccountManagementDialog } from "@/ui/components/AccountManagementDialog"; import { ArtboardPreview } from "@/ui/components/ArtboardPreview"; import { CanvasAnnotationPreview } from "@/ui/components/CanvasAnnotationPreview"; import { CanvasAnnotations } from "@/ui/components/CanvasAnnotations"; import { CanvasAgentComposer, composerImageSize, defaultComposerImageSettings, type ComposerImageSettings, type ComposerMode } from "@/ui/components/CanvasAgentComposer"; import { CanvasAgentTimeline } from "@/ui/components/CanvasAgentTimeline"; import { CanvasGeneratedFilesPanel, CanvasGrid, CanvasLayerPanel } from "@/ui/components/CanvasPanels"; import { CanvasMinimap } from "@/ui/components/CanvasMinimap"; import { CanvasNodeContextMenu, type NodeContextMenuHandlers } from "@/ui/components/CanvasNodeContextMenu"; import { useGlobalToast } from "@/ui/components/CanvasToast"; import { CanvasZoomMenu } from "@/ui/components/CanvasZoomMenu"; import { useUserMessage } from "@/ui/hooks/useUserMessage"; import { ImageGeneratorFloatingComposer } from "@/ui/components/ImageGeneratorFloatingComposer"; import { AdjustPanel, CropOverlay, defaultToolbarVisibility, EraserOverlay, ExpandPanel, FlipRotatePanel, ImageNodeToolbar, MockupDropOverlay, MoveObjectOverlay, MultiAnglePanel, QuickEditPanel, VectorizedImageToolbar, type AdjustOptions, type ExpandOptions, type ImageToolbarVisibility, type MaskPoint, type MoveObjectSelection, type MultiAngleOptions, type NormalizedRect } from "@/ui/components/ImageNodeActions"; import { ImageTextEditPanel, type EditableTextLayer } from "@/ui/components/ImageTextEditPanel"; import { MultiSelectionOverlay, type MultiSelectionResizeHandle } from "@/ui/components/MultiSelectionOverlay"; import { TextStyleToolbar, type TextStylePatch } from "@/ui/components/TextStyleToolbar"; import { loadMotevaFontCatalog } from "@/ui/components/TextStyleToolbar/fontCatalog"; import { WorkspaceTitle } from "@/ui/components/WorkspaceTitle"; import { autoImageAdjustments, defaultImageAdjustments, parseImageAdjustments, serializeImageAdjustments } from "@/ui/lib/imageAdjustments"; import { canvasImageUrl } from "@/ui/lib/imageDelivery"; import { isImageGeneratorThreadMessage, visibleAgentMessages } from "./canvas/agentMessageFilters"; import { CanvasColorPopover } from "./canvas/CanvasColorPopover"; import { buildAlignmentGuides, clamp, minGroupNodeScale, nodeBounds, nodeIntersectsRect, normalizeScreenRect, pointInsideNode, scaleGroupNode, screenBoundsForNodes, screenRectToWorldRect, type AlignmentGuide, type NodeAlignment, type NodeDistribution, type NodeTransform, type ResizeHandle, type SelectionBox } from "@/ui/pages/CanvasWorkspace/canvas/canvasGeometry"; import { alignNodes, autoLayoutAroundNode as autoLayoutAroundNodeOp, autoLayoutNodes, canResizeCanvasNode, canUseNodeContextMenu, cloneCanvasNode, distributeNodes, groupNodes, isCanvasPenStrokeNode, isRealCanvasImageNode, moveNodesByDelta, patchFrameStyleNode, removeNodeById, removeNodesById, reorderNode as reorderNodeOp, resizeVisualNodeByHandle, toggleSetValue, toggleSetValues, ungroupNodes, updateNodeContent as updateNodeContentOp, updateNodeTransform as updateNodeTransformOp, visibleCanvasNodes as getVisibleCanvasNodes } from "@/ui/pages/CanvasWorkspace/canvas/canvasNodeOps"; import { CanvasSelectionFrame } from "@/ui/pages/CanvasWorkspace/canvas/CanvasSelectionFrame"; import { beginFrameDrawing, FrameDraftOverlay, type FrameDraft, type FrameWorldRect } from "./canvas/FrameToolOverlay"; import { FrameStyleToolbar } from "./canvas/FrameStyleToolbar"; import { defaultFrameFillColor, defaultFrameStrokeColor, defaultFrameStrokeStyle, defaultFrameStrokeWidth, normalizeHexColor } from "./canvas/frameStyle"; import { generatorTaskPollIntervalMs } from "@/ui/pages/CanvasWorkspace/canvas/generatorTaskPolling"; import { useCanvasDocument } from "./canvas/hooks/useCanvasDocument"; import { useCanvasPersistence } from "./canvas/hooks/useCanvasPersistence"; import { useCanvasSelection } from "./canvas/hooks/useCanvasSelection"; import { useGenerationStream, type GenerationChannel } from "./canvas/hooks/useGenerationStream"; import { imageGeneratorPlaceholderSize } from "./canvas/imageGeneratorSizing"; import { canvasNodeToReferenceImage, isAssetReferencedByCanvas, isCanvasNodeReferenceImage } from "./canvas/nodeReferenceImages"; import { canvasNodeSvg, exportCanvasNode, exportImageNode, exportTextNode, imageNodeRasterBlob, svgToRasterBlob } from "./canvas/nodeExport"; import { beginPenStroke, createPenStrokeNode, PenStrokeDraftOverlay, PenToolControls, type PenDraft, type PenSettings } from "@/ui/pages/CanvasWorkspace/canvas/PenTool"; import { PenStrokeToolbar, updatePenStrokeNode, type PenStrokePatch } from "./canvas/PenStrokeToolbar"; import { ShapeNodePreview } from "@/ui/pages/CanvasWorkspace/canvas/ShapeNodePreview"; import { ShapeStyleToolbar } from "@/ui/pages/CanvasWorkspace/canvas/ShapeStyleToolbar"; import { beginShapeDrawing, ShapeDraftOverlay, type ShapeDraft } from "@/ui/pages/CanvasWorkspace/canvas/ShapeToolOverlay"; import { createBooleanShapeGroupNode, createShapeNode, isBooleanCapableShapeNode, isBooleanShapeGroupNode, isBooleanShapeOperandNode, isLineShapeNode, isShapeNode, patchShapeStyleNode, resizeLineShapeEndpoint, shapeTitle, updateBooleanShapeOperation, type BooleanShapeOperation, type ShapeEndpointKey, type ShapeKind, type ShapeStylePatch, type ShapeWorldDraft } from "@/ui/pages/CanvasWorkspace/canvas/shapeNode"; import { defaultTextVisibleBox, defaultTextVisibleFontSize } from "./canvas/textNodeDefaults"; import { WorkspaceToolbar } from "./canvas/WorkspaceToolbar"; import type { CanvasTool, FrameStylePatch, NodeScreenBounds } from "./canvas/types"; import { revokeReferenceImage, revokeReferenceImages, type AnnotationPreviewRequest, type CanvasAnnotation, type PromptComposerHandle, type UploadedReferenceImage } from "@/ui/components/PromptComposer"; const minCanvasZoom = 0.1; const maxCanvasZoom = 2.8; const defaultCanvasBackgroundColor = "#F7F7F4"; const canvasBackgroundSwatches = ["#F5F5F5", "#000000", "#FFFFFF", "#00F016", "#A855F7", "#E9D5FF"]; const generatedFilesPanelWidth = 408; const layersPanelWidth = 274; const newAgentConversationId = "__new_agent_conversation__"; const canvasWheelGuardSelector = [ ".ui-popover-content", ".ui-dropdown-menu-content", ".ui-context-menu-content", ".canvas-layer-panel", ".canvas-generated-files-panel", ".image-action-panel", ".image-node-toolbar", ".vector-result-toolbar", ".text-style-toolbar", ".workspace-toolbar", ".canvas-statusbar", ".canvas-minimap", ".account-management-backdrop", ".account-management-dialog" ].join(","); type LocalizedText = Record; type HomeImageModel = { id: string; label: string; description: string; duration: string; credits: number; }; type ImageActionMode = "quick-edit" | "eraser" | "edit-text" | "multi-angle" | "move-object" | "mockup" | "adjust" | "expand" | "crop" | "flip-rotate" | null; const defaultCropSelection: NormalizedRect = { x: 0, y: 0, width: 1, height: 1 }; const imageModelBlueprints = [ { id: "gpt-image-2-auto", label: { "zh-CN": "GPT Image 2", "en-US": "GPT Image 2" }, description: { "zh-CN": "根据提示词自动调整质量,成本可能变化。", "en-US": "Automatically adjusts quality per prompt. Costs may vary." }, duration: "30s", credits: 15 }, { id: "gpt-image-2-low", label: { "zh-CN": "GPT Image 2 Low", "en-US": "GPT Image 2 Low" }, description: { "zh-CN": "速度快、成本低,适合多数探索任务。", "en-US": "Fast, affordable, great for most tasks." }, duration: "30s", credits: 8 }, { id: "gpt-image-2-medium", label: { "zh-CN": "GPT Image 2 Medium", "en-US": "GPT Image 2 Medium" }, description: { "zh-CN": "更多细节,中等成本,适合精修视觉。", "en-US": "More detail, moderate cost." }, duration: "30s", credits: 12 }, { id: "nano-banana-pro", label: { "zh-CN": "Nano Banana Pro", "en-US": "Nano Banana Pro" }, description: { "zh-CN": "适合快速生成商品图、品牌视觉和社媒图。", "en-US": "Good for fast product visuals, brand images, and social posts." }, duration: "20s", credits: 20 }, { id: "nano-banana-2", label: { "zh-CN": "Nano Banana 2", "en-US": "Nano Banana 2" }, description: { "zh-CN": "平衡速度和质量,适合日常创意探索。", "en-US": "Balanced speed and quality for everyday exploration." }, duration: "20s", credits: 14 }, { id: "nano-banana-2-lite", label: { "zh-CN": "Nano Banana 2 Lite", "en-US": "Nano Banana 2 Lite" }, description: { "zh-CN": "轻量低成本,适合草稿与快速迭代。", "en-US": "Lightweight and lower cost for drafts and quick iteration." }, duration: "15s", credits: 8 }, { id: "gpt-image-1.5", label: { "zh-CN": "GPT Image 1.5", "en-US": "GPT Image 1.5" }, description: { "zh-CN": "经典图片模型,适合成本敏感的基础生图。", "en-US": "Classic image model for cost-sensitive generation." }, duration: "30s", credits: 10 }, { id: "luma-uni-1", label: { "zh-CN": "Luma Uni-1", "en-US": "Luma Uni-1" }, description: { "zh-CN": "适合风格化视觉与创意氛围图。", "en-US": "Good for stylized creative visuals." }, duration: "25s", credits: 18 }, { id: "luma-uni-1-max", label: { "zh-CN": "Luma Uni-1 Max", "en-US": "Luma Uni-1 Max" }, description: { "zh-CN": "高质量版本,适合关键视觉和精修输出。", "en-US": "Higher quality for key visuals and polished output." }, duration: "35s", credits: 30 }, { id: "flux-2-pro", label: { "zh-CN": "Flux 2 Pro", "en-US": "Flux 2 Pro" }, description: { "zh-CN": "适合产品、字体感和商业海报探索。", "en-US": "Good for products, typography-heavy visuals, and commercial posters." }, duration: "30s", credits: 16 } ] satisfies Array<{ id: string; label: LocalizedText; description: LocalizedText; duration: string; credits: number; }>; function getImageModels(locale: Locale): HomeImageModel[] { return imageModelBlueprints.map((model) => ({ id: model.id, label: model.label[locale], description: model.description[locale], duration: model.duration, credits: model.credits })); } function toImageApiModel(modelId: string) { return modelId.startsWith("gpt-image-2") ? "gpt-image-2" : modelId; } function defaultImageSize() { return "1024x1024"; } function backgroundRemovalOptions(node: CanvasNode): Record { const width = Math.max(1, Math.round(node.width)); const height = Math.max(1, Math.round(node.height)); return { imageWidth: String(width), imageHeight: String(height), inputSize: String(backgroundRemovalInputSize(width, height)) }; } function backgroundRemovalInputSize(width: number, height: number) { const maxSide = Math.max(width, height, 1); const rounded = Math.ceil(maxSide / 32) * 32; return Math.min(Math.max(rounded, 256), 2048); } function shouldBypassCanvasWheel(target: EventTarget | null) { return target instanceof Element && Boolean(target.closest(canvasWheelGuardSelector)); } function expandTargetGeometryForNode(node: CanvasNode, options: ExpandOptions) { const ratio = expandRatioValue(options.ratio, node.width / Math.max(node.height, 1)); const sourceRatio = node.width / Math.max(node.height, 1); const scale = normalizeExpandScale(options.scale); let width = node.width; let height = node.height; if (ratio > sourceRatio) { width = node.height * ratio; } else { height = node.width / Math.max(ratio, 0.01); } width *= scale; height *= scale; const imageRect = normalizeExpandSourceRect(options.imageRect, node, width, height); return { x: node.x - imageRect.x * width, y: node.y - imageRect.y * height, width, height, imageRect }; } function normalizeExpandSourceRect(rect: NormalizedRect | undefined, node: CanvasNode, targetWidth: number, targetHeight: number): NormalizedRect { const defaultRect = { x: (1 - node.width / Math.max(targetWidth, 1)) / 2, y: (1 - node.height / Math.max(targetHeight, 1)) / 2, width: node.width / Math.max(targetWidth, 1), height: node.height / Math.max(targetHeight, 1) }; if (!rect) return clampExpandRect(defaultRect); return clampExpandRect({ ...rect, width: defaultRect.width, height: defaultRect.height }); } function clampExpandRect(rect: NormalizedRect): NormalizedRect { const width = clamp(rect.width, 0.02, 1); const height = clamp(rect.height, 0.02, 1); return { x: clamp(rect.x, 0, Math.max(0, 1 - width)), y: clamp(rect.y, 0, Math.max(0, 1 - height)), width, height }; } function expandRatioValue(ratio: ExpandOptions["ratio"], fallbackRatio: number) { switch (ratio) { case "1:1": return 1; case "3:4": return 3 / 4; case "2:3": return 2 / 3; case "9:16": return 9 / 16; case "4:3": return 4 / 3; case "3:2": return 3 / 2; case "16:9": return 16 / 9; case "4:5": return 4 / 5; case "5:4": return 5 / 4; default: return Math.max(0.01, fallbackRatio); } } function normalizeExpandScale(scale: number) { if (!Number.isFinite(scale)) return 1; return clamp(scale, 1, 2); } function expandImageSize(ratio: ExpandOptions["ratio"], fallbackRatio: number) { const fixedSizes: Partial> = { "1:1": "1024x1024", "3:4": "1024x1365", "2:3": "1024x1536", "9:16": "1152x2048", "4:3": "1365x1024", "3:2": "1536x1024", "16:9": "2048x1152", "4:5": "1024x1280", "5:4": "1280x1024" }; if (ratio !== "original" && fixedSizes[ratio]) return fixedSizes[ratio]; const safeRatio = Math.max(0.01, fallbackRatio); if (safeRatio >= 1) return `${Math.round(1024 * safeRatio)}x1024`; return `1024x${Math.round(1024 / safeRatio)}`; } function withImageModeSettings( contents: AgentContent[], settings: ComposerImageSettings, t: (key: string, values?: Record) => string, targetNodeId?: string ) { return [ ...contents, { type: "text" as const, text: t("imageModeDirective", { quality: t(`quality${settings.quality === "auto" ? "Auto" : settings.quality === "high" ? "High" : settings.quality === "medium" ? "Medium" : "Low"}`), ratio: settings.ratio, width: settings.width, height: settings.height, count: settings.count }), textSource: "settings" }, ...(targetNodeId ? [ { type: "text" as const, text: `Image generator target node: ${targetNodeId}`, textSource: "settings" } ] : []) ]; } function CanvasAccountControls({ credits, collapsed = false, onOpenConversation }: { credits: number; collapsed?: boolean; onOpenConversation?: () => void }) { const { t, locale, toggleLocale } = useI18n(); const { isAuthenticated, user, openLogin, logout, updateUser } = useAuth(); const accountRef = useRef(null); const [accountOpen, setAccountOpen] = useState(false); const [accountManagementOpen, setAccountManagementOpen] = useState(false); const displayName = user?.name?.trim() || user?.email?.split("@")[0] || t("account"); const displayEmail = user?.email?.trim() || ""; const displayAvatar = cleanCanvasAvatarUrl(user?.avatarUrl); const avatarLabel = displayEmail ? `${displayName} ${displayEmail}` : displayName; const initials = canvasAvatarInitials(displayName || displayEmail); useEffect(() => { if (!accountOpen) return; const closeOnOutside = (event: MouseEvent) => { if (!accountRef.current?.contains(event.target as Node)) { setAccountOpen(false); } }; const closeOnEscape = (event: KeyboardEvent) => { if (event.key === "Escape") setAccountOpen(false); }; document.addEventListener("mousedown", closeOnOutside); document.addEventListener("keydown", closeOnEscape); return () => { document.removeEventListener("mousedown", closeOnOutside); document.removeEventListener("keydown", closeOnEscape); }; }, [accountOpen]); const openAccountMenu = () => { if (!isAuthenticated) { openLogin(); return; } setAccountOpen((open) => !open); }; return (
{credits}
{isAuthenticated && accountOpen && (
{displayAvatar ? : initials} {displayName} {displayEmail && {displayEmail}}
)}
{collapsed && ( <>
); } function canvasAvatarInitials(value: string) { const cleaned = value.trim(); if (!cleaned) return "M"; const words = cleaned.split(/\s+/).filter(Boolean); if (words.length >= 2) return `${words[0][0] ?? ""}${words[1][0] ?? ""}`.toUpperCase(); return Array.from(cleaned)[0]?.toUpperCase() ?? "M"; } function cleanCanvasAvatarUrl(value: string | undefined) { const avatarUrl = value?.trim() || ""; const lowerAvatarUrl = avatarUrl.toLowerCase(); if (lowerAvatarUrl.includes("assets-persist.") && lowerAvatarUrl.includes("/img/") && lowerAvatarUrl.includes("-avatar.png")) return ""; return avatarUrl; } export function CanvasWorkspace({ projectId }: { projectId: string }) { const { t, locale } = useI18n(); const { isAuthenticated } = useAuth(); const toUserMessageText = useUserMessage(); const { showToast } = useGlobalToast(); const containerRef = useRef(null); const canvasImageInputRef = useRef(null); const agentComposerRef = useRef(null); const referenceImagesRef = useRef([]); const annotationsRef = useRef([]); const imageGeneratorThreadIdsRef = useRef>(new Set()); const activeAgentThreadIdRef = useRef(null); const selectedAgentThreadIdRef = useRef(null); const lastCanvasPointerRef = useRef<{ clientX: number; clientY: number } | null>(null); const textExtractionEventsCloseRef = useRef<(() => void) | null>(null); const mockupPrepareTimerRef = useRef | null>(null); const mockupPrepareRequestRef = useRef(0); const moveRecognitionRequestRef = useRef(0); const quickEditFocusRef = useRef<{ viewport: CanvasViewport; dirty: boolean } | null>(null); const webSearchHydratedRef = useRef(false); const { project, setProject, projectRef, viewport, setViewport, viewportRef, nodes, setNodes, nodesRef, applyProjectDocument } = useCanvasDocument(); const { selectedId, selectedIds, setSelectedIds, selectedNodes, selectedNode, selectOnlyNode, selectNodes, toggleNodeSelection } = useCanvasSelection(nodes); const [messages, setMessages] = useState([]); const [prompt, setPrompt] = useState(""); const [agentThreads, setAgentThreads] = useState([]); const [credits, setCredits] = useState(30); const [assistantPanelOpen, setAssistantPanelOpen] = useState(true); const [agentHistoryOpen, setAgentHistoryOpen] = useState(false); const [agentHistorySearch, setAgentHistorySearch] = useState(""); const [selectedAgentThreadId, setSelectedAgentThreadId] = useState(null); const [referenceImages, setReferenceImages] = useState([]); const [annotations, setAnnotations] = useState([]); const [annotationPreview, setAnnotationPreview] = useState(null); const [composerMode, setComposerMode] = useState("agent"); const [useAgentWebSearch, setUseAgentWebSearch] = useState(false); const [selectedCanvasModelId, setSelectedCanvasModelId] = useState("gpt-image-2-auto"); const [composerImageSettings, setComposerImageSettings] = useState(defaultComposerImageSettings); const [canvasStageSize, setCanvasStageSize] = useState({ width: 1100, height: 760 }); const [mode, setMode] = useState<"select" | "pan">("select"); const [activeTool, setActiveTool] = useState("select"); const [editingTextNodeId, setEditingTextNodeId] = useState(null); const [canvasToolMenuOpen, setCanvasToolMenuOpen] = useState(false); const [shapeToolMenuOpen, setShapeToolMenuOpen] = useState(false); const [selectedShapeKind, setSelectedShapeKind] = useState("rectangle"); const [layersPanelOpen, setLayersPanelOpen] = useState(false); const [generatedFilesPanelOpen, setGeneratedFilesPanelOpen] = useState(false); const [showMinimap, setShowMinimap] = useState(() => { if (typeof window === "undefined") return true; return window.localStorage.getItem("fluxo:show-minimap") !== "false"; }); const [canvasBackgroundColor, setCanvasBackgroundColor] = useState(defaultCanvasBackgroundColor); const [showGrid, setShowGrid] = useState(false); const [, setSaving] = useState(false); const [agentGenerating, setAgentGenerating] = useState(false); const [imageGeneratorGenerating, setImageGeneratorGenerating] = useState(false); const [canvasActionGenerating, setCanvasActionGenerating] = useState(false); const [dirty, setDirty] = useState(false); const [selectionBox, setSelectionBox] = useState(null); const [frameDraft, setFrameDraft] = useState(null); const [shapeDraft, setShapeDraft] = useState(null); const [penDraft, setPenDraft] = useState(null); const [penSettings, setPenSettings] = useState({ color: "#000000", width: 3 }); const [canvasPanning, setCanvasPanning] = useState(false); const [alignmentGuides, setAlignmentGuides] = useState([]); const [canvasFileDragging, setCanvasFileDragging] = useState(false); const [error, setError] = useState(""); const [clipboardNode, setClipboardNode] = useState(null); const [hiddenNodeIds, setHiddenNodeIds] = useState>(() => new Set()); const [lockedNodeIds, setLockedNodeIds] = useState>(() => new Set()); const [toolbarVisibility, setToolbarVisibility] = useState(defaultToolbarVisibility); const [imageActionMode, setImageActionMode] = useState(null); const [quickEditPrompt, setQuickEditPrompt] = useState(""); const [eraserBrushSize, setEraserBrushSize] = useState(42); const [eraserBrushColor, setEraserBrushColor] = useState("#5E6CFF"); const [eraserBrushOpacity, setEraserBrushOpacity] = useState(72); const [eraserMarks, setEraserMarks] = useState([]); const [textEditItems, setTextEditItems] = useState([]); const [textEditLoading, setTextEditLoading] = useState(false); const [textEditError, setTextEditError] = useState(""); const [mockupDropActive, setMockupDropActive] = useState(false); const [mockupDragNodeId, setMockupDragNodeId] = useState(null); const [mockupPendingTargetId, setMockupPendingTargetId] = useState(null); const [mockupReadyTargetId, setMockupReadyTargetId] = useState(null); const [mockupPlacement, setMockupPlacement] = useState(null); const [mockupSurface, setMockupSurface] = useState(null); const [mockupModel, setMockupModel] = useState(null); const [movePrompt, setMovePrompt] = useState(""); const [moveSelection, setMoveSelection] = useState(null); const [moveRecognitionLabel, setMoveRecognitionLabel] = useState(""); const [moveRecognitionLoading, setMoveRecognitionLoading] = useState(false); const [cropPrompt, setCropPrompt] = useState(""); const [cropSelection, setCropSelection] = useState(null); const [adjustOptions, setAdjustOptions] = useState(defaultImageAdjustments); const [expandOptions, setExpandOptions] = useState({ ratio: "3:4", scale: 1, prompt: "" }); const [multiAngleOptions, setMultiAngleOptions] = useState({ tab: "subject", yaw: 29, pitch: 25, zoom: 50, prompt: "" }); const canvasActionWorkActive = canvasActionGenerating || textEditLoading || nodes.some(isCanvasActionWorkingNode); const agentTimelineActive = agentGenerating || Boolean(project && !projectHasCanvasActionGeneration(project) && nodes.some(isAgentVisibleGeneratingNode)); const mockupPreparing = Boolean(mockupPendingTargetId); const anyGenerating = agentGenerating || imageGeneratorGenerating || canvasActionWorkActive || mockupPreparing; useEffect(() => { window.localStorage.setItem("fluxo:show-minimap", showMinimap ? "true" : "false"); }, [showMinimap]); useEffect(() => { if (!isAuthenticated) { setCredits(30); return; } designGateway .dashboard() .then((dashboard) => setCredits(dashboard.credits)) .catch(() => undefined); }, [isAuthenticated]); useEffect(() => { webSearchHydratedRef.current = false; const stored = window.localStorage.getItem(canvasBackgroundStorageKey(projectId)); setCanvasBackgroundColor(normalizeHexColor(stored ?? "", defaultCanvasBackgroundColor)); }, [projectId]); useEffect(() => { if (editingTextNodeId && !nodes.some((node) => node.id === editingTextNodeId)) { setEditingTextNodeId(null); } }, [editingTextNodeId, nodes]); const applyProject = useCallback((nextProject: Project, options: { preserveMessages?: boolean } = {}) => { applyProjectDocument(nextProject); if (!options.preserveMessages) { setMessages(visibleAgentMessagesForThread(nextProject, imageGeneratorThreadIdsRef.current, selectedAgentThreadIdRef.current)); } setEditingTextNodeId(null); }, [applyProjectDocument]); const appendThinkingMessage = useCallback((message: AgentMessage) => { if (isImageGeneratorThreadMessage(message, imageGeneratorThreadIdsRef.current)) return; if (message.type === "text_extraction_result" || message.name === "text_extraction") return; const selectedThreadId = selectedAgentThreadIdRef.current; if (selectedThreadId === newAgentConversationId) return; if (selectedThreadId) { const messageThreadId = message.threadId || projectRef.current?.lastThreadId || ""; if (messageThreadId !== selectedThreadId) return; } setMessages((current) => { const existingIndex = current.findIndex((item) => item.id === message.id); if (existingIndex < 0) return [...current, message]; const next = [...current]; next[existingIndex] = message; return next; }); }, []); const { startProjectEvents, startAgentSocket, stopGenerationStream, agentStreamingProjectIdRef } = useGenerationStream({ applyProject, appendThinkingMessage, imageGeneratorThreadIdsRef, setAgentGenerating, setImageGeneratorGenerating, setCanvasActionGenerating, setError, messages: { generateError: t("generateError"), generateTimeout: t("generateTimeout") } }); const loadAgentThreads = useCallback(async () => { const currentProject = projectRef.current; if (!currentProject) return; try { const response = await designGateway.listAgentThreads(currentProject.id); setAgentThreads(response.threads); } catch (err) { console.warn("Failed to load agent thread history", err); } }, [projectRef]); const { saveCanvasSnapshot, updateProjectTitle } = useCanvasPersistence({ project, projectRef, viewport, viewportRef, nodes, nodesRef, dirty, anyGenerating, setProject, setDirty, setSaving, setError, messages: { saveError: t("saveError"), renameProjectError: t("renameProjectError") } }); useEffect(() => { if (!agentGenerating) { activeAgentThreadIdRef.current = null; } }, [agentGenerating]); useEffect(() => { selectedAgentThreadIdRef.current = null; setSelectedAgentThreadId(null); setAgentThreads([]); setAgentHistorySearch(""); setAgentHistoryOpen(false); }, [projectId]); useEffect(() => { if (agentHistoryOpen) void loadAgentThreads(); }, [agentHistoryOpen, loadAgentThreads]); useEffect(() => { designGateway .getProject(projectId) .then(({ project: nextProject }) => { applyProject(nextProject); if (!webSearchHydratedRef.current) { setUseAgentWebSearch(projectHasWebSearchEnabled(nextProject)); webSearchHydratedRef.current = true; } if (isProjectGenerating(nextProject)) { const channel: GenerationChannel = projectHasImageGeneratorGeneration(nextProject) ? "image-generator" : projectHasCanvasActionGeneration(nextProject) ? "canvas-action" : "agent"; if (channel === "image-generator") { setImageGeneratorGenerating(true); } else if (channel === "canvas-action") { setCanvasActionGenerating(true); runningCanvasActionThreadIds(nextProject).forEach((threadId) => startProjectEvents(nextProject.id, threadId, "canvas-action")); } else { setAgentGenerating(true); activeAgentThreadIdRef.current = nextProject.lastThreadId || null; } if (channel !== "canvas-action") { startProjectEvents(nextProject.id, nextProject.lastThreadId, channel); } } return designGateway.getProjectHistory(nextProject.id); }) .then((history) => { if (history && !agentStreamingProjectIdRef.current) { const currentProject = projectRef.current; if (currentProject) { setMessages(visibleAgentMessagesForThread({ ...currentProject, messages: history.messages }, imageGeneratorThreadIdsRef.current, selectedAgentThreadIdRef.current)); } else { setMessages(visibleAgentMessages(history.messages, imageGeneratorThreadIdsRef.current)); } } }) .catch((err: unknown) => setError(toUserMessageText(err, "generateError"))); }, [applyProject, projectId, startProjectEvents]); useEffect(() => { referenceImagesRef.current = referenceImages; }, [referenceImages]); useEffect(() => { annotationsRef.current = annotations; }, [annotations]); useEffect(() => () => revokeReferenceImages(referenceImagesRef.current), []); useEffect(() => { void loadMotevaFontCatalog(); }, []); useEffect(() => { return () => { textExtractionEventsCloseRef.current?.(); mockupPrepareRequestRef.current += 1; if (mockupPrepareTimerRef.current) clearTimeout(mockupPrepareTimerRef.current); }; }, []); useEffect(() => { const stage = containerRef.current; if (!stage) return; const updateSize = () => { const nextSize = { width: stage.clientWidth, height: stage.clientHeight }; setCanvasStageSize((current) => (current.width === nextSize.width && current.height === nextSize.height ? current : nextSize)); }; updateSize(); const observer = new ResizeObserver(updateSize); observer.observe(stage); window.addEventListener("resize", updateSize); return () => { observer.disconnect(); window.removeEventListener("resize", updateSize); }; }, [project?.id, projectId]); const visibleCanvasNodes = useMemo(() => getVisibleCanvasNodes(nodes, hiddenNodeIds), [hiddenNodeIds, nodes]); const renderedCanvasNodes = useMemo(() => nodes.filter((node) => !isBooleanShapeOperandNode(node, nodes) && (!hiddenNodeIds.has(node.id) || selectedIds.has(node.id))), [hiddenNodeIds, nodes, selectedIds]); const imageModels = useMemo(() => getImageModels(locale), [locale]); const agentHistoryThreads = useMemo(() => agentHistoryListItems(agentThreads, project, imageGeneratorThreadIdsRef.current), [agentThreads, project]); const filteredAgentHistoryThreads = useMemo(() => { const keyword = agentHistorySearch.trim().toLowerCase(); if (!keyword) return agentHistoryThreads; return agentHistoryThreads.filter((thread) => agentThreadTitle(thread).toLowerCase().includes(keyword)); }, [agentHistorySearch, agentHistoryThreads]); const selectedCanvasModel = imageModels.find((model) => model.id === selectedCanvasModelId) ?? imageModels[0]; const activeHistoryThreadId = selectedAgentThreadId === newAgentConversationId ? newAgentConversationId : selectedAgentThreadId || agentHistoryThreads[0]?.agentThreadId || ""; const selectedNodeControlsEnabled = selectedNodes.length <= 1 && selectedNode ? !lockedNodeIds.has(selectedNode.id) : false; const selectedControlNode = selectedNodeControlsEnabled ? selectedNode : null; const selectedImageNode = selectedControlNode && isRealCanvasImageNode(selectedControlNode) ? selectedControlNode : null; const selectedImageNodeBusy = selectedImageNode ? isImageActionBusy(selectedImageNode) : false; const selectedImageGeneratorNode = selectedControlNode?.type === "image" && selectedControlNode.layerRole === "image-generator" ? selectedControlNode : null; const selectedImageGeneratorBusy = imageGeneratorGenerating || selectedImageGeneratorNode?.status === "generating"; const selectedPenStrokeNode = selectedControlNode && isCanvasPenStrokeNode(selectedControlNode) ? selectedControlNode : null; const selectedFrameNode = selectedControlNode?.type === "frame" && selectedControlNode.layerRole === "manual-frame" ? selectedControlNode : null; const selectedShapeNode = selectedControlNode && isShapeNode(selectedControlNode) ? selectedControlNode : null; const selectedBooleanShapeGroupNode = selectedControlNode && isBooleanShapeGroupNode(selectedControlNode) ? selectedControlNode : null; const selectedTextNode = selectedControlNode?.type === "text" ? selectedControlNode : null; const hasCurrentConversation = messages.some((message) => message.content.trim() || message.title.trim()); const selectedNodeBounds = useMemo(() => { if (!selectedImageNode) return null; return { left: viewport.x + selectedImageNode.x * viewport.k, top: viewport.y + selectedImageNode.y * viewport.k, width: selectedImageNode.width * viewport.k, height: selectedImageNode.height * viewport.k }; }, [selectedImageNode, viewport]); const selectedImageGeneratorBounds = useMemo(() => { if (!selectedImageGeneratorNode) return null; return { left: viewport.x + selectedImageGeneratorNode.x * viewport.k, top: viewport.y + selectedImageGeneratorNode.y * viewport.k, width: selectedImageGeneratorNode.width * viewport.k, height: selectedImageGeneratorNode.height * viewport.k }; }, [selectedImageGeneratorNode, viewport]); const selectedFrameBounds = useMemo(() => { if (!selectedFrameNode) return null; return { left: viewport.x + selectedFrameNode.x * viewport.k, top: viewport.y + selectedFrameNode.y * viewport.k, width: selectedFrameNode.width * viewport.k, height: selectedFrameNode.height * viewport.k }; }, [selectedFrameNode, viewport]); const selectedShapeBounds = useMemo(() => { if (!selectedShapeNode) return null; return { left: viewport.x + selectedShapeNode.x * viewport.k, top: viewport.y + selectedShapeNode.y * viewport.k, width: selectedShapeNode.width * viewport.k, height: selectedShapeNode.height * viewport.k }; }, [selectedShapeNode, viewport]); const selectedBooleanShapeGroupBounds = useMemo(() => { if (!selectedBooleanShapeGroupNode) return null; return { left: viewport.x + selectedBooleanShapeGroupNode.x * viewport.k, top: viewport.y + selectedBooleanShapeGroupNode.y * viewport.k, width: selectedBooleanShapeGroupNode.width * viewport.k, height: selectedBooleanShapeGroupNode.height * viewport.k }; }, [selectedBooleanShapeGroupNode, viewport]); const selectedPenStrokeBounds = useMemo(() => { if (!selectedPenStrokeNode) return null; return { left: viewport.x + selectedPenStrokeNode.x * viewport.k, top: viewport.y + selectedPenStrokeNode.y * viewport.k, width: selectedPenStrokeNode.width * viewport.k, height: selectedPenStrokeNode.height * viewport.k }; }, [selectedPenStrokeNode, viewport]); const selectedTextBounds = useMemo(() => { if (!selectedTextNode) return null; return { left: viewport.x + selectedTextNode.x * viewport.k, top: viewport.y + selectedTextNode.y * viewport.k, width: selectedTextNode.width * viewport.k, height: selectedTextNode.height * viewport.k }; }, [selectedTextNode, viewport]); const floatingToolbarLeftInset = generatedFilesPanelOpen ? generatedFilesPanelWidth : layersPanelOpen ? layersPanelWidth : 0; const insertCanvasImageReference = useCallback((node: CanvasNode, options: { focus?: boolean; pending?: boolean; replacePending?: boolean } = {}) => { const reference = canvasNodeToReferenceImage(node); if (!reference) return; agentComposerRef.current?.upsertReference(reference, options); }, []); useEffect(() => { if (!selectedImageNode) return; insertCanvasImageReference(selectedImageNode, { focus: false, pending: true, replacePending: true }); }, [insertCanvasImageReference, selectedImageNode?.content, selectedImageNode?.id]); const multiSelectionBounds = useMemo(() => { if (selectedNodes.length < 2) return null; if (selectedNodes.some((node) => lockedNodeIds.has(node.id))) return null; return screenBoundsForNodes(selectedNodes, viewport); }, [lockedNodeIds, selectedNodes, viewport]); const selectedGroupId = useMemo(() => { if (selectedNodes.length < 2) return null; const groupId = selectedNodes[0]?.parentId; if (!groupId) return null; const groupNodes = nodes.filter((node) => node.parentId === groupId); if (groupNodes.length < 2) return null; return groupNodes.every((node) => selectedIds.has(node.id)) && selectedNodes.every((node) => node.parentId === groupId) ? groupId : null; }, [nodes, selectedIds, selectedNodes]); const canBooleanOperateSelection = useMemo(() => { if (selectedBooleanShapeGroupNode) return true; return selectedNodes.length >= 2 && selectedNodes.every(isBooleanCapableShapeNode); }, [selectedBooleanShapeGroupNode, selectedNodes]); useEffect(() => { setImageActionMode(null); setQuickEditPrompt(""); setEraserMarks([]); setTextEditItems([]); setTextEditLoading(false); setTextEditError(""); setMockupDropActive(false); setMockupDragNodeId(null); setMockupPendingTargetId(null); setMockupReadyTargetId(null); setMockupPlacement(null); setMockupSurface(null); setMockupModel(null); mockupPrepareRequestRef.current += 1; if (mockupPrepareTimerRef.current) { clearTimeout(mockupPrepareTimerRef.current); mockupPrepareTimerRef.current = null; } setMoveSelection(null); setMoveRecognitionLabel(""); setMoveRecognitionLoading(false); setCropSelection(null); setCropPrompt(""); }, [selectedId, selectedIds.size]); useEffect(() => { if (imageActionMode !== "move-object" || !selectedImageNode || !moveSelection || moveSelection.srcBox.width < 0.02 || moveSelection.srcBox.height < 0.02) { moveRecognitionRequestRef.current += 1; setMoveRecognitionLabel(""); setMoveRecognitionLoading(false); return; } if (moveSelection.dstBox) { moveRecognitionRequestRef.current += 1; setMoveRecognitionLoading(false); return; } const requestId = moveRecognitionRequestRef.current + 1; moveRecognitionRequestRef.current = requestId; setMoveRecognitionLabel(""); setMoveRecognitionLoading(true); const timer = window.setTimeout(() => { designGateway .recognizeObject(selectedImageNode.content, moveSelection.srcBox, locale === "zh-CN" ? "zh" : "en") .then((result) => { if (moveRecognitionRequestRef.current !== requestId) return; setMoveRecognitionLabel(result.label); const bbox = result.bbox; if (bbox && bbox.width >= 0.02 && bbox.height >= 0.02) { setMoveSelection((current) => { if (!current || current.dstBox) return current; if (Math.abs(current.srcBox.x - bbox.x) < 0.004 && Math.abs(current.srcBox.y - bbox.y) < 0.004 && Math.abs(current.srcBox.width - bbox.width) < 0.004 && Math.abs(current.srcBox.height - bbox.height) < 0.004) { return current; } return { srcBox: { x: bbox.x, y: bbox.y, width: bbox.width, height: bbox.height } }; }); } }) .catch(() => { if (moveRecognitionRequestRef.current !== requestId) return; setMoveRecognitionLabel(""); }) .finally(() => { if (moveRecognitionRequestRef.current !== requestId) return; setMoveRecognitionLoading(false); }); }, 300); return () => window.clearTimeout(timer); }, [imageActionMode, locale, moveSelection, selectedImageNode?.content]); const handleWheel = (event: React.WheelEvent) => { if (shouldBypassCanvasWheel(event.target)) { return; } event.preventDefault(); const rect = containerRef.current?.getBoundingClientRect(); if (!rect) return; const factor = Math.pow(1.1, -event.deltaY / 120); const nextScale = clamp(viewport.k * factor, minCanvasZoom, maxCanvasZoom); const mouseX = event.clientX - rect.left; const mouseY = event.clientY - rect.top; const worldX = (mouseX - viewport.x) / viewport.k; const worldY = (mouseY - viewport.y) / viewport.k; setViewport({ x: mouseX - worldX * nextScale, y: mouseY - worldY * nextScale, k: nextScale }); setDirty(true); }; const zoomCanvasTo = (nextZoom: number) => { const current = viewportRef.current; const nextScale = clamp(nextZoom, minCanvasZoom, maxCanvasZoom); const centerX = canvasStageSize.width / 2; const centerY = canvasStageSize.height / 2; const worldX = (centerX - current.x) / current.k; const worldY = (centerY - current.y) / current.k; const nextViewport = { x: centerX - worldX * nextScale, y: centerY - worldY * nextScale, k: nextScale }; viewportRef.current = nextViewport; setViewport(nextViewport); setDirty(true); }; const fitCanvasToAllNodes = () => { const bounds = nodesRef.current.length > 0 ? nodeBounds(nodesRef.current) : { x: 0, y: 0, width: 1200, height: 800 }; const padding = Math.max(96, Math.min(Math.max(bounds.width, bounds.height) * 0.08, 240)); const nextScale = clamp( Math.min(canvasStageSize.width / Math.max(bounds.width + padding * 2, 1), canvasStageSize.height / Math.max(bounds.height + padding * 2, 1)), minCanvasZoom, maxCanvasZoom ); const nextViewport = { x: canvasStageSize.width / 2 - (bounds.x + bounds.width / 2) * nextScale, y: canvasStageSize.height / 2 - (bounds.y + bounds.height / 2) * nextScale, k: nextScale }; viewportRef.current = nextViewport; setViewport(nextViewport); setDirty(true); }; const updateCanvasBackgroundColor = (color: string) => { const nextColor = normalizeHexColor(color, defaultCanvasBackgroundColor); setCanvasBackgroundColor(nextColor); window.localStorage.setItem(canvasBackgroundStorageKey(projectId), nextColor); }; const startCanvasPan = (event: React.PointerEvent) => { event.preventDefault(); setCanvasPanning(true); const start = { pointerX: event.clientX, pointerY: event.clientY, x: viewport.x, y: viewport.y }; const handleMove = (moveEvent: PointerEvent) => { setViewport((current) => ({ ...current, x: start.x + moveEvent.clientX - start.pointerX, y: start.y + moveEvent.clientY - start.pointerY })); setDirty(true); }; const handleUp = () => { window.removeEventListener("pointermove", handleMove); window.removeEventListener("pointerup", handleUp); window.removeEventListener("pointercancel", handleUp); setCanvasPanning(false); void saveCanvasSnapshot({ quiet: true }); }; window.addEventListener("pointermove", handleMove); window.addEventListener("pointerup", handleUp); window.addEventListener("pointercancel", handleUp); }; const syncAnnotations = (nextAnnotations: CanvasAnnotation[]) => { annotationsRef.current = nextAnnotations; setAnnotations(nextAnnotations); setAnnotationPreview((current) => (current && nextAnnotations.some((annotation) => annotation.id === current.annotation.id) ? current : null)); }; const canvasPointFromClient = (clientX: number, clientY: number) => { const rect = containerRef.current?.getBoundingClientRect(); const currentViewport = viewportRef.current; if (!rect) { return { x: -currentViewport.x / currentViewport.k, y: -currentViewport.y / currentViewport.k }; } return { x: (clientX - rect.left - currentViewport.x) / currentViewport.k, y: (clientY - rect.top - currentViewport.y) / currentViewport.k }; }; const showCanvasToast = (message: string, tone: "success" | "error" = "success") => { showToast(message, tone); }; const findAnnotationTarget = (x: number, y: number, candidate?: CanvasNode) => { const visualNodes = nodesRef.current.filter((node) => (node.type === "image" || node.type === "frame") && node.status !== "generating"); if (candidate && visualNodes.some((node) => node.id === candidate.id) && pointInsideNode(x, y, candidate)) return candidate; return [...visualNodes].reverse().find((node) => pointInsideNode(x, y, node)) ?? null; }; const addAnnotationAtClientPoint = (clientX: number, clientY: number, candidate?: CanvasNode) => { const point = canvasPointFromClient(clientX, clientY); const target = findAnnotationTarget(point.x, point.y, candidate); if (!target) return false; const nextIndex = annotationsRef.current.reduce((max, annotation) => Math.max(max, annotation.index), 0) + 1; const annotation: CanvasAnnotation = { id: cryptoFallbackId(), index: nextIndex, nodeId: target.id, x: point.x, y: point.y, label: t("annotationLabel", { index: nextIndex }) }; syncAnnotations([...annotationsRef.current, annotation]); agentComposerRef.current?.insertAnnotation(annotation); return true; }; const addFrameFromRect = useCallback( (rect: FrameWorldRect) => { const node: CanvasNode = { id: cryptoFallbackId(), type: "frame", title: t("newFrameNode"), x: rect.x, y: rect.y, width: rect.width, height: rect.height, content: "", tone: "natural", status: "draft", layerRole: "manual-frame", opacity: 1, fillColor: defaultFrameFillColor, strokeColor: defaultFrameStrokeColor, strokeWidth: defaultFrameStrokeWidth, strokeStyle: defaultFrameStrokeStyle }; setNodes((current) => [...current, node]); selectOnlyNode(node.id); setActiveTool("select"); setMode("select"); setDirty(true); }, [selectOnlyNode, t] ); const addShapeFromDraft = useCallback( (draft: ShapeWorldDraft) => { const node = createShapeNode({ id: cryptoFallbackId(), title: shapeTitle(selectedShapeKind, t), kind: selectedShapeKind, draft }); setNodes((current) => { const next = [...current, node]; nodesRef.current = next; return next; }); selectOnlyNode(node.id); setActiveTool("select"); setMode("select"); setDirty(true); }, [nodesRef, selectOnlyNode, selectedShapeKind, t] ); const startPenDrawing = useCallback( (event: React.PointerEvent) => { const rect = containerRef.current?.getBoundingClientRect(); if (!rect) return; event.preventDefault(); selectOnlyNode(null); beginPenStroke({ clientX: event.clientX, clientY: event.clientY, stageRect: rect, viewport: viewportRef.current, settings: penSettings, onDraftChange: setPenDraft, onCommit: (draft) => { const node = createPenStrokeNode(draft, t("newPenStrokeNode")); setNodes((current) => { const next = [...current, node]; nodesRef.current = next; return next; }); setDirty(true); } }); }, [penSettings, selectOnlyNode, t] ); const startShapeDrawing = useCallback( (event: React.PointerEvent) => { const rect = containerRef.current?.getBoundingClientRect(); if (!rect) return; event.preventDefault(); selectOnlyNode(null); beginShapeDrawing({ clientX: event.clientX, clientY: event.clientY, stageRect: rect, viewport: viewportRef.current, onDraftChange: setShapeDraft, onCommit: addShapeFromDraft }); }, [addShapeFromDraft, selectOnlyNode, viewportRef] ); const handleBackgroundPointerDown = (event: React.PointerEvent) => { if (event.button !== 0) return; const target = event.target as HTMLElement; if ( target.closest(".canvas-node") || target.closest(".workspace-toolbar") || target.closest(".shape-tool-popover") || target.closest(".canvas-bottom-left") || target.closest(".canvas-layer-panel") || target.closest(".canvas-generated-files-panel") || target.closest(".image-node-toolbar") || target.closest(".frame-style-toolbar") || target.closest(".shape-style-toolbar") || target.closest(".shape-radius-popover") || target.closest(".frame-color-popover") || target.closest(".pen-tool-controls") || target.closest(".pen-color-popover") || target.closest(".image-action-panel") || target.closest(".image-mask-overlay") || target.closest(".multi-selection-toolbar") || target.closest(".text-style-toolbar") || target.closest(".text-font-popover") || target.closest(".text-weight-popover") || target.closest(".text-size-popover") || target.closest(".text-align-popover") || target.closest(".text-advanced-popover") ) { return; } if (editingTextNodeId) { event.preventDefault(); finishTextEditing(editingTextNodeId); return; } if (activeTool === "pin") { event.preventDefault(); return; } if (activeTool === "text") { event.preventDefault(); addTextNodeAt(canvasPointFromClient(event.clientX, event.clientY)); return; } if (activeTool === "pen") { startPenDrawing(event); return; } if (activeTool === "frame") { const rect = containerRef.current?.getBoundingClientRect(); if (!rect) return; event.preventDefault(); selectOnlyNode(null); beginFrameDrawing({ clientX: event.clientX, clientY: event.clientY, stageRect: rect, viewport: viewportRef.current, onDraftChange: setFrameDraft, onCommit: addFrameFromRect }); return; } if (activeTool === "shape") { startShapeDrawing(event); return; } const start = { pointerX: event.clientX, pointerY: event.clientY, stageLeft: containerRef.current?.getBoundingClientRect().left ?? 0, stageTop: containerRef.current?.getBoundingClientRect().top ?? 0, x: viewport.x, y: viewport.y }; if (mode === "select") { const additiveIds = event.shiftKey ? selectedNodes.map((node) => node.id) : []; let dragged = false; const handleMove = (moveEvent: PointerEvent) => { const box = normalizeScreenRect(start.pointerX - start.stageLeft, start.pointerY - start.stageTop, moveEvent.clientX - start.stageLeft, moveEvent.clientY - start.stageTop); if (box.width < 4 && box.height < 4) return; dragged = true; setSelectionBox(box); const worldRect = screenRectToWorldRect(box, viewportRef.current); const hitIds = nodesRef.current.filter((node) => !hiddenNodeIds.has(node.id) && nodeIntersectsRect(node, worldRect)).map((node) => node.id); selectNodes([...additiveIds, ...hitIds]); }; const handleUp = () => { window.removeEventListener("pointermove", handleMove); window.removeEventListener("pointerup", handleUp); setSelectionBox(null); if (!dragged) selectOnlyNode(null); }; window.addEventListener("pointermove", handleMove); window.addEventListener("pointerup", handleUp); return; } selectOnlyNode(null); startCanvasPan(event); }; const startMockupSourceDrag = (event: React.PointerEvent, sourceNode: CanvasNode, targetNode: CanvasNode) => { const targetPlacement = mockupPlacement; const targetSurface = mockupSurface?.targetId === targetNode.id ? mockupSurface : { targetId: targetNode.id, polygon: [] }; const targetModel = mockupModel; if (!targetPlacement || !targetModel) return; event.preventDefault(); const wasDirty = dirty; setMockupDragNodeId(sourceNode.id); const start = { pointerX: event.clientX, pointerY: event.clientY, x: sourceNode.x, y: sourceNode.y, scale: viewport.k }; let dragged = false; const updateDropState = (clientX: number, clientY: number) => { const point = canvasPointFromClient(clientX, clientY); setMockupDropActive(pointInsideMockupPlacement(point.x, point.y, targetNode, targetPlacement)); return point; }; updateDropState(event.clientX, event.clientY); const handleMove = (moveEvent: PointerEvent) => { const movement = Math.hypot(moveEvent.clientX - start.pointerX, moveEvent.clientY - start.pointerY); if (movement > 3) dragged = true; const dx = (moveEvent.clientX - start.pointerX) / start.scale; const dy = (moveEvent.clientY - start.pointerY) / start.scale; const nextSourceNode = { ...sourceNode, x: start.x + dx, y: start.y + dy }; const nextNodes = nodesRef.current.map((node) => (node.id === sourceNode.id ? nextSourceNode : node)); nodesRef.current = nextNodes; setNodes(nextNodes); updateDropState(moveEvent.clientX, moveEvent.clientY); setDirty(true); }; const handleUp = (upEvent: PointerEvent) => { window.removeEventListener("pointermove", handleMove); window.removeEventListener("pointerup", handleUp); setMockupDropActive(false); setMockupDragNodeId(null); const dropPoint = canvasPointFromClient(upEvent.clientX, upEvent.clientY); const droppedOnTarget = pointInsideMockupPlacement(dropPoint.x, dropPoint.y, targetNode, targetPlacement); const droppedNode = nodesRef.current.find((node) => node.id === sourceNode.id); const restoredSourceNode = droppedNode ? { ...droppedNode, x: start.x, y: start.y } : sourceNode; const restoredNodes = nodesRef.current.map((node) => (node.id === sourceNode.id ? restoredSourceNode : node)); nodesRef.current = restoredNodes; setNodes(restoredNodes); if (droppedOnTarget && droppedNode && dragged) { void applyMockupPrint(restoredSourceNode, targetNode, targetPlacement, targetSurface, targetModel, dropPoint); return; } setDirty(wasDirty); }; window.addEventListener("pointermove", handleMove); window.addEventListener("pointerup", handleUp); }; const handleNodePointerDown = (event: React.PointerEvent, node: CanvasNode) => { event.stopPropagation(); if (event.button !== 0) return; if ( imageActionMode === "mockup" && selectedImageNode && mockupReadyTargetId === selectedImageNode.id && mockupPlacement && node.id !== selectedImageNode.id && isRealCanvasImageNode(node) && !lockedNodeIds.has(node.id) && !hiddenNodeIds.has(node.id) ) { startMockupSourceDrag(event, node, selectedImageNode); return; } if (activeTool === "text" && (node.type === "image" || node.type === "frame")) { event.preventDefault(); addTextNodeAt(canvasPointFromClient(event.clientX, event.clientY)); return; } if (activeTool === "pen") { startPenDrawing(event); return; } if (activeTool === "shape") { startShapeDrawing(event); return; } if (activeTool === "pin") { event.preventDefault(); addAnnotationAtClientPoint(event.clientX, event.clientY, node); return; } if (mode === "pan") { startCanvasPan(event); return; } if (editingTextNodeId && editingTextNodeId !== node.id) { finishTextEditing(editingTextNodeId); } const additive = event.shiftKey || event.metaKey || event.ctrlKey; if (additive) { toggleNodeSelection(node.id); return; } const shouldEditTextOnClick = node.type === "text" && node.layerRole === "editable-text" && selectedIds.has(node.id) && selectedIds.size === 1 && activeTool === "select" && mode === "select" && !lockedNodeIds.has(node.id) && !node.parentId && !editingTextNodeId; const nodeGroupIds = node.parentId ? nodesRef.current.filter((item) => item.parentId === node.parentId).map((item) => item.id) : []; const useExistingSelection = selectedIds.has(node.id) && selectedIds.size > 1; const groupDrag = useExistingSelection || nodeGroupIds.length > 1; const dragIds = groupDrag ? new Set(useExistingSelection ? selectedIds : nodeGroupIds) : new Set([node.id]); if (groupDrag) { selectNodes(Array.from(dragIds)); } else { selectOnlyNode(node.id); } if (lockedNodeIds.has(node.id)) return; const start = { pointerX: event.clientX, pointerY: event.clientY, positions: nodesRef.current .filter((item) => dragIds.has(item.id) && !lockedNodeIds.has(item.id)) .map((item) => ({ id: item.id, x: item.x, y: item.y })), scale: viewport.k }; let dragged = false; const handleMove = (moveEvent: PointerEvent) => { const movement = Math.hypot(moveEvent.clientX - start.pointerX, moveEvent.clientY - start.pointerY); if (movement > 3) dragged = true; if (shouldEditTextOnClick && !dragged) return; const dx = (moveEvent.clientX - start.pointerX) / start.scale; const dy = (moveEvent.clientY - start.pointerY) / start.scale; const nextNodes = moveNodesByDelta(nodesRef.current, start.positions, dx, dy); const movedNodes = nextNodes.filter((item) => dragIds.has(item.id) && !lockedNodeIds.has(item.id)); nodesRef.current = nextNodes; setNodes(nextNodes); setAlignmentGuides(buildAlignmentGuides(movedNodes, nextNodes, viewportRef.current)); setDirty(true); }; const handleUp = () => { window.removeEventListener("pointermove", handleMove); window.removeEventListener("pointerup", handleUp); setAlignmentGuides([]); if (shouldEditTextOnClick && !dragged) { setEditingTextNodeId(node.id); return; } if (dragged && Array.from(dragIds).some((nodeId) => nodesRef.current.find((item) => item.id === nodeId)?.layerRole === "mockup-print")) { void refreshMockupPrintTextures(Array.from(dragIds)); return; } void saveCanvasSnapshot({ quiet: true }); }; window.addEventListener("pointermove", handleMove); window.addEventListener("pointerup", handleUp); }; const handleTextNodeResizePointerDown = (event: React.PointerEvent, node: CanvasNode, handle: ResizeHandle) => { const horizontalDirection = handle.includes("right") ? 1 : handle.includes("left") ? -1 : 0; const verticalDirection = handle.includes("bottom") ? 1 : handle.includes("top") ? -1 : 0; const start = { pointerX: event.clientX, pointerY: event.clientY, x: node.x, y: node.y, width: node.width, height: node.height, fontSize: node.fontSize || 48, scale: viewport.k }; const handleMove = (moveEvent: PointerEvent) => { const dx = (moveEvent.clientX - start.pointerX) / start.scale; const dy = (moveEvent.clientY - start.pointerY) / start.scale; const horizontalActive = horizontalDirection !== 0 && (verticalDirection === 0 || Math.abs(dx) >= Math.abs(dy) * 0.35); const verticalActive = verticalDirection !== 0 && (horizontalDirection === 0 || Math.abs(dy) >= Math.abs(dx) * 0.35); const pointerWidth = horizontalActive ? start.width + dx * horizontalDirection : start.width; const pointerHeight = verticalActive ? start.height + dy * verticalDirection : start.height; const nextFontSize = verticalActive ? clamp(Math.round(start.fontSize * Math.max(0.15, pointerHeight / Math.max(start.height, 1))), 8, 512) : start.fontSize; const fontNode = { ...node, fontSize: nextFontSize }; const naturalSize = measureTextNodeContent(fontNode); const nextWidth = horizontalActive ? Math.max(24, pointerWidth) : naturalSize.width; const wrappedSize = measureTextNodeContent(fontNode, nextWidth); const nextHeight = Math.max(12, wrappedSize.height); const nextX = horizontalActive && horizontalDirection < 0 ? start.x + start.width - nextWidth : horizontalActive && horizontalDirection > 0 ? start.x : start.x + (start.width - nextWidth) / 2; const nextY = verticalActive && verticalDirection < 0 ? start.y + start.height - nextHeight : start.y; const resizedNode = { ...node, x: nextX, y: nextY, width: nextWidth, height: nextHeight, fontSize: nextFontSize }; nodesRef.current = nodesRef.current.map((item) => (item.id === node.id ? resizedNode : item)); setNodes((current) => current.map((item) => (item.id === node.id ? resizedNode : item))); setAlignmentGuides(buildAlignmentGuides([resizedNode], nodesRef.current, viewportRef.current)); setDirty(true); }; const handleUp = () => { window.removeEventListener("pointermove", handleMove); window.removeEventListener("pointerup", handleUp); setAlignmentGuides([]); if (node.layerRole === "mockup-print") { void refreshMockupPrintTextures([node.id]); return; } void saveCanvasSnapshot({ quiet: true }); }; window.addEventListener("pointermove", handleMove); window.addEventListener("pointerup", handleUp); }; const handleNodeResizePointerDown = (event: React.PointerEvent, node: CanvasNode, handle: ResizeHandle) => { event.stopPropagation(); if (mode === "pan") return; selectOnlyNode(node.id); if (lockedNodeIds.has(node.id)) return; if (node.type === "text" && node.layerRole === "editable-text") { handleTextNodeResizePointerDown(event, node, handle); return; } const start = { pointerX: event.clientX, pointerY: event.clientY, scale: viewport.k }; const handleMove = (moveEvent: PointerEvent) => { const dx = (moveEvent.clientX - start.pointerX) / start.scale; const dy = (moveEvent.clientY - start.pointerY) / start.scale; const resizedNode = resizeVisualNodeByHandle(node, handle, { dx, dy }); nodesRef.current = nodesRef.current.map((item) => (item.id === node.id ? resizedNode : item)); setNodes((current) => current.map((item) => (item.id === node.id ? resizedNode : item))); setAlignmentGuides(buildAlignmentGuides([resizedNode], nodesRef.current, viewportRef.current)); setDirty(true); }; const handleUp = () => { window.removeEventListener("pointermove", handleMove); window.removeEventListener("pointerup", handleUp); setAlignmentGuides([]); void saveCanvasSnapshot({ quiet: true }); }; window.addEventListener("pointermove", handleMove); window.addEventListener("pointerup", handleUp); }; const handleShapeEndpointPointerDown = (event: React.PointerEvent, node: CanvasNode, endpoint: ShapeEndpointKey) => { event.preventDefault(); event.stopPropagation(); if (mode === "pan" || !isLineShapeNode(node)) return; selectOnlyNode(node.id); if (lockedNodeIds.has(node.id)) return; const startNode = node; const handleMove = (moveEvent: PointerEvent) => { const resizedNode = resizeLineShapeEndpoint(startNode, endpoint, canvasPointFromClient(moveEvent.clientX, moveEvent.clientY)); nodesRef.current = nodesRef.current.map((item) => (item.id === node.id ? resizedNode : item)); setNodes((current) => current.map((item) => (item.id === node.id ? resizedNode : item))); setAlignmentGuides(buildAlignmentGuides([resizedNode], nodesRef.current, viewportRef.current)); setDirty(true); }; const handleUp = () => { window.removeEventListener("pointermove", handleMove); window.removeEventListener("pointerup", handleUp); setAlignmentGuides([]); void saveCanvasSnapshot({ quiet: true }); }; window.addEventListener("pointermove", handleMove); window.addEventListener("pointerup", handleUp); }; const handleMultiSelectionResizePointerDown = (event: React.PointerEvent, handle: MultiSelectionResizeHandle) => { event.preventDefault(); event.stopPropagation(); if (mode === "pan" || selectedIds.size < 2) return; const selected = nodesRef.current.filter((node) => selectedIds.has(node.id)); const scalableNodes = selected.filter((node) => !lockedNodeIds.has(node.id) && canResizeCanvasNode(node)); if (selected.length < 2 || scalableNodes.length === 0) return; const bounds = nodeBounds(selected); const horizontalDirection = handle.includes("right") ? 1 : -1; const verticalDirection = handle.includes("bottom") ? 1 : -1; const anchorX = horizontalDirection > 0 ? bounds.x : bounds.x + bounds.width; const anchorY = verticalDirection > 0 ? bounds.y : bounds.y + bounds.height; const start = { pointerX: event.clientX, pointerY: event.clientY, bounds, anchorX, anchorY, nodes: scalableNodes.map((node) => ({ ...node })), scale: viewport.k }; const minScale = Math.max(0.04, ...start.nodes.map(minGroupNodeScale)); const handleMove = (moveEvent: PointerEvent) => { const dx = (moveEvent.clientX - start.pointerX) / start.scale; const dy = (moveEvent.clientY - start.pointerY) / start.scale; const widthScale = (start.bounds.width + dx * horizontalDirection) / Math.max(start.bounds.width, 1); const heightScale = (start.bounds.height + dy * verticalDirection) / Math.max(start.bounds.height, 1); const pointerScale = Math.abs(widthScale - 1) >= Math.abs(heightScale - 1) ? widthScale : heightScale; const nextScale = Math.max(minScale, pointerScale); const resizedById = new Map(start.nodes.map((node) => [node.id, scaleGroupNode(node, start.anchorX, start.anchorY, nextScale)])); const nextNodes = nodesRef.current.map((node) => resizedById.get(node.id) ?? node); const resizedNodes = nextNodes.filter((node) => resizedById.has(node.id)); nodesRef.current = nextNodes; setNodes(nextNodes); setAlignmentGuides(buildAlignmentGuides(resizedNodes, nextNodes, viewportRef.current)); setDirty(true); }; const handleUp = () => { window.removeEventListener("pointermove", handleMove); window.removeEventListener("pointerup", handleUp); setAlignmentGuides([]); void saveCanvasSnapshot({ quiet: true }); }; window.addEventListener("pointermove", handleMove); window.addEventListener("pointerup", handleUp); }; const updateNodeContent = (nodeId: string, content: string) => { const nextNodes = updateNodeContentOp(nodesRef.current, nodeId, content); nodesRef.current = nextNodes; setNodes(nextNodes); setDirty(true); }; const renameNodeTitle = (nodeId: string, title: string) => { const nextTitle = title.trim(); if (!nextTitle) return; const applyPatch = (item: CanvasNode) => (item.id === nodeId ? { ...item, title: nextTitle } : item); nodesRef.current = nodesRef.current.map(applyPatch); setNodes((current) => current.map(applyPatch)); setDirty(true); }; const updateFrameStyle = (nodeId: string, patch: FrameStylePatch) => { const applyPatch = (item: CanvasNode) => (item.id === nodeId ? patchFrameStyleNode(item, patch) : item); nodesRef.current = nodesRef.current.map(applyPatch); setNodes((current) => current.map(applyPatch)); setDirty(true); }; const updateShapeStyle = (nodeId: string, patch: ShapeStylePatch) => { const applyPatch = (item: CanvasNode) => (item.id === nodeId && isShapeNode(item) ? patchShapeStyleNode(item, patch) : item); nodesRef.current = nodesRef.current.map(applyPatch); setNodes((current) => current.map(applyPatch)); setDirty(true); }; const updatePenStrokeStyle = (nodeId: string, patch: PenStrokePatch) => { const applyPatch = (item: CanvasNode) => (item.id === nodeId && isCanvasPenStrokeNode(item) ? updatePenStrokeNode(item, patch, viewportRef.current.k) : item); nodesRef.current = nodesRef.current.map(applyPatch); setNodes((current) => current.map(applyPatch)); setDirty(true); }; const updateTextStyle = (nodeId: string, patch: TextStylePatch) => { const shouldFit = textStylePatchChangesMetrics(patch); const applyPatch = (item: CanvasNode) => { if (item.id !== nodeId || item.type !== "text") return item; const nextNode = { ...item, ...patch }; return shouldFit ? fitTextNodeToContent(nextNode, { keepX: true, keepY: true }) : nextNode; }; nodesRef.current = nodesRef.current.map(applyPatch); setNodes((current) => current.map(applyPatch)); setDirty(true); }; const removeNode = (nodeId: string) => { nodesRef.current = removeNodeById(nodesRef.current, nodeId); setNodes((current) => removeNodeById(current, nodeId)); setEditingTextNodeId((current) => (current === nodeId ? null : current)); setHiddenNodeIds((current) => { const next = new Set(current); next.delete(nodeId); return next; }); setLockedNodeIds((current) => { const next = new Set(current); next.delete(nodeId); return next; }); setSelectedIds((current) => { const next = new Set(current); next.delete(nodeId); return next; }); if (selectedId === nodeId) selectOnlyNode(null); setDirty(true); }; const finishTextEditing = (nodeId: string, contentOverride?: string) => { const node = nodesRef.current.find((item) => item.id === nodeId); const content = contentOverride ?? node?.content ?? ""; setEditingTextNodeId(null); setActiveTool("select"); setMode("select"); if (!node) return; if (node.type === "text" && node.layerRole === "editable-text" && isBlankTextContent(content)) { removeNode(nodeId); return; } const nextNode = fitTextNodeToContent({ ...node, content }, { keepX: true, keepY: true }); nodesRef.current = nodesRef.current.map((item) => (item.id === nodeId ? nextNode : item)); setNodes((current) => current.map((item) => (item.id === nodeId ? nextNode : item))); selectOnlyNode(nodeId); setDirty(true); }; const duplicateNode = (node: CanvasNode) => { const copy = cloneCanvasNode(node, cryptoFallbackId()); setNodes((current) => [...current, copy]); selectOnlyNode(copy.id); setDirty(true); }; const copyNode = (node: CanvasNode) => { setClipboardNode(node); void writeCanvasNodeToSystemClipboard(node) .then(() => showCanvasToast(t("clipboardCopied"))) .catch(() => showCanvasToast(t("clipboardCopyFailed"), "error")); }; const pasteNode = (sourceNode = clipboardNode) => { if (!sourceNode) return; const copy = cloneCanvasNode(sourceNode, cryptoFallbackId()); setNodes((current) => [...current, copy]); selectOnlyNode(copy.id); setDirty(true); }; const reorderNode = (nodeId: string, action: "forward" | "backward" | "front" | "back") => { setNodes((current) => reorderNodeOp(current, nodeId, action)); setDirty(true); }; const toggleLayerHidden = (nodeId: string) => { setHiddenNodeIds((current) => toggleSetValue(current, nodeId)); setDirty(true); }; const toggleLayerLocked = (nodeId: string) => { setLockedNodeIds((current) => toggleSetValue(current, nodeId)); setDirty(true); }; const toggleLayersHidden = (nodeIds: string[]) => { if (nodeIds.length === 0) return; setHiddenNodeIds((current) => toggleSetValues(current, nodeIds)); setDirty(true); }; const toggleLayersLocked = (nodeIds: string[]) => { if (nodeIds.length === 0) return; setLockedNodeIds((current) => toggleSetValues(current, nodeIds)); setDirty(true); }; const updateNodeTransform = (nodeId: string, patch: Partial | ((transform: NodeTransform) => Partial)) => { setNodes((current) => updateNodeTransformOp(current, nodeId, patch)); setDirty(true); }; const toggleNodeTransform = (nodeId: string, key: "flipX" | "flipY") => { updateNodeTransform(nodeId, (transform) => ({ [key]: !transform[key] })); }; const rotateNodeClockwise = (nodeId: string) => { updateNodeTransform(nodeId, (transform) => ({ rotation: transform.rotation + 90 })); }; const autoLayoutAroundNode = (node: CanvasNode) => { setNodes((current) => autoLayoutAroundNodeOp(current, node)); selectOnlyNode(node.id); setDirty(true); }; const autoLayoutSelectedNodes = () => { if (selectedIds.size < 2) return; setNodes((current) => autoLayoutNodes(current, selectedIds)); setDirty(true); }; const alignSelectedNodes = (alignment: NodeAlignment) => { if (selectedIds.size < 2) return; setNodes((current) => alignNodes(current, selectedIds, alignment)); setDirty(true); }; const distributeSelectedNodes = (direction: NodeDistribution) => { if (selectedIds.size < 3) return; setNodes((current) => distributeNodes(current, selectedIds, direction)); setDirty(true); }; const createGroupFromSelectedNodes = () => { const selected = nodesRef.current.filter((node) => selectedIds.has(node.id)); if (selected.length < 2) return; const groupId = `group-${cryptoFallbackId()}`; setNodes((current) => groupNodes(current, selectedIds, groupId)); setDirty(true); }; const applyBooleanShapeOperation = (operation: BooleanShapeOperation) => { const selectedBooleanGroup = nodesRef.current.find((node) => selectedIds.has(node.id) && isBooleanShapeGroupNode(node)); if (selectedBooleanGroup) { const nextNodes = nodesRef.current.map((node) => (node.id === selectedBooleanGroup.id ? updateBooleanShapeOperation(node, operation) : node)); nodesRef.current = nextNodes; setNodes(nextNodes); selectOnlyNode(selectedBooleanGroup.id); setDirty(true); return; } const selected = nodesRef.current.filter((node) => selectedIds.has(node.id)); if (selected.length < 2 || !selected.every(isBooleanCapableShapeNode)) return; const groupId = `boolean-${cryptoFallbackId()}`; const groupNode = createBooleanShapeGroupNode({ id: groupId, title: operation.toUpperCase(), operation, nodes: selected }); const selectedSet = new Set(selected.map((node) => node.id)); const highestSelectedIndex = Math.max(...nodesRef.current.map((node, index) => (selectedSet.has(node.id) ? index : -1))); const nextNodes = nodesRef.current .map((node) => (selectedSet.has(node.id) ? { ...node, parentId: groupId } : node)) .flatMap((node, index) => (index === highestSelectedIndex ? [node, groupNode] : [node])); nodesRef.current = nextNodes; setNodes(nextNodes); selectOnlyNode(groupId); setDirty(true); }; const ungroupBooleanShapeGroup = (groupId: string | undefined) => { if (!groupId) return; const groupNode = nodesRef.current.find((node) => node.id === groupId && isBooleanShapeGroupNode(node)); if (!groupNode) return; const childIds = nodesRef.current.filter((node) => node.parentId === groupId).map((node) => node.id); const nextNodes = nodesRef.current.flatMap((node) => { if (node.id === groupId) return []; if (node.parentId === groupId) return [{ ...node, parentId: undefined }]; return [node]; }); nodesRef.current = nextNodes; setNodes(nextNodes); selectNodes(childIds); setDirty(true); }; const ungroupNodesByGroupId = (groupId: string | undefined) => { if (!groupId) return; if (nodesRef.current.some((node) => node.id === groupId && isBooleanShapeGroupNode(node))) { ungroupBooleanShapeGroup(groupId); return; } const groupNodeIds = nodesRef.current.filter((node) => node.parentId === groupId).map((node) => node.id); setNodes((current) => ungroupNodes(current, groupId)); selectNodes(groupNodeIds); setDirty(true); }; const ungroupSelectedNodes = () => { const selected = nodesRef.current.filter((node) => selectedIds.has(node.id)); const selectedBooleanGroup = selected.find(isBooleanShapeGroupNode); if (selectedBooleanGroup) { ungroupBooleanShapeGroup(selectedBooleanGroup.id); return; } ungroupNodesByGroupId(selected.find((node) => node.parentId)?.parentId); }; const mergeSelectedNodes = async () => { const currentProject = projectRef.current; if (!currentProject) return; const selected = nodesRef.current.filter((node) => selectedIds.has(node.id)); if (selected.length < 2) return; const selectedSet = new Set(selected.map((node) => node.id)); const existingIds = new Set(nodesRef.current.map((node) => node.id)); setSaving(true); setError(""); try { if (dirty) { const savedProject = await saveCanvasSnapshot({ quiet: true }); if (!savedProject) return; } const result = await designGateway.mergeLayers(currentProject.id, { nodeIds: selected.map((node) => node.id), title: t("mergedLayerTitle") }); applyProject(result.project); projectRef.current = result.project; nodesRef.current = result.project.nodes; setHiddenNodeIds((current) => new Set(Array.from(current).filter((nodeId) => !selectedSet.has(nodeId)))); setLockedNodeIds((current) => new Set(Array.from(current).filter((nodeId) => !selectedSet.has(nodeId)))); selectOnlyNode(result.project.nodes.find((node) => !existingIds.has(node.id))?.id ?? null); setDirty(false); setCanvasActionGenerating(true); startProjectEvents(result.project.id, result.project.lastThreadId, "canvas-action"); } catch (err) { setError(toUserMessageText(err, "generateError")); setCanvasActionGenerating(false); } finally { setSaving(false); } }; const deleteSelectedNodes = () => { const ids = new Set(selectedNodes.filter((node) => !lockedNodeIds.has(node.id) && !hiddenNodeIds.has(node.id)).map((node) => node.id)); if (ids.size === 0) return; setNodes((current) => removeNodesById(current, ids)); setHiddenNodeIds((current) => new Set(Array.from(current).filter((nodeId) => !ids.has(nodeId)))); setLockedNodeIds((current) => new Set(Array.from(current).filter((nodeId) => !ids.has(nodeId)))); selectOnlyNode(null); setDirty(true); }; const sendNodeToAgentComposer = (node: CanvasNode) => { if (isRealCanvasImageNode(node)) { insertCanvasImageReference(node, { focus: true }); return; } if (node.type === "text") { const content = node.content.trim(); if (!content) return; setPrompt((current) => (current.trim() ? `${current.trim()}\n${content}` : content)); setComposerMode("agent"); } }; const createNodeContextMenuHandlers = (node: CanvasNode): NodeContextMenuHandlers => ({ copy: () => copyNode(node), cut: () => { copyNode(node); removeNode(node.id); }, paste: pasteNode, duplicate: () => duplicateNode(node), bringForward: () => reorderNode(node.id, "forward"), sendBackward: () => reorderNode(node.id, "backward"), bringToFront: () => reorderNode(node.id, "front"), sendToBack: () => reorderNode(node.id, "back"), sendToChat: () => sendNodeToAgentComposer(node), createGroup: createGroupFromSelectedNodes, ungroup: () => (isBooleanShapeGroupNode(node) ? ungroupBooleanShapeGroup(node.id) : ungroupNodesByGroupId(node.parentId)), autoLayout: () => autoLayoutAroundNode(node), mergeLayers: () => void mergeSelectedNodes(), toggleHidden: () => toggleLayerHidden(node.id), toggleLocked: () => toggleLayerLocked(node.id), flipHorizontal: () => toggleNodeTransform(node.id, "flipX"), flipVertical: () => toggleNodeTransform(node.id, "flipY"), exportFormats: node.type === "text" ? ["svg"] : ["png", "jpg", "svg"], exportAs: (format) => exportCanvasNode(node, format), deleteNode: () => removeNode(node.id) }); useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { if (isEditableShortcutTarget(event.target)) return; const key = event.key.toLowerCase(); const primary = isPrimaryShortcut(event); const selected = selectedNode; const selectedHandlers = selected && !lockedNodeIds.has(selected.id) && canUseNodeContextMenu(selected) ? createNodeContextMenuHandlers(selected) : null; const run = (action: (() => void) | undefined, prevent = true) => { if (!action) return false; if (prevent) event.preventDefault(); action(); return true; }; if (!primary && !event.shiftKey && !event.altKey && key === "escape" && (imageActionMode || mockupPendingTargetId)) { return void run(() => { setImageActionMode(null); setMockupDropActive(false); setMockupDragNodeId(null); setMockupPendingTargetId(null); setMockupReadyTargetId(null); setMockupPlacement(null); setMockupSurface(null); setMockupModel(null); mockupPrepareRequestRef.current += 1; if (mockupPrepareTimerRef.current) { clearTimeout(mockupPrepareTimerRef.current); mockupPrepareTimerRef.current = null; } }); } if (!primary && !event.shiftKey && !event.altKey && key === "v") return void run(() => chooseCanvasTool("select")); if (!primary && !event.shiftKey && !event.altKey && key === "h") return void run(() => chooseCanvasTool("pan")); if (!primary && !event.shiftKey && !event.altKey && key === "f") return void run(() => chooseCanvasTool("frame")); if (!primary && !event.shiftKey && !event.altKey && key === "t") return void run(() => chooseCanvasTool("text")); if (!primary && !event.shiftKey && !event.altKey && key === "r") return void run(() => chooseShapeTool("rectangle")); if (!primary && !event.shiftKey && !event.altKey && key === "l") return void run(() => chooseShapeTool("line")); if (!primary && event.shiftKey && !event.altKey && key === "l") return void run(() => chooseShapeTool("arrow")); if (!primary && !event.shiftKey && !event.altKey && key === "o") return void run(() => chooseShapeTool("ellipse")); if (primary && (key === "+" || key === "=")) return void run(() => zoomCanvasTo(viewportRef.current.k + 0.1)); if (primary && !event.shiftKey && key === "-") return void run(() => zoomCanvasTo(viewportRef.current.k - 0.1)); if (!primary && event.shiftKey && (key === "!" || key === "1")) return void run(fitCanvasToAllNodes); if (primary && !event.shiftKey && key === "c") return void run(selectedHandlers?.copy); if (primary && !event.shiftKey && key === "x") return void run(selectedHandlers?.cut); if (primary && !event.shiftKey && key === "d") return void run(selectedHandlers?.duplicate); if (primary && !event.shiftKey && event.key === "]") return void run(selectedHandlers?.bringForward); if (primary && !event.shiftKey && event.key === "[") return void run(selectedHandlers?.sendBackward); if (!primary && !event.shiftKey && event.key === "]") return void run(selectedHandlers?.bringToFront); if (!primary && !event.shiftKey && event.key === "[") return void run(selectedHandlers?.sendToBack); if (primary && event.shiftKey && key === "g") return void run(ungroupSelectedNodes); if (primary && !event.shiftKey && key === "g") return void run(createGroupFromSelectedNodes); if (!primary && event.shiftKey && key === "a") return void run(selectedHandlers?.autoLayout); if (primary && !event.shiftKey && key === "e") return void run(selectedHandlers?.mergeLayers); if (primary && event.shiftKey && key === "h") return void run(selectedHandlers?.toggleHidden); if (primary && event.shiftKey && key === "l") return void run(selectedHandlers?.toggleLocked); if ((event.key === "Backspace" || event.key === "Delete") && selectedNodes.length > 1) return void run(deleteSelectedNodes); if ((event.key === "Backspace" || event.key === "Delete") && selected) return void run(selectedHandlers?.deleteNode); }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }); useEffect(() => { const handlePaste = (event: ClipboardEvent) => { if (isEditableShortcutTarget(event.target)) return; const imageFiles = event.clipboardData ? dataTransferImageFiles(event.clipboardData, "clipboard-image") : []; const pasteImageFiles = () => { const pointer = lastCanvasPointerRef.current; const anchor = pointer ? canvasPointFromClient(pointer.clientX, pointer.clientY) : canvasCenterPoint(); void uploadImagesToCanvas(imageFiles, anchor); }; if (clipboardNode && imageFiles.length > 0 && isTransparentClipboardNode(clipboardNode)) { event.preventDefault(); void shouldUseInternalClipboardNode(clipboardNode, imageFiles).then((useInternalNode) => { if (useInternalNode) { pasteNode(clipboardNode); return; } pasteImageFiles(); }); return; } if (imageFiles.length > 0) { event.preventDefault(); pasteImageFiles(); return; } if (!clipboardNode) return; event.preventDefault(); pasteNode(); }; window.addEventListener("paste", handlePaste); return () => window.removeEventListener("paste", handlePaste); }); const addCanvasNode = (type: CanvasNode["type"]) => { const rect = containerRef.current?.getBoundingClientRect(); const stageWidth = rect?.width ?? 1100; const stageHeight = rect?.height ?? 760; const centerX = (stageWidth / 2 - viewport.x) / viewport.k; const centerY = (stageHeight / 2 - viewport.y) / viewport.k; const size = type === "text" ? { width: 280, height: 180 } : type === "image" ? { width: 360, height: 460 } : { width: 520, height: 320 }; const node: CanvasNode = { id: typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID() : `${Date.now()}-${type}`, type, title: type === "text" ? t("newTextNode") : type === "image" ? t("newImageNode") : t("newFrameNode"), x: centerX - size.width / 2, y: centerY - size.height / 2, width: size.width, height: size.height, content: type === "text" ? t("newTextNodeContent") : project?.brief ?? t("unnamedCanvasProject"), tone: "natural", status: "draft" }; setNodes((current) => [...current, node]); selectOnlyNode(node.id); setDirty(true); }; const addImageGeneratorNode = (point?: { x: number; y: number }) => { const rect = containerRef.current?.getBoundingClientRect(); const stageWidth = rect?.width ?? 1100; const stageHeight = rect?.height ?? 760; const center = { x: (stageWidth / 2 - viewport.x) / viewport.k, y: (stageHeight / 2 - viewport.y) / viewport.k }; const origin = point ?? center; const size = imageGeneratorPlaceholderSize(composerImageSettings, viewport.k, { width: stageWidth, height: stageHeight }); const node: CanvasNode = { id: cryptoFallbackId(), type: "image", title: t("imageGeneratorNode"), x: origin.x - size.width / 2, y: origin.y - size.height / 2, width: size.width, height: size.height, content: "", tone: "image-generator", status: "draft", layerRole: "image-generator" }; setComposerMode("image"); setActiveTool("select"); setMode("select"); setNodes((current) => { const next = [...current, node]; nodesRef.current = next; return next; }); selectOnlyNode(node.id); setDirty(true); }; const updateComposerImageSettings = (settings: ComposerImageSettings) => { setComposerImageSettings(settings); const generator = selectedImageGeneratorNode; if (!generator) return; const size = imageGeneratorPlaceholderSize(settings, viewportRef.current.k, canvasStageSize); setNodes((current) => { const next = current.map((node) => { if (node.id !== generator.id || node.type !== "image" || node.layerRole !== "image-generator") return node; const centerX = node.x + node.width / 2; const centerY = node.y + node.height / 2; return { ...node, x: centerX - size.width / 2, y: centerY - size.height / 2, width: size.width, height: size.height }; }); nodesRef.current = next; return next; }); setDirty(true); }; const addTextNodeAt = (point?: { x: number; y: number }) => { const rect = containerRef.current?.getBoundingClientRect(); const stageWidth = rect?.width ?? 1100; const stageHeight = rect?.height ?? 760; const currentViewport = viewportRef.current; const center = { x: (stageWidth / 2 - currentViewport.x) / currentViewport.k, y: (stageHeight / 2 - currentViewport.y) / currentViewport.k }; const origin = point ?? center; const zoom = Math.max(currentViewport.k, 0.01); const screenFontSize = defaultTextVisibleFontSize; const size = { width: defaultTextVisibleBox.width / zoom, height: defaultTextVisibleBox.height / zoom }; const x = point ? origin.x : origin.x - size.width / 2; const y = point ? origin.y : origin.y - size.height / 2; const node: CanvasNode = { id: cryptoFallbackId(), type: "text", title: t("newTextNode"), x, y, width: size.width, height: size.height, content: "", tone: "ink", status: "draft", layerRole: "editable-text", fontFamily: "Inter", fontWeight: "Regular", fontSize: screenFontSize / zoom, color: "#000000", textAlign: "left", opacity: 1, strokeColor: "#000000", strokeWidth: 0, lineHeight: 0, letterSpacing: 0, textDecoration: "none", textTransform: "none" }; setNodes((current) => { const next = [...current, node]; nodesRef.current = next; return next; }); selectOnlyNode(node.id); setEditingTextNodeId(node.id); setDirty(true); }; const chooseCanvasTool = (tool: CanvasTool) => { setActiveTool(tool); setCanvasToolMenuOpen(false); if (tool !== "shape") setShapeToolMenuOpen(false); if (tool === "select" || tool === "pin") { setMode("select"); return; } if (tool === "pan") { setMode("pan"); return; } if (tool === "image") { addImageGeneratorNode(); return; } if (tool === "frame") { setMode("select"); return; } if (tool === "shape") { setMode("select"); return; } if (tool === "pen") { setMode("select"); return; } setMode("select"); }; const chooseShapeTool = (kind: ShapeKind) => { setSelectedShapeKind(kind); setShapeToolMenuOpen(false); chooseCanvasTool("shape"); }; const shareProject = () => { if (!project) return; const shareUrl = window.location.href; const shareTitle = project.title || t("canvasUntitled"); if (navigator.share) { void navigator.share({ title: shareTitle, url: shareUrl }).catch((err: unknown) => { if (err instanceof DOMException && err.name === "AbortError") return; showCanvasToast(t("shareUnavailable"), "error"); }); return; } if (!navigator.clipboard?.writeText) { showCanvasToast(t("shareUnavailable"), "error"); return; } void navigator.clipboard.writeText(shareUrl).then(() => showCanvasToast(t("shareLinkCopied"))).catch(() => showCanvasToast(t("shareUnavailable"), "error")); }; const syncReferenceImages = (references: UploadedReferenceImage[]) => { referenceImagesRef.current = references; setReferenceImages(references); }; const handleReferenceRemoved = (reference: UploadedReferenceImage) => { revokeReferenceImage(reference); if (reference.external) return; if (isCanvasNodeReferenceImage(reference)) return; if (isAssetReferencedByCanvas(reference.publicUrl, nodesRef.current)) return; if (reference.publicUrl) { void designGateway.deleteAsset(reference.publicUrl).catch((err: Error) => console.warn("Failed to queue reference image cleanup", err)); } }; const beginNewAgentConversation = () => { selectedAgentThreadIdRef.current = newAgentConversationId; setSelectedAgentThreadId(newAgentConversationId); setAgentHistoryOpen(false); setPrompt(""); setMessages([]); setError(""); agentComposerRef.current?.reset(""); activeAgentThreadIdRef.current = null; }; const selectAgentThread = (threadId: string) => { selectedAgentThreadIdRef.current = threadId; setSelectedAgentThreadId(threadId); setAgentHistoryOpen(false); const currentProject = projectRef.current; if (currentProject) { setMessages(visibleAgentMessagesForThread(currentProject, imageGeneratorThreadIdsRef.current, threadId)); } }; const removeAgentThreadFromMenu = (threadId: string) => { setAgentThreads((current) => current.filter((thread) => thread.agentThreadId !== threadId)); }; const generate = async (source: "agent" | "image-generator" = "agent", imageGeneratorContents?: AgentContent[], resetImageGenerator?: () => void) => { const selectedGeneratorNode = source === "image-generator" && selectedImageGeneratorNode ? selectedImageGeneratorNode : null; const activeMode: ComposerMode = source === "image-generator" ? "image" : composerMode; const enableSearch = source === "agent" && useAgentWebSearch; const activeRef = agentComposerRef; const activePrompt = prompt; const baseContents = source === "image-generator" ? imageGeneratorContents ?? [] : activeRef.current?.toAgentContents() ?? [{ type: "text" as const, text: activePrompt, textSource: "input" }]; const hasUserText = baseContents.some((content) => content.type === "text" && content.text.replace(/\u00a0/g, " ").trim()); const contents = activeMode === "image" ? withImageModeSettings(baseContents, composerImageSettings, t, selectedGeneratorNode?.id) : baseContents; if (!project || !hasUserText) return; const selectedThreadId = selectedAgentThreadIdRef.current; const reusableAgentThreadId = source === "agent" && selectedThreadId !== newAgentConversationId ? selectedThreadId || latestAgentThreadId(project, imageGeneratorThreadIdsRef.current) : ""; const threadId = reusableAgentThreadId || cryptoFallbackId(); if (source === "image-generator") { setImageGeneratorGenerating(true); if (selectedGeneratorNode) { const lockedPrompt = imageGeneratorSubmittedPrompt(baseContents); setNodes((current) => { const next = current.map((node) => node.id === selectedGeneratorNode.id && node.type === "image" && node.layerRole === "image-generator" ? { ...node, content: lockedPrompt, status: "generating" } : node ); nodesRef.current = next; return next; }); } } else { activeAgentThreadIdRef.current = threadId; selectedAgentThreadIdRef.current = threadId; setSelectedAgentThreadId(threadId); setAgentGenerating(true); } setError(""); if (source === "image-generator") { imageGeneratorThreadIdsRef.current.add(threadId); } else { setMessages((current) => [...current, ...createOptimisticAgentMessages(threadId, contents)]); } try { if (dirty) { await saveCanvasSnapshot({ quiet: true }); } startAgentSocket( project.id, { messages: [{ role: "user", contents }], mode: source === "image-generator" ? "image-generator" : activeMode === "image" ? "image" : "design", threadId, threadIdType: 9, imageModel: toImageApiModel(selectedCanvasModel.id), imageSize: activeMode === "image" ? composerImageSize(composerImageSettings) : undefined, enableWebSearch: enableSearch, toolConfig: { preferToolCategories: { ...(enableSearch ? { SEARCH: ["search_web_info", "search_images"] } : {}), IMAGE: ["generate_image_gpt_image_2"] } } }, source ); if (source === "image-generator") { resetImageGenerator?.(); } else { activeRef.current?.reset(""); setPrompt(""); } setDirty(false); } catch (err) { setError(toUserMessageText(err, "generateError")); if (source === "image-generator") { setImageGeneratorGenerating(false); } else { activeAgentThreadIdRef.current = null; setAgentGenerating(false); } } }; const stopAgentTask = async () => { if (!project) return; const threadId = activeAgentThreadIdRef.current || project.lastThreadId; stopGenerationStream("agent"); activeAgentThreadIdRef.current = null; setAgentGenerating(false); setError(""); try { const { project: nextProject } = await designGateway.stopAgentTask(project.id, threadId); applyProject(nextProject); } catch (err) { setError(toUserMessageText(err, "generateError")); } }; const runImageNodeAction = async (action: NodeActionRequest["action"], request: Omit = {}) => { const targetNode = selectedImageNode; if (!project || !targetNode || isImageActionBusy(targetNode)) return; setCanvasActionGenerating(true); setError(""); try { if (dirty) { const savedProject = await saveCanvasSnapshot({ quiet: true }); if (!savedProject) { setCanvasActionGenerating(false); return; } } const result = await designGateway.runNodeActionAsync(project.id, targetNode.id, { action, imageModel: toImageApiModel(selectedCanvasModel.id), imageSize: defaultImageSize(), ...request }); applyProject(result.project); startProjectEvents(result.project.id, result.project.lastThreadId, "canvas-action"); setImageActionMode(null); setEraserMarks([]); setTextEditItems([]); setTextEditLoading(false); setTextEditError(""); setMoveSelection(null); setMoveRecognitionLabel(""); setMoveRecognitionLoading(false); setCropSelection(null); setQuickEditPrompt(""); setDirty(false); } catch (err) { setError(toUserMessageText(err, "generateError")); setCanvasActionGenerating(false); setMoveRecognitionLoading(false); } }; const syncProjectWithLocalCanvas = (nextProject: Project, nextNodes = nodesRef.current) => { const syncedProject = { ...nextProject, viewport: viewportRef.current, nodes: nextNodes }; projectRef.current = syncedProject; setProject(syncedProject); return syncedProject; }; const prepareMockupPlacement = () => { const currentProject = projectRef.current ?? project; if (!currentProject || !selectedImageNode || selectedImageNodeBusy || !isRealCanvasImageNode(selectedImageNode)) return; const targetId = selectedImageNode.id; const requestId = mockupPrepareRequestRef.current + 1; mockupPrepareRequestRef.current = requestId; if (mockupPrepareTimerRef.current) clearTimeout(mockupPrepareTimerRef.current); setMockupDropActive(false); setMockupDragNodeId(null); setMockupReadyTargetId(null); setMockupPlacement(null); setMockupSurface(null); setMockupModel(null); setMockupPendingTargetId(targetId); setImageActionMode(null); let closeMockupEvents: (() => void) | null = null; let settled = false; const minimumPrepareTime = new Promise((resolve) => { mockupPrepareTimerRef.current = setTimeout(() => { mockupPrepareTimerRef.current = null; resolve(); }, 650); }); const finishFromProject = async (nextProject: Project) => { if (settled) return false; if (mockupPrepareRequestRef.current !== requestId) { closeMockupEvents?.(); return false; } applyProject(nextProject, { preserveMessages: true }); const target = nextProject.nodes.find((node) => node.id === targetId); if (!target || !hasUsableMockupModel(target.mockupModel)) return false; const model = target.mockupModel; const surface = usableMockupSurface(target.mockupSurface, target); const placement = await resolveMockupPlacement(target, model, surface); await minimumPrepareTime; if (settled) return false; if (mockupPrepareRequestRef.current !== requestId) { closeMockupEvents?.(); return false; } const nextNodes = nodesRef.current.map((node) => (node.id === targetId ? { ...node, status: target.status, mockupSurface: surface, mockupModel: model } : node)); nodesRef.current = nextNodes; setNodes(nextNodes); syncProjectWithLocalCanvas(nextProject, nextNodes); setMockupPlacement(placement); setMockupSurface(surface); setMockupModel(model); setMockupReadyTargetId(targetId); setMockupPendingTargetId(null); setImageActionMode("mockup"); settled = true; closeMockupEvents?.(); return true; }; const failPreparation = (message: string) => { if (settled || mockupPrepareRequestRef.current !== requestId) return; settled = true; closeMockupEvents?.(); setMockupPendingTargetId(null); setMockupPlacement(null); setMockupSurface(null); setMockupModel(null); setError(message); }; void (async () => { try { if (dirty) { const savedProject = await saveCanvasSnapshot({ quiet: true }); if (mockupPrepareRequestRef.current !== requestId) return; if (!savedProject) { setMockupPendingTargetId(null); return; } } const requestProject = projectRef.current ?? currentProject; const response = await designGateway.generateMockupModel(requestProject.id, targetId); if (mockupPrepareRequestRef.current !== requestId) return; if (await finishFromProject(response.project)) return; let timer: ReturnType | null = null; let closed = false; closeMockupEvents = () => { closed = true; if (timer) clearTimeout(timer); }; const poll = async () => { try { const task = await designGateway.getGeneratorTask(response.project.id, response.threadId); if (closed || mockupPrepareRequestRef.current !== requestId) return; if (task.data.status === "failed") { const { project: nextProject } = await designGateway.getProject(response.project.id); if (closed || mockupPrepareRequestRef.current !== requestId) return; failPreparation(projectFailureMessage(nextProject) || t("generateError")); return; } if (task.data.status === "completed") { const { project: nextProject } = await designGateway.getProject(response.project.id); if (closed || mockupPrepareRequestRef.current !== requestId) return; const ready = await finishFromProject(nextProject); if (ready) return; failPreparation(projectFailureMessage(nextProject) || t("generateError")); return; } timer = setTimeout(poll, generatorTaskPollIntervalMs); } catch (err) { if (!closed) failPreparation(toUserMessageText(err, "generateError")); } }; void poll(); } catch (err) { if (mockupPrepareRequestRef.current !== requestId) return; failPreparation(toUserMessageText(err, "generateError")); } })(); }; const applyMockupPrint = async (sourceNode: CanvasNode, targetNode: CanvasNode, placement: MockupPlacement, surface: MockupSurface, model: MockupModel, dropPoint?: { x: number; y: number }) => { if (!project || !isRealCanvasImageNode(sourceNode)) return; const printPlacement = mockupPrintPlacementForDrop(sourceNode, targetNode, placement, dropPoint); const fit = mockupPlacementWorldRect(targetNode, printPlacement); let content = sourceNode.content; try { content = await renderMockupPrintTexture(sourceNode.content, targetNode.content, printPlacement, fit, model); } catch (err) { console.warn("Failed to render mockup texture preview", err); } const printNode: CanvasNode = { id: cryptoFallbackId(), type: "image", title: t("mockupPrintTitle"), x: fit.x, y: fit.y, width: fit.width, height: fit.height, content, tone: "mockup-print", status: "success", layerRole: "mockup-print", opacity: 0.92, mockupSurface: surface, mockupModel: model, mockupSourceContent: sourceNode.mockupSourceContent || sourceNode.content }; const targetIndex = nodesRef.current.findIndex((node) => node.id === targetNode.id); const nextNodes = [...nodesRef.current]; nextNodes.splice(targetIndex >= 0 ? targetIndex + 1 : nextNodes.length, 0, printNode); nodesRef.current = nextNodes; setNodes(nextNodes); selectOnlyNode(printNode.id); setImageActionMode(null); setMockupDropActive(false); setMockupReadyTargetId(null); setMockupPlacement(null); setMockupSurface(null); setMockupModel(null); setDirty(true); void saveCanvasSnapshot({ quiet: true }); }; const refreshMockupPrintTextures = async (nodeIds: string[]) => { const nextNodes = [...nodesRef.current]; let changed = false; for (const nodeId of nodeIds) { const index = nextNodes.findIndex((node) => node.id === nodeId); const printNode = nextNodes[index]; const targetId = printNode?.mockupSurface?.targetId; const model = printNode?.mockupModel; if (!printNode || printNode.layerRole !== "mockup-print" || !printNode.mockupSourceContent || !targetId || !model) continue; const targetNode = nextNodes.find((node) => node.id === targetId); if (!targetNode || !isRealCanvasImageNode(targetNode)) continue; try { const placement = mockupPlacementFromPrintNode(printNode, targetNode); const content = await renderMockupPrintTexture(printNode.mockupSourceContent, targetNode.content, placement, { width: printNode.width, height: printNode.height }, model); nextNodes[index] = { ...printNode, content }; changed = true; } catch (err) { console.warn("Failed to refresh mockup texture preview", err); } } if (!changed) { void saveCanvasSnapshot({ quiet: true }); return; } nodesRef.current = nextNodes; setNodes(nextNodes); setDirty(true); void saveCanvasSnapshot({ quiet: true }); }; const restoreQuickEditViewport = useCallback(() => { const focus = quickEditFocusRef.current; if (focus) { viewportRef.current = focus.viewport; setViewport(focus.viewport); setDirty(focus.dirty); quickEditFocusRef.current = null; } setImageActionMode(null); }, [setViewport, viewportRef]); useEffect(() => { if (imageActionMode !== "quick-edit") return; const handlePointerDown = (event: PointerEvent) => { const target = event.target as Element | null; if (target?.closest(".quick-edit-panel")) return; restoreQuickEditViewport(); }; document.addEventListener("pointerdown", handlePointerDown, true); return () => document.removeEventListener("pointerdown", handlePointerDown, true); }, [imageActionMode, restoreQuickEditViewport]); const applyQuickEdit = () => { if (!quickEditPrompt.trim()) return; restoreQuickEditViewport(); void runImageNodeAction("quick-edit", { prompt: quickEditPrompt.trim() }); }; const openQuickEditPanel = () => { if (!selectedImageNode) return; const stageWidth = canvasStageSize.width; const stageHeight = canvasStageSize.height; if (!quickEditFocusRef.current) { quickEditFocusRef.current = { viewport: { ...viewportRef.current }, dirty }; } if (stageWidth > 0 && stageHeight > 0) { const topMargin = 52; const sideMargin = 56; const footerReserve = 188; const availableWidth = Math.max(320, stageWidth - sideMargin * 2); const availableHeight = Math.max(240, stageHeight - topMargin - footerReserve); const scale = clamp(Math.min(availableWidth / Math.max(selectedImageNode.width, 1), availableHeight / Math.max(selectedImageNode.height, 1)), minCanvasZoom, maxCanvasZoom); const nextViewport = { x: stageWidth / 2 - (selectedImageNode.x + selectedImageNode.width / 2) * scale, y: topMargin - selectedImageNode.y * scale, k: scale }; viewportRef.current = nextViewport; setViewport(nextViewport); } setMode("select"); setImageActionMode("quick-edit"); }; const applyEraserEdit = () => { if (eraserMarks.length === 0) return; void runImageNodeAction("eraser", { prompt: t("eraserDefaultPrompt"), mask: JSON.stringify({ brushSize: eraserBrushSize, brushColor: eraserBrushColor, brushOpacity: eraserBrushOpacity, points: eraserMarks }) }); }; const openTextEditPanel = async () => { if (!project || !selectedImageNode || isImageActionBusy(selectedImageNode)) return; const targetNodeId = selectedImageNode.id; textExtractionEventsCloseRef.current?.(); textExtractionEventsCloseRef.current = null; setImageActionMode(null); setTextEditItems([]); setTextEditLoading(true); setTextEditError(""); setCanvasActionGenerating(true); try { const response = await designGateway.extractNodeTextAsync(project.id, targetNodeId, "edit-text"); applyProject(response.project, { preserveMessages: true }); const openPanelWithResult = (result: ExtractNodeTextResponse) => { setTextEditItems(result.items.map(toEditableTextLayer)); setTextEditError(""); setImageActionMode("edit-text"); setTextEditLoading(false); }; let closed = false; let timer: ReturnType | null = null; const closePolling = () => { closed = true; if (timer) clearTimeout(timer); }; textExtractionEventsCloseRef.current = closePolling; const poll = async () => { try { const task = await designGateway.getGeneratorTask(response.project.id, response.threadId); if (closed) return; if (task.data.status === "completed") { const { project: nextProject } = await designGateway.getProject(response.project.id); if (closed) return; applyProject(nextProject, { preserveMessages: true }); const result = textExtractionResultFromProject(nextProject, response.threadId); if (result) openPanelWithResult(result); setCanvasActionGenerating(false); textExtractionEventsCloseRef.current = null; return; } if (task.data.status === "failed") { const { project: nextProject } = await designGateway.getProject(response.project.id); if (closed) return; applyProject(nextProject, { preserveMessages: true }); setTextEditLoading(false); setCanvasActionGenerating(false); setError(projectFailureMessage(nextProject) || t("editTextOcrFailed")); textExtractionEventsCloseRef.current = null; return; } timer = setTimeout(poll, generatorTaskPollIntervalMs); } catch (nextError) { if (closed) return; setTextEditLoading(false); setCanvasActionGenerating(false); setError(toUserMessageText(nextError, "editTextOcrFailed")); textExtractionEventsCloseRef.current = null; } }; void poll(); } catch (err) { setError(toUserMessageText(err, "editTextOcrFailed")); setTextEditLoading(false); setCanvasActionGenerating(false); } }; const updateTextEditItem = (id: string, text: string) => { setTextEditItems((current) => current.map((item) => (item.id === id ? { ...item, text, removed: false } : item))); }; const removeTextEditItem = (id: string) => { setTextEditItems((current) => current.map((item) => (item.id === id ? { ...item, text: "", removed: true } : item))); }; const cancelTextEdit = () => { textExtractionEventsCloseRef.current?.(); textExtractionEventsCloseRef.current = null; setImageActionMode(null); setTextEditItems([]); setTextEditLoading(false); setTextEditError(""); }; const applyTextEdit = () => { if (textEditItems.length === 0 || textEditLoading) return; void runImageNodeAction("edit-text", { prompt: buildTextEditPrompt(textEditItems, t("editTextPrompt")), options: { textLayers: JSON.stringify(textEditItems.map(toTextLayerPayload)) } }); }; const applyMoveObject = () => { if (!moveSelection?.dstBox) return; const objectLabel = moveRecognitionLabel.trim(); void runImageNodeAction("move-object", { prompt: movePrompt.trim() || t("moveDefaultPrompt"), selection: JSON.stringify({ src_box: moveSelection.srcBox, dst_box: moveSelection.dstBox }), options: objectLabel ? { object_label: objectLabel } : undefined }); }; const applyMultiAngle = () => { void runImageNodeAction("multi-angle", { prompt: multiAngleOptions.prompt.trim() || t("multiAngleDefaultPrompt"), options: { tab: multiAngleOptions.tab, rotation: String(multiAngleOptions.yaw), tilt: String(multiAngleOptions.pitch), zoom: String(multiAngleOptions.zoom) } }); }; const updateSelectedImageAdjustments = (nextAdjustments: AdjustOptions) => { setAdjustOptions(nextAdjustments); const targetId = selectedImageNode?.id; if (!targetId) return; const imageAdjustments = serializeImageAdjustments(nextAdjustments) || undefined; const applyPatch = (node: CanvasNode) => (node.id === targetId ? { ...node, imageAdjustments } : node); const nextNodes = nodesRef.current.map(applyPatch); nodesRef.current = nextNodes; setNodes((current) => current.map(applyPatch)); setDirty(true); }; const openAdjustPanel = () => { setAdjustOptions(parseImageAdjustments(selectedImageNode?.imageAdjustments)); setImageActionMode("adjust"); }; const autoAdjustSelectedImage = () => { updateSelectedImageAdjustments(autoImageAdjustments(adjustOptions)); }; const openExpandPanel = () => { setExpandOptions((current) => ({ ...current, imageRect: undefined })); setImageActionMode("expand"); }; const applyExpandImage = () => { if (!selectedImageNode) return; const target = expandTargetGeometryForNode(selectedImageNode, expandOptions); void runImageNodeAction("expand", { prompt: expandOptions.prompt.trim() || t("expandPrompt"), imageSize: expandImageSize(expandOptions.ratio, target.width / Math.max(target.height, 1)), selection: JSON.stringify({ target: { x: target.x, y: target.y, width: target.width, height: target.height }, source: target.imageRect }), options: { ratio: expandOptions.ratio, scale: `${expandOptions.scale}x`, targetX: String(target.x), targetY: String(target.y), targetWidth: String(target.width), targetHeight: String(target.height), sourceRect: JSON.stringify(target.imageRect) } }); }; const applyCropImage = async () => { const targetNode = selectedImageNode; const currentProject = projectRef.current ?? project; if (!targetNode || !currentProject) return; const selection = cropSelection ?? defaultCropSelection; if (selection.width < 0.02 || selection.height < 0.02) return; setSaving(true); setError(""); try { const cropped = await cropImageNodeFile(targetNode, selection); const publicUrl = await designGateway.uploadImage(cropped.file); const nextNodes = nodesRef.current.map((node) => node.id === targetNode.id ? { ...node, x: targetNode.x + targetNode.width * selection.x, y: targetNode.y + targetNode.height * selection.y, width: cropped.displayWidth, height: cropped.displayHeight, content: publicUrl, status: "success" } : node ); nodesRef.current = nextNodes; setNodes(nextNodes); setImageActionMode(null); setCropSelection(null); setCropPrompt(""); setDirty(true); const { project: savedProject } = await designGateway.saveCanvas(currentProject.id, viewportRef.current, nextNodes, [], { version: currentProject.version, snapshotId: currentProject.snapshotId, canvas: currentProject.canvas }); applyProject(savedProject); setDirty(false); } catch (err) { setError(toUserMessageText(err, "generateError")); } finally { setSaving(false); } }; const attachImage = async (file: File | null) => { if (!file) return; const localPreviewUrl = URL.createObjectURL(file); try { const publicUrl = await designGateway.uploadImage(file); const previewUrl = publicUrl || localPreviewUrl; if (previewUrl !== localPreviewUrl) URL.revokeObjectURL(localPreviewUrl); const reference = { id: `${Date.now()}-${file.name}`, name: file.name, publicUrl, previewUrl }; if (agentComposerRef.current) { agentComposerRef.current.insertReference(reference); } else { syncReferenceImages([...referenceImagesRef.current, reference]); } } catch (err) { URL.revokeObjectURL(localPreviewUrl); setError(toUserMessageText(err, "generateError")); } }; const uploadReferenceToImageGenerator = async (file: File | null) => { if (!file) return null; const localPreviewUrl = URL.createObjectURL(file); try { const publicUrl = await designGateway.uploadImage(file); const previewUrl = publicUrl || localPreviewUrl; if (previewUrl !== localPreviewUrl) URL.revokeObjectURL(localPreviewUrl); const reference = { id: `${Date.now()}-${file.name}`, name: file.name, publicUrl, previewUrl }; return reference; } catch (err) { URL.revokeObjectURL(localPreviewUrl); setError(toUserMessageText(err, "generateError")); return null; } }; const canvasCenterPoint = () => { const rect = containerRef.current?.getBoundingClientRect(); const currentViewport = viewportRef.current; return { x: ((rect?.width ?? 1100) / 2 - currentViewport.x) / currentViewport.k, y: ((rect?.height ?? 760) / 2 - currentViewport.y) / currentViewport.k }; }; const uploadImagesToCanvas = async (files: File[], anchor = canvasCenterPoint()) => { const currentProject = projectRef.current; const imageFiles = files.filter(isImageFile); if (!currentProject || imageFiles.length === 0) return; setSaving(true); setError(""); const preparedUploads: Array<{ file: File; previewUrl: string; node: CanvasNode }> = []; try { for (const [index, file] of imageFiles.entries()) { const previewUrl = URL.createObjectURL(file); const dimensions = await readImageDimensions(file).catch(() => ({ width: 512, height: 512 })); const size = fitCanvasImageSize(dimensions.width, dimensions.height); const tone = imageMaySupportTransparency(file) ? "transparent-image" : "natural"; preparedUploads.push({ file, previewUrl, node: { id: cryptoFallbackId(), type: "image", title: file.name || t("newImageNode"), x: anchor.x - size.width / 2 + index * 36, y: anchor.y - size.height / 2 + index * 36, width: size.width, height: size.height, content: previewUrl, tone: "natural", status: "uploading" } }); } const optimisticNodes = preparedUploads.map((item) => item.node); const nextNodesWithUploads = [...nodesRef.current, ...optimisticNodes]; nodesRef.current = nextNodesWithUploads; setNodes(nextNodesWithUploads); selectNodes(optimisticNodes.map((node) => node.id)); setDirty(true); const uploadedResults = await Promise.allSettled( preparedUploads.map(async ({ file, node }) => ({ id: node.id, publicUrl: await designGateway.uploadImage(file) })) ); const uploadedById = new Map(); const failedIds = new Set(); uploadedResults.forEach((result, index) => { const nodeId = preparedUploads[index]?.node.id; if (!nodeId) return; if (result.status === "fulfilled") uploadedById.set(result.value.id, result.value.publicUrl); else failedIds.add(nodeId); }); const finalUploadedNodeIds = new Set(uploadedById.keys()); const finalNodes = nodesRef.current .filter((node) => !failedIds.has(node.id)) .map((node) => { const publicUrl = uploadedById.get(node.id); return publicUrl ? { ...node, content: publicUrl, status: "success" } : node; }); nodesRef.current = finalNodes; setNodes(finalNodes); selectNodes(finalNodes.filter((node) => finalUploadedNodeIds.has(node.id)).map((node) => node.id)); preparedUploads.forEach((item) => URL.revokeObjectURL(item.previewUrl)); if (failedIds.size > 0) { setError(t("generateError")); } const { project: savedProject } = await designGateway.saveCanvas(currentProject.id, viewportRef.current, finalNodes, [], { version: currentProject.version, snapshotId: currentProject.snapshotId, canvas: "" }); applyProject(savedProject); setDirty(false); if (failedIds.size === 0 && finalUploadedNodeIds.size > 0) showCanvasToast(t("imageUploadSuccess")); } catch (err) { preparedUploads.forEach((item) => URL.revokeObjectURL(item.previewUrl)); const failedUploadIds = new Set(preparedUploads.map((item) => item.node.id)); if (failedUploadIds.size > 0) { const nextNodes = nodesRef.current.filter((node) => !failedUploadIds.has(node.id)); nodesRef.current = nextNodes; setNodes(nextNodes); } setError(toUserMessageText(err, "generateError")); } finally { setSaving(false); setCanvasFileDragging(false); } }; const handleCanvasImageInput = (event: React.ChangeEvent) => { const files = Array.from(event.target.files ?? []); event.target.value = ""; void uploadImagesToCanvas(files); }; const handleCanvasPointerMove = (event: React.PointerEvent) => { lastCanvasPointerRef.current = { clientX: event.clientX, clientY: event.clientY }; }; const handleCanvasDragOver = (event: React.DragEvent) => { if (!dataTransferHasImage(event.dataTransfer)) return; event.preventDefault(); event.dataTransfer.dropEffect = "copy"; setCanvasFileDragging(true); }; const handleCanvasDragLeave = (event: React.DragEvent) => { if (event.currentTarget.contains(event.relatedTarget as Node | null)) return; setCanvasFileDragging(false); }; const handleCanvasDrop = (event: React.DragEvent) => { const files = dataTransferImageFiles(event.dataTransfer); if (files.length === 0) return; event.preventDefault(); event.stopPropagation(); setCanvasFileDragging(false); void uploadImagesToCanvas(files, canvasPointFromClient(event.clientX, event.clientY)); }; if (!project) { return (
{error || t("loadingCanvas")}
); } return (
0 1 : 1 7 : 1 4 : 5 3
{t("promo")} {t("upgradeNow")}
{assistantPanelOpen ? ( ) : ( setAssistantPanelOpen(true)} /> )} {layersPanelOpen && ( setLayersPanelOpen(false)} /> )} {generatedFilesPanelOpen && ( exportImageNode(node, isVectorizedImageNode(node) ? "svg" : "png")} onClose={() => setGeneratedFilesPanelOpen(false)} /> )} {showGrid && }
{renderedCanvasNodes.map((node) => { const hidden = hiddenNodeIds.has(node.id); const locked = lockedNodeIds.has(node.id); const canOperateNode = !locked; const suppressSelectionFrame = (imageActionMode === "expand" || imageActionMode === "crop") && selectedImageNode?.id === node.id; return (
{selectedImageGeneratorBounds && ( void generate("image-generator", contents, reset)} /> )} {selectionBox &&
} {alignmentGuides.map((guide) => ( ))} {multiSelectionBounds && ( selectedNodes.filter((node) => node.type === "image").forEach((node) => exportImageNode(node, format))} onResizeStart={handleMultiSelectionResizePointerDown} /> )} {selectedBooleanShapeGroupNode && selectedBooleanShapeGroupBounds && ( autoLayoutAroundNode(selectedBooleanShapeGroupNode)} onCreateGroup={createGroupFromSelectedNodes} onUngroup={() => ungroupBooleanShapeGroup(selectedBooleanShapeGroupNode.id)} onMergeLayers={mergeSelectedNodes} canBooleanOperate onBooleanOperation={applyBooleanShapeOperation} onAlign={alignSelectedNodes} onDistribute={distributeSelectedNodes} onDownload={(format) => exportCanvasNode(selectedBooleanShapeGroupNode, format)} onResizeStart={(event, handle) => handleMultiSelectionResizePointerDown(event, handle)} /> )} {selectedImageNode && selectedNodeBounds && ( <> {isVectorizedImageNode(selectedImageNode) ? ( exportImageNode(selectedImageNode, "svg")} /> ) : ( <> {imageActionMode !== "quick-edit" && imageActionMode !== "eraser" && imageActionMode !== "edit-text" && imageActionMode !== "expand" && imageActionMode !== "crop" && imageActionMode !== "flip-rotate" && selectedImageNode.status !== "text-extracting" && mockupPendingTargetId !== selectedImageNode.id && ( void runImageNodeAction("upscale", { prompt: t("upscalePrompt", { scale }), options: { scale } }) } onRemoveBackground={() => selectedImageNode && void runImageNodeAction("remove-background", { prompt: t("removeBackgroundPrompt"), options: backgroundRemovalOptions(selectedImageNode) })} onEraser={() => { setEraserMarks([]); setImageActionMode("eraser"); }} onEditElements={() => void runImageNodeAction("edit-elements", { prompt: t("editElementsPrompt") })} onEditText={() => void openTextEditPanel()} onMultiAngle={() => setImageActionMode("multi-angle")} onMoveObject={() => { setMoveSelection(null); setMoveRecognitionLabel(""); setMoveRecognitionLoading(false); setImageActionMode("move-object"); }} onMockup={prepareMockupPlacement} onExpand={openExpandPanel} onAdjust={openAdjustPanel} onCrop={() => { setCropSelection(defaultCropSelection); setImageActionMode("crop"); }} onVectorize={() => void runImageNodeAction("vectorize", { prompt: t("vectorPrompt") })} onFlipRotate={() => setImageActionMode("flip-rotate")} onDownload={() => exportImageNode(selectedImageNode, "png")} /> )} {imageActionMode === "quick-edit" && ( )} {imageActionMode === "eraser" && ( { setImageActionMode(null); setEraserMarks([]); }} onApply={applyEraserEdit} /> )} {imageActionMode === "edit-text" && ( )} {imageActionMode === "move-object" && ( { setImageActionMode(null); setMoveSelection(null); setMoveRecognitionLabel(""); setMoveRecognitionLoading(false); }} onApply={applyMoveObject} /> )} {imageActionMode === "multi-angle" && ( setImageActionMode(null)} onApply={applyMultiAngle} /> )} {mockupPendingTargetId === selectedImageNode.id && } {imageActionMode === "mockup" && mockupReadyTargetId === selectedImageNode.id && mockupPlacement && mockupModel && ( )} {imageActionMode === "adjust" && ( setImageActionMode(null)} onAutoAdjust={autoAdjustSelectedImage} /> )} {imageActionMode === "expand" && ( setImageActionMode(null)} onApply={applyExpandImage} /> )} {imageActionMode === "crop" && ( { setImageActionMode(null); setCropSelection(null); }} onApply={applyCropImage} /> )} {imageActionMode === "flip-rotate" && ( toggleNodeTransform(selectedImageNode.id, "flipX")} onFlipVertical={() => toggleNodeTransform(selectedImageNode.id, "flipY")} onRotationChange={(rotation) => updateNodeTransform(selectedImageNode.id, { rotation })} onRotate={() => rotateNodeClockwise(selectedImageNode.id)} onDownload={() => exportImageNode(selectedImageNode, "png")} /> )} )} )} {selectedFrameNode && selectedFrameBounds && ( updateFrameStyle(selectedFrameNode.id, patch)} /> )} {selectedShapeNode && selectedShapeBounds && !isLineShapeNode(selectedShapeNode) && ( updateShapeStyle(selectedShapeNode.id, patch)} onDownload={() => exportCanvasNode(selectedShapeNode, "svg")} /> )} {selectedPenStrokeNode && selectedPenStrokeBounds && ( updatePenStrokeStyle(selectedPenStrokeNode.id, patch)} onDownload={() => exportImageNode(selectedPenStrokeNode, "svg")} /> )} {selectedTextNode && selectedTextBounds && editingTextNodeId !== selectedTextNode.id && ( updateTextStyle(selectedTextNode.id, patch)} onDownload={() => exportTextNode(selectedTextNode)} /> )}
{showMinimap && ( { viewportRef.current = nextViewport; setViewport(nextViewport); setDirty(true); }} onViewportCommit={() => void saveCanvasSnapshot({ quiet: true })} /> )}
undefined} > zoomCanvasTo(viewportRef.current.k + 0.1)} onZoomOut={() => zoomCanvasTo(viewportRef.current.k - 0.1)} onFitAll={fitCanvasToAllNodes} onZoomTo={zoomCanvasTo} />
setShowGrid((value) => !value)} onUploadImage={() => canvasImageInputRef.current?.click()} /> {activeTool === "pen" && ( )}
{assistantPanelOpen && ( )}
); } function CanvasNodeView({ node, selected, mockupDragging, editing, showResizeHandles, hidden, locked, mockupClipPath, transform, selectionScale, viewportScale, hasClipboard, contextMenuHandlers, onPointerDown, onContextMenu, onTitleRename, onResizePointerDown, onShapeEndpointPointerDown, onContentChange, onTextEditFinish }: { node: CanvasNode; selected: boolean; mockupDragging: boolean; editing: boolean; showResizeHandles: boolean; hidden: boolean; locked: boolean; mockupClipPath?: string; transform: NodeTransform; selectionScale: number; viewportScale: number; hasClipboard: boolean; contextMenuHandlers: NodeContextMenuHandlers | null; onPointerDown: (event: React.PointerEvent) => void; onContextMenu: (event: React.MouseEvent) => void; onTitleRename: (title: string) => void; onResizePointerDown: (event: React.PointerEvent, handle: ResizeHandle) => void; onShapeEndpointPointerDown: (event: React.PointerEvent, endpoint: ShapeEndpointKey) => void; onContentChange: (content: string) => void; onTextEditFinish: (content: string) => void; }) { const { t } = useI18n(); const visual = node.type === "frame" || node.type === "image"; const manualFrame = node.type === "frame" && node.layerRole === "manual-frame"; const shapeNode = isShapeNode(node); const booleanShapeGroup = isBooleanShapeGroupNode(node); const lineShapeNode = isLineShapeNode(node); const penStroke = node.type === "image" && node.layerRole === "pen-stroke"; const textOverlay = node.type === "text" && node.layerRole === "editable-text"; const textOverlayStyle = textOverlay ? editableTextLayerStyle(node) : undefined; const textEditorRef = useRef(null); const selectionTitle = manualFrame ? "Frame" : shapeNode || booleanShapeGroup ? node.title.trim() || "Shape" : penStroke ? node.title.trim() || "Pen stroke" : node.title.trim() || (node.type === "image" ? "Image" : node.type); const selectionSize = `${Math.round(node.width)} × ${Math.round(node.height)}`; const SelectionIcon = node.type === "frame" ? Frame : node.type === "text" ? Type : penStroke ? PenLine : ImageIcon; useEffect(() => { if (!editing || !textOverlay) return; const editor = textEditorRef.current; if (!editor) return; editor.focus(); const cursor = editor.value.length; editor.setSelectionRange(cursor, cursor); }, [editing, node.id, textOverlay]); const handleContextMenu = (event: React.MouseEvent) => { if (!contextMenuHandlers) event.preventDefault(); onContextMenu(event); }; const contentLayerStyle: CSSProperties = { transform: `rotate(${transform.rotation}deg) scale(${transform.flipX ? -1 : 1}, ${transform.flipY ? -1 : 1})`, clipPath: mockupClipPath }; const nodeElement = (
{shapeNode || booleanShapeGroup ? ( ) : visual ? ( ) : textOverlay ? ( editing ? (