"use client"; import { ArrowDown, BookOpen, ChevronDown, Circle, Folder, Frame, Globe2, Image as ImageIcon, Layers, Lock, LogOut, Map as MapIcon, Loader2, MessageCircle, MessageSquarePlus, PanelRightClose, PenLine, Search, Trash2, Type, User, Zap } from "lucide-react"; import { startTransition, useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react"; import { openBrandKit, openHome } from "@/application/route"; import { ContextMenu, ContextMenuTrigger } from "@/components/ui/context-menu"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import type { BrandKit } from "@/domain/brandKit"; import type { AgentContent, AgentMessage, AgentStarterConfig, AgentThreadSummary, CanvasNode, CanvasViewport, ExtractedTextLayer, ExtractNodeTextResponse, MockupModel, MockupModelMap, MockupPlacement, MockupSurface, NodeActionRequest, Project, ShareAccess } from "@/domain/design"; import { useI18n, type Locale } from "@/i18n/i18n"; import { brandKitRepository, brandKitStorageScope } from "@/infrastructure/brandKitRepository"; import { designGateway } from "@/infrastructure/designGateway"; import { httpStatusOf } from "@/infrastructure/userMessages"; import { useAuth } from "@/ui/auth/AuthProvider"; import { AccountManagementDialog } from "@/ui/components/AccountManagementDialog"; import { ArtboardPreview } from "@/ui/components/ArtboardPreview"; import { BrandKitSelector } from "@/ui/components/BrandKitSelector"; 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 { CanvasAgentEmptyState } from "@/ui/components/CanvasAgentEmptyState"; import { defaultAgentStarterConfig, normalizeAgentStarterConfig } from "@/ui/components/CanvasAgentEmptyState/defaults"; 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 { renderVisualAnnotationGuide, visualAnnotationPayload, VisualAnnotationPanel, type VisualAnnotationMark, type VisualAnnotationTool } from "@/ui/components/VisualAnnotationPanel"; import { loadMotevaFontCatalog } from "@/ui/components/TextStyleToolbar/fontCatalog"; import { WorkspaceTitle } from "@/ui/components/WorkspaceTitle"; import { SharePopover } from "@/ui/components/SharePopover"; import { autoImageAdjustments, defaultImageAdjustments, parseImageAdjustments, serializeImageAdjustments } from "@/ui/lib/imageAdjustments"; import { friendlyProjectFailureMessage } from "@/ui/lib/friendlyAgentErrors"; import { cadImportMessageKey, isCadFile, isCanvasImportFile, prepareCanvasImportAsset, type PreparedCanvasImportAsset } from "@/ui/lib/cadImport"; import { cadSemanticContextFromSvgText } from "@/ui/lib/cadSemantics"; import { uploadPreparedCanvasImportAsset } from "@/ui/lib/canvasImportUpload"; import { isSvgSource, isVectorImageNode } from "@/ui/lib/vectorImage"; import { canvasImageUrl, canvasNodeRenderContent } from "@/ui/lib/imageDelivery"; import { createModelAgentContentPreparer } from "@/ui/lib/modelImageReferences"; import { visiblePromptText } from "@/ui/lib/promptImageReferences"; 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, attachNodesToContainingFrames, autoLayoutAroundNode as autoLayoutAroundNodeOp, autoLayoutNodes, canResizeCanvasNode, canUseNodeContextMenu, cloneCanvasNode, distributeNodes, groupNodes, isCanvasPenStrokeNode, isFrameChildNode, isFrameContainerNode, isRealCanvasImageNode, moveNodesByDelta, nodeIdsWithFrameChildren, orderedCanvasNodesForRender, patchFrameStyleNode, removeNodesById, reorderLayer as reorderLayerOp, reorderNode as reorderNodeOp, resizeFrameWithChildren, resizeVisualNodeByHandle, toggleSetValues, ungroupNodes, updateNodeContent as updateNodeContentOp, updateNodeTransform as updateNodeTransformOp, visibleCanvasNodes as getVisibleCanvasNodes, type LayerReorderRequest } from "@/ui/pages/CanvasWorkspace/canvas/canvasNodeOps"; import { CanvasSelectionFrame } from "@/ui/pages/CanvasWorkspace/canvas/CanvasSelectionFrame"; import { createCroppedSvgRasterSource, createVectorCropSiblingNode } from "./canvas/cadCrop"; import { installCanvasWheelListener } from "./canvas/canvasWheel"; import { detectColorBoundaryCrop } from "./canvas/colorBoundaryCrop"; import { createLeaferCanvasRenderer, leaferViewSize } from "./canvas/leaferRenderer"; import { cropSelectionFromScreenDrag, cropSelectionScreenBounds, visibleCropSelectionForBounds } from "./canvas/cropViewport"; import { beginFrameDrawing, FrameDraftOverlay, type FrameDraft, type FrameWorldRect } from "./canvas/FrameToolOverlay"; import { FrameStyleToolbar } from "./canvas/FrameStyleToolbar"; import { 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, imageGeneratorTargetSizeFromNode, imageGeneratorTargetSizeFromSettings, serializeImageGeneratorTargetSize, type ImageGeneratorTargetSize } 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 canvasSidePanelWidth = 268; const newAgentConversationId = "__new_agent_conversation__"; const prepareModelAgentContents = createModelAgentContentPreparer({ uploadImage: designGateway.uploadImage }); 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(","); function revokePreparedUploadUrls(item: { sourcePreviewUrl: string; renderPreviewUrl?: string }) { URL.revokeObjectURL(item.sourcePreviewUrl); if (item.renderPreviewUrl) URL.revokeObjectURL(item.renderPreviewUrl); } function renderAlignmentGuidesImperatively(layer: HTMLElement | null, guides: AlignmentGuide[]) { if (!layer) return; if (guides.length === 0) { layer.replaceChildren(); return; } while (layer.childElementCount < guides.length) layer.append(document.createElement("span")); Array.from(layer.children).forEach((child, index) => { const element = child as HTMLElement; const guide = guides[index]; if (!guide) { element.style.display = "none"; return; } element.className = `canvas-alignment-guide ${guide.orientation}`; element.style.display = "block"; element.style.left = `${guide.left}px`; element.style.top = `${guide.top}px`; element.style.width = guide.orientation === "horizontal" ? `${guide.length}px` : ""; element.style.height = guide.orientation === "vertical" ? `${guide.length}px` : ""; }); } type LocalizedText = Record; type HomeImageModel = { id: string; label: string; description: string; duration: string; credits: number; }; type ImageActionMode = "quick-edit" | "visual-annotation" | "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 isProjectAccessDenied(err: unknown) { const status = httpStatusOf(err); return status === 403 || status === 404; } 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 withImageGeneratorTarget(contents: AgentContent[], targetNodeId?: string) { return [ ...contents, ...(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; } const ownerWorkspaceAccess: ShareAccess = { permission: "owner", source: "owner", authenticated: true, isOwner: true, capabilities: { canViewCanvas: true, canViewChat: true, canEdit: true, canManage: true } }; export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess }: { projectId: string; shareAccess?: ShareAccess }) { const { t, locale } = useI18n(); const { isAuthenticated, user } = useAuth(); const canEdit = shareAccess.capabilities.canEdit; const canViewChat = shareAccess.capabilities.canViewChat; const canManageSharing = shareAccess.capabilities.canManage; const canManageBrandKit = shareAccess.isOwner; const toUserMessageText = useUserMessage(); const { showToast } = useGlobalToast(); const showGenerationError = useCallback((message: string) => showToast(message, "error"), [showToast]); const containerRef = useRef(null); const canvasWorldRef = useRef(null); const leaferMountRef = useRef(null); const leaferRendererRef = useRef> | null>(null); const imperativeAlignmentGuideRef = useRef(null); const viewportCommitTimerRef = useRef(null); const canvasAssetInputRef = useRef(null); const agentComposerRef = useRef(null); const assistantFeedRef = useRef(null); const assistantFooterRef = useRef(null); const autoScrollAgentFeedRef = useRef(false); 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 pendingWorkspaceCropRef = useRef<{ nodeId: string; selection: NormalizedRect } | null>(null); const workspaceCropDetectionRequestRef = useRef(0); 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 [agentHistoryLoading, setAgentHistoryLoading] = useState(canViewChat); const [prompt, setPrompt] = useState(""); const [agentStarterConfig, setAgentStarterConfig] = useState(() => defaultAgentStarterConfig(t)); const [agentThreads, setAgentThreads] = useState([]); const [credits, setCredits] = useState(30); const [assistantPanelOpen, setAssistantPanelOpen] = useState(canViewChat); const [agentHistoryOpen, setAgentHistoryOpen] = useState(false); const [agentHistorySearch, setAgentHistorySearch] = useState(""); const [selectedAgentThreadId, setSelectedAgentThreadId] = useState(null); const [assistantFooterHeight, setAssistantFooterHeight] = useState(0); const [showAssistantScrollButton, setShowAssistantScrollButton] = useState(false); 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 [brandKits, setBrandKits] = useState([]); const [brandKitsLoading, setBrandKitsLoading] = useState(false); const [brandKitUpdating, setBrandKitUpdating] = useState(false); 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 [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 [visualAnnotationMarks, setVisualAnnotationMarks] = useState([]); const [visualAnnotationSubmitting, setVisualAnnotationSubmitting] = useState(false); 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 [workspaceCropTargetId, setWorkspaceCropTargetId] = useState(null); const [workspaceCropDraftBounds, setWorkspaceCropDraftBounds] = useState(null); const [workspaceCropDetecting, setWorkspaceCropDetecting] = useState(false); 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 || visualAnnotationSubmitting || nodes.some(isCanvasActionWorkingNode); const agentTimelineActive = agentGenerating || Boolean(project && !projectHasCanvasActionGeneration(project) && nodes.some(isAgentVisibleGeneratingNode)); const mockupPreparing = Boolean(mockupPendingTargetId); const anyGenerating = agentGenerating || imageGeneratorGenerating || canvasActionWorkActive || mockupPreparing; const updateAssistantScrollButton = useCallback(() => { const feed = assistantFeedRef.current; if (!feed || feed.scrollHeight <= feed.clientHeight) { setShowAssistantScrollButton(false); return; } const distanceToEnd = feed.scrollHeight - feed.scrollTop - feed.clientHeight; const shouldShow = distanceToEnd > 24; setShowAssistantScrollButton((current) => (current === shouldShow ? current : shouldShow)); }, []); const scrollAssistantFeedToEnd = useCallback((behavior: ScrollBehavior = "auto") => { window.requestAnimationFrame(() => { const feed = assistantFeedRef.current; if (!feed) return; if (behavior === "auto") { feed.scrollTop = feed.scrollHeight; updateAssistantScrollButton(); return; } feed.scrollTo({ top: feed.scrollHeight, behavior }); window.setTimeout(updateAssistantScrollButton, 220); }); }, [updateAssistantScrollButton]); 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(() => { let active = true; const fallback = defaultAgentStarterConfig(t); setAgentStarterConfig(fallback); designGateway .getAgentStarterConfig(locale) .then((config) => { if (active) setAgentStarterConfig(normalizeAgentStarterConfig(config, fallback)); }) .catch(() => undefined); return () => { active = false; }; }, [locale, t]); useEffect(() => { let active = true; if (!isAuthenticated || !canManageBrandKit) { setBrandKits([]); setBrandKitsLoading(false); return; } setBrandKitsLoading(true); brandKitRepository .list(brandKitStorageScope(user?.id)) .then((kits) => { if (active) setBrandKits(kits); }) .catch(() => { if (!active) return; setBrandKits([]); showToast(t("brandKitLoadError"), "error"); }) .finally(() => { if (active) setBrandKitsLoading(false); }); return () => { active = false; }; }, [canManageBrandKit, isAuthenticated, showToast, t, user?.id]); useEffect(() => { if (!canViewChat) { setAssistantPanelOpen(false); setAgentHistoryOpen(false); } }, [canViewChat]); 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 } = {}) => { const mergedProject = preserveLocalDraftImageGenerators(nextProject, nodesRef.current); applyProjectDocument(mergedProject); if (!options.preserveMessages) { setMessages(visibleAgentMessagesForThread(mergedProject, imageGeneratorThreadIdsRef.current, selectedAgentThreadIdRef.current)); } setEditingTextNodeId(null); }, [applyProjectDocument, nodesRef]); const selectProjectBrandKit = useCallback( async (brandKitId: string | null) => { const currentProject = projectRef.current; if (!currentProject || !canManageBrandKit) return; setBrandKitUpdating(true); try { const { project: updatedProject } = await designGateway.updateProjectBrandKit(currentProject.id, brandKitId ?? ""); projectRef.current = updatedProject; setProject(updatedProject); showToast(t(brandKitId ? "brandKitApplied" : "brandKitCleared"), "success"); } catch { showToast(t("brandKitBindingError"), "error"); } finally { setBrandKitUpdating(false); } }, [canManageBrandKit, projectRef, setProject, showToast, t] ); 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, showGenerationError, messages: { generateError: t("generateError"), generateTimeout: t("generateTimeout") } }); const loadAgentThreads = useCallback(async () => { if (!canViewChat) return; 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); } }, [canViewChat, projectRef]); const { saveCanvasSnapshot, scheduleCanvasSave, updateProjectTitle } = useCanvasPersistence({ project, projectRef, viewport, viewportRef, nodes, nodesRef, dirty, anyGenerating, disabled: !canEdit, setProject, setDirty, setSaving, setError, messages: { saveError: t("saveError"), renameProjectError: t("renameProjectError") } }); useEffect(() => { if (!agentGenerating) { activeAgentThreadIdRef.current = null; } }, [agentGenerating]); useEffect(() => { if (!assistantPanelOpen || !autoScrollAgentFeedRef.current) return; scrollAssistantFeedToEnd("auto"); if (agentTimelineActive) return; const timer = window.setTimeout(() => { autoScrollAgentFeedRef.current = false; }, 250); return () => window.clearTimeout(timer); }, [agentTimelineActive, assistantPanelOpen, messages, scrollAssistantFeedToEnd]); useEffect(() => { if (!assistantPanelOpen) { setShowAssistantScrollButton(false); return; } const frame = window.requestAnimationFrame(updateAssistantScrollButton); return () => window.cancelAnimationFrame(frame); }, [agentTimelineActive, assistantPanelOpen, messages, updateAssistantScrollButton]); useEffect(() => { if (!assistantPanelOpen) return; const footer = assistantFooterRef.current; if (!footer) return; const updateFooterHeight = () => { setAssistantFooterHeight(Math.ceil(footer.getBoundingClientRect().height)); updateAssistantScrollButton(); }; updateFooterHeight(); if (typeof ResizeObserver === "undefined") return; const observer = new ResizeObserver(updateFooterHeight); observer.observe(footer); return () => observer.disconnect(); }, [assistantPanelOpen, error, updateAssistantScrollButton]); useEffect(() => { selectedAgentThreadIdRef.current = null; setSelectedAgentThreadId(null); setAgentHistoryLoading(canViewChat); setAgentThreads([]); setAgentHistorySearch(""); setAgentHistoryOpen(false); autoScrollAgentFeedRef.current = false; }, [canViewChat, projectId]); useEffect(() => { if (agentHistoryOpen) void loadAgentThreads(); }, [agentHistoryOpen, loadAgentThreads]); useEffect(() => { let active = true; setAgentHistoryLoading(canViewChat); designGateway .getProject(projectId) .then(({ project: nextProject }) => { applyProject(nextProject); if (!webSearchHydratedRef.current) { setUseAgentWebSearch(projectHasWebSearchEnabled(nextProject)); webSearchHydratedRef.current = true; } if (isProjectGenerating(nextProject) && canViewChat) { 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 canViewChat ? designGateway.getProjectHistory(nextProject.id) : null; }) .then((history) => { if (history && !agentStreamingProjectIdRef.current) { const currentProject = projectRef.current; if (currentProject) { const projectWithHistory = { ...currentProject, lastThreadId: history.threadId || currentProject.lastThreadId, messages: history.messages }; projectRef.current = projectWithHistory; setProject(projectWithHistory); setMessages(visibleAgentMessagesForThread(projectWithHistory, imageGeneratorThreadIdsRef.current, selectedAgentThreadIdRef.current)); } else { setMessages(visibleAgentMessages(history.messages, imageGeneratorThreadIdsRef.current)); } } }) .catch((err: unknown) => { if (isProjectAccessDenied(err)) { showToast("你没有访问该项目权限", "error"); openHome(); return; } setError(toUserMessageText(err, "generateError")); }) .finally(() => { if (active) setAgentHistoryLoading(false); }); return () => { active = false; }; }, [applyProject, canViewChat, 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 renderOrderedCanvasNodes = useMemo(() => orderedCanvasNodesForRender(nodes), [nodes]); const renderedCanvasNodes = useMemo(() => renderOrderedCanvasNodes.filter((node) => !isBooleanShapeOperandNode(node, nodes) && (!hiddenNodeIds.has(node.id) || selectedIds.has(node.id))), [hiddenNodeIds, nodes, renderOrderedCanvasNodes, selectedIds]); useEffect(() => { const mount = leaferMountRef.current; if (!mount || !project?.id) return; let disposed = false; void createLeaferCanvasRenderer(mount).then((renderer) => { if (disposed) { renderer.destroy(); return; } leaferRendererRef.current = renderer; const viewSize = leaferViewSize(mount, canvasStageSize); renderer.resize(viewSize.width, viewSize.height); renderer.setViewport(viewportRef.current); renderer.sync(renderedCanvasNodes, hiddenNodeIds, canvasWorldRef.current); }); return () => { disposed = true; leaferRendererRef.current?.destroy(); leaferRendererRef.current = null; }; }, [project?.id]); useEffect(() => { leaferRendererRef.current?.sync(renderedCanvasNodes, hiddenNodeIds, canvasWorldRef.current); }, [hiddenNodeIds, renderedCanvasNodes]); useEffect(() => { leaferRendererRef.current?.setViewport(viewport); }, [viewport]); useEffect(() => { leaferRendererRef.current?.resize(canvasStageSize.width, canvasStageSize.height); }, [canvasStageSize.height, canvasStageSize.width]); 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 = canEdit && 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 workspaceCropTarget = useMemo(() => { if (selectedImageNode) return selectedImageNode; const candidates = nodes.filter((node) => { if (!isRealCanvasImageNode(node) || hiddenNodeIds.has(node.id) || lockedNodeIds.has(node.id)) return false; const bounds = canvasNodeScreenBounds(node, viewport); return bounds.left < canvasStageSize.width && bounds.top < canvasStageSize.height && bounds.left + bounds.width > 0 && bounds.top + bounds.height > 0; }); return candidates.length === 1 ? candidates[0] : null; }, [canvasStageSize.height, canvasStageSize.width, hiddenNodeIds, lockedNodeIds, nodes, selectedImageNode, viewport]); const workspaceCropTargetBusy = workspaceCropTarget ? isImageActionBusy(workspaceCropTarget) : false; const selectedImageGeneratorNode = selectedControlNode?.type === "image" && selectedControlNode.layerRole === "image-generator" ? selectedControlNode : null; const selectedImageGeneratorBusy = 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]); useEffect(() => { if (!selectedImageGeneratorNode) return; const targetSize = imageGeneratorTargetSizeFromNode(selectedImageGeneratorNode); setComposerImageSettings((current) => { if (current.width === targetSize.width && current.height === targetSize.height) return current; return { ...current, width: targetSize.width, height: targetSize.height, ratio: "auto" }; }); }, [selectedImageGeneratorNode?.id]); 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 || layersPanelOpen ? canvasSidePanelWidth : 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, selectedImageNode?.semanticContext]); 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(""); setVisualAnnotationMarks([]); setVisualAnnotationSubmitting(false); 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(() => { const pending = pendingWorkspaceCropRef.current; if (!pending || selectedId !== pending.nodeId || selectedIds.size !== 1) return; pendingWorkspaceCropRef.current = null; setCropSelection(pending.selection); setImageActionMode("crop"); }, [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 applyViewportImperatively = useCallback( (nextViewport: CanvasViewport) => { viewportRef.current = nextViewport; if (canvasWorldRef.current) { canvasWorldRef.current.style.transform = `translate(${nextViewport.x}px, ${nextViewport.y}px) scale(${nextViewport.k})`; } leaferRendererRef.current?.setViewport(nextViewport); const grid = containerRef.current?.querySelector(".canvas-grid"); if (grid) { const gridSize = 48 * nextViewport.k; grid.style.backgroundSize = `${gridSize}px ${gridSize}px`; grid.style.backgroundPosition = `${nextViewport.x % gridSize}px ${nextViewport.y % gridSize}px`; } }, [viewportRef] ); const commitImperativeViewport = useCallback(() => { viewportCommitTimerRef.current = null; const nextViewport = { ...viewportRef.current }; containerRef.current?.classList.remove("is-viewport-transforming"); startTransition(() => setViewport(nextViewport)); setDirty(true); }, [setViewport, viewportRef]); const scheduleViewportCommit = useCallback(() => { if (viewportCommitTimerRef.current) window.clearTimeout(viewportCommitTimerRef.current); viewportCommitTimerRef.current = window.setTimeout(commitImperativeViewport, 90); }, [commitImperativeViewport]); useEffect( () => () => { if (viewportCommitTimerRef.current) window.clearTimeout(viewportCommitTimerRef.current); }, [] ); const handleWheel = useCallback((event: WheelEvent) => { if (shouldBypassCanvasWheel(event.target)) { return; } event.preventDefault(); const rect = containerRef.current?.getBoundingClientRect(); if (!rect) return; const currentViewport = viewportRef.current; const factor = Math.pow(1.1, -event.deltaY / 120); const nextScale = clamp(currentViewport.k * factor, minCanvasZoom, maxCanvasZoom); const mouseX = event.clientX - rect.left; const mouseY = event.clientY - rect.top; const worldX = (mouseX - currentViewport.x) / currentViewport.k; const worldY = (mouseY - currentViewport.y) / currentViewport.k; const nextViewport = { x: mouseX - worldX * nextScale, y: mouseY - worldY * nextScale, k: nextScale }; containerRef.current?.classList.add("is-viewport-transforming"); applyViewportImperatively(nextViewport); scheduleViewportCommit(); }, [applyViewportImperatively, scheduleViewportCommit, viewportRef]); useEffect(() => { const stage = containerRef.current; if (!stage) return; return installCanvasWheelListener(stage, handleWheel); }, [handleWheel, project?.id]); 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 }; if (viewportCommitTimerRef.current) window.clearTimeout(viewportCommitTimerRef.current); viewportCommitTimerRef.current = null; applyViewportImperatively(nextViewport); containerRef.current?.classList.remove("is-viewport-transforming"); 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 }; if (viewportCommitTimerRef.current) window.clearTimeout(viewportCommitTimerRef.current); viewportCommitTimerRef.current = null; applyViewportImperatively(nextViewport); containerRef.current?.classList.remove("is-viewport-transforming"); 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 currentViewport = viewportRef.current; const start = { pointerX: event.clientX, pointerY: event.clientY, x: currentViewport.x, y: currentViewport.y }; const handleMove = (moveEvent: PointerEvent) => { applyViewportImperatively({ ...viewportRef.current, x: start.x + moveEvent.clientX - start.pointerX, y: start.y + moveEvent.clientY - start.pointerY }); }; const handleUp = () => { window.removeEventListener("pointermove", handleMove); window.removeEventListener("pointerup", handleUp); window.removeEventListener("pointercancel", handleUp); setCanvasPanning(false); startTransition(() => setViewport({ ...viewportRef.current })); setDirty(true); scheduleCanvasSave(); }; 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 = useCallback((message: string, tone: "success" | "error" = "success") => { showToast(message, tone); }, [showToast]); const attachFrameChildren = (current: CanvasNode[], candidateIds: Set) => attachNodesToContainingFrames(current, candidateIds); const setNodesWithFrameAttachment = (updater: (current: CanvasNode[]) => { nodes: CanvasNode[]; candidateIds: Set }) => { setNodes((current) => { const result = updater(current); const next = attachFrameChildren(result.nodes, result.candidateIds); nodesRef.current = next; return next; }); }; const frameAwareNodeIds = (nodeIds: Iterable) => nodeIdsWithFrameChildren(nodesRef.current, new Set(nodeIds)); const findAnnotationTarget = (x: number, y: number, candidate?: CanvasNode) => { const visualNodes = orderedCanvasNodesForRender(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: "transparent", strokeColor: defaultFrameStrokeColor, strokeWidth: defaultFrameStrokeWidth, strokeStyle: defaultFrameStrokeStyle }; setNodes((current) => { const candidateIds = new Set(current.map((item) => item.id)); const next = attachFrameChildren([...current, node], candidateIds); nodesRef.current = next; return next; }); 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 }); setNodesWithFrameAttachment((current) => ({ nodes: [...current, node], candidateIds: new Set([node.id]) })); 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")); setNodesWithFrameAttachment((current) => ({ nodes: [...current, node], candidateIds: new Set([node.id]) })); 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 finishWorkspaceCropPreselection = (targetNode: CanvasNode, selection: NormalizedRect) => { workspaceCropDetectionRequestRef.current += 1; setWorkspaceCropDetecting(false); setWorkspaceCropDraftBounds(null); setWorkspaceCropTargetId(null); setActiveTool("select"); setMode("select"); if (selectedId !== targetNode.id || selectedIds.size !== 1) { pendingWorkspaceCropRef.current = { nodeId: targetNode.id, selection }; selectOnlyNode(targetNode.id); return; } setCropSelection(selection); setImageActionMode("crop"); }; const startWorkspaceCropPreselection = (event: React.PointerEvent, targetNode: CanvasNode) => { const stageRect = containerRef.current?.getBoundingClientRect(); if (!stageRect) return; event.preventDefault(); const bounds = canvasNodeScreenBounds(targetNode, viewportRef.current); const visibleSelection = visibleCropSelectionForBounds(bounds, { width: stageRect.width, height: stageRect.height }); const startClient = { x: event.clientX, y: event.clientY }; const start = { x: event.clientX - stageRect.left, y: event.clientY - stageRect.top }; let dragged = false; const selectionAt = (clientX: number, clientY: number) => cropSelectionFromScreenDrag(bounds, visibleSelection, start, { x: clientX - stageRect.left, y: clientY - stageRect.top }); const handleMove = (moveEvent: PointerEvent) => { const movement = Math.hypot(moveEvent.clientX - startClient.x, moveEvent.clientY - startClient.y); if (!dragged && movement < 4) return; dragged = true; setWorkspaceCropDraftBounds(cropSelectionScreenBounds(bounds, selectionAt(moveEvent.clientX, moveEvent.clientY))); }; const cleanup = () => { window.removeEventListener("pointermove", handleMove); window.removeEventListener("pointerup", handleUp); window.removeEventListener("pointercancel", handleCancel); setWorkspaceCropDraftBounds(null); }; const handleCancel = () => { cleanup(); }; const handleUp = (upEvent: PointerEvent) => { cleanup(); if (dragged) { finishWorkspaceCropPreselection(targetNode, clampCropSelection(selectionAt(upEvent.clientX, upEvent.clientY))); return; } const point = { x: clamp((start.x - bounds.left) / Math.max(bounds.width, 1), 0, 1), y: clamp((start.y - bounds.top) / Math.max(bounds.height, 1), 0, 1) }; const requestId = workspaceCropDetectionRequestRef.current + 1; workspaceCropDetectionRequestRef.current = requestId; setWorkspaceCropDetecting(true); void detectColorBoundaryCrop(canvasImageUrl(canvasNodeRenderContent(targetNode)), point, visibleSelection) .catch(() => visibleSelection) .then((selection) => { if (workspaceCropDetectionRequestRef.current !== requestId) return; finishWorkspaceCropPreselection(targetNode, clampCropSelection(selection)); }); }; window.addEventListener("pointermove", handleMove); window.addEventListener("pointerup", handleUp); window.addEventListener("pointercancel", handleCancel); }; 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; } if (activeTool === "crop") { event.preventDefault(); return; } const start = { pointerX: event.clientX, pointerY: event.clientY, stageLeft: containerRef.current?.getBoundingClientRect().left ?? 0, stageTop: containerRef.current?.getBoundingClientRect().top ?? 0, x: viewportRef.current.x, y: viewportRef.current.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: viewportRef.current.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 (activeTool === "crop") { if (!workspaceCropDetecting && workspaceCropTargetId === node.id && isRealCanvasImageNode(node) && !lockedNodeIds.has(node.id) && !hiddenNodeIds.has(node.id)) { startWorkspaceCropPreselection(event, node); } 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) && !editingTextNodeId; const frameChild = isFrameChildNode(node, nodesRef.current); const nodeGroupIds = node.parentId && !frameChild ? 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 selectedDragIds = groupDrag ? new Set(useExistingSelection ? selectedIds : nodeGroupIds) : new Set([node.id]); const dragIds = nodeIdsWithFrameChildren(nodesRef.current, selectedDragIds); if (groupDrag) { selectNodes(Array.from(selectedDragIds)); } else { selectOnlyNode(node.id); } if (lockedNodeIds.has(node.id)) return; const dragNodeById = new Map(nodesRef.current.map((item) => [item.id, item])); const movesWithSelectedFrame = (item: CanvasNode) => { const parent = item.parentId ? dragNodeById.get(item.parentId) : null; return Boolean(parent && selectedDragIds.has(parent.id) && isFrameContainerNode(parent)); }; const start = { pointerX: event.clientX, pointerY: event.clientY, positions: nodesRef.current .filter((item) => dragIds.has(item.id) && (!lockedNodeIds.has(item.id) || movesWithSelectedFrame(item))) .map((item) => ({ id: item.id, x: item.x, y: item.y })), scale: viewportRef.current.k }; const draggedElements = new Map( start.positions.map((position) => [position.id, canvasWorldRef.current?.querySelector(`[data-canvas-node-id="${CSS.escape(position.id)}"]`) ?? null]) ); 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) => draggedElements.has(item.id)); const alignmentNodes = movedNodes.filter((item) => selectedDragIds.has(item.id)); nodesRef.current = nextNodes; movedNodes.forEach((item) => { const element = draggedElements.get(item.id); if (element) { element.style.left = `${item.x}px`; element.style.top = `${item.y}px`; } leaferRendererRef.current?.setNodePosition(item.id, item.x, item.y); }); containerRef.current?.classList.add("is-node-dragging"); renderAlignmentGuidesImperatively(imperativeAlignmentGuideRef.current, buildAlignmentGuides(alignmentNodes, nextNodes, viewportRef.current)); }; const handleUp = () => { window.removeEventListener("pointermove", handleMove); window.removeEventListener("pointerup", handleUp); containerRef.current?.classList.remove("is-node-dragging"); renderAlignmentGuidesImperatively(imperativeAlignmentGuideRef.current, []); setAlignmentGuides([]); if (shouldEditTextOnClick && !dragged) { setEditingTextNodeId(node.id); return; } if (dragged) { const nextNodes = attachFrameChildren(nodesRef.current, dragIds); nodesRef.current = nextNodes; startTransition(() => setNodes(nextNodes)); setDirty(true); } if (dragged && Array.from(dragIds).some((nodeId) => nodesRef.current.find((item) => item.id === nodeId)?.layerRole === "mockup-print")) { void refreshMockupPrintTextures(Array.from(dragIds)); return; } scheduleCanvasSave(); }; 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([]); const nextNodes = attachFrameChildren(nodesRef.current, new Set([node.id])); nodesRef.current = nextNodes; setNodes(nextNodes); if (node.layerRole === "mockup-print") { void refreshMockupPrintTextures([node.id]); return; } scheduleCanvasSave(); }; 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 }); const nextNodes = isFrameContainerNode(node) ? resizeFrameWithChildren(nodesRef.current, node.id, resizedNode) : nodesRef.current.map((item) => (item.id === node.id ? resizedNode : item)); nodesRef.current = nextNodes; setNodes(nextNodes); setAlignmentGuides(buildAlignmentGuides([resizedNode], nodesRef.current, viewportRef.current)); setDirty(true); }; const handleUp = () => { window.removeEventListener("pointermove", handleMove); window.removeEventListener("pointerup", handleUp); setAlignmentGuides([]); if (!isFrameContainerNode(node)) { const nextNodes = attachFrameChildren(nodesRef.current, new Set([node.id])); nodesRef.current = nextNodes; setNodes(nextNodes); } scheduleCanvasSave(); }; 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([]); scheduleCanvasSave(); }; 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([]); scheduleCanvasSave(); }; 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 frame = nodesRef.current.find((item) => item.id === nodeId); if (!frame) return; const nextFrame = patchFrameStyleNode(frame, patch); const nextNodes = isFrameContainerNode(frame) ? resizeFrameWithChildren(nodesRef.current, nodeId, nextFrame) : nodesRef.current.map((item) => (item.id === nodeId ? nextFrame : item)); nodesRef.current = nextNodes; setNodes(nextNodes); 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) => { const ids = frameAwareNodeIds([nodeId]); nodesRef.current = removeNodesById(nodesRef.current, ids); setNodes((current) => removeNodesById(current, ids)); setEditingTextNodeId((current) => (current && ids.has(current) ? null : current)); setHiddenNodeIds((current) => { const next = new Set(current); ids.forEach((id) => next.delete(id)); return next; }); setLockedNodeIds((current) => { const next = new Set(current); ids.forEach((id) => next.delete(id)); return next; }); setSelectedIds((current) => { const next = new Set(current); ids.forEach((id) => next.delete(id)); return next; }); if (selectedId && ids.has(selectedId)) 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()); setNodesWithFrameAttachment((current) => ({ nodes: [...current, copy], candidateIds: new Set([copy.id]) })); selectOnlyNode(copy.id); setDirty(true); }; const copyNode = (node: CanvasNode) => { void writeCanvasNodeToSystemClipboard(node) .then(() => showCanvasToast(t("clipboardCopied"))) .catch(() => showCanvasToast(t("clipboardCopyFailed"), "error")); }; const reorderNode = (nodeId: string, action: "forward" | "backward" | "front" | "back") => { setNodes((current) => { const next = reorderNodeOp(current, nodeId, action); nodesRef.current = next; return next; }); setDirty(true); }; const reorderLayer = (request: LayerReorderRequest) => { setNodes((current) => { const next = reorderLayerOp(current, request); nodesRef.current = next; return next; }); setDirty(true); }; const toggleLayerHidden = (nodeId: string) => { const ids = Array.from(frameAwareNodeIds([nodeId])); setHiddenNodeIds((current) => toggleSetValues(current, ids)); setDirty(true); }; const toggleLayerLocked = (nodeId: string) => { const ids = Array.from(frameAwareNodeIds([nodeId])); setLockedNodeIds((current) => toggleSetValues(current, ids)); setDirty(true); }; const toggleLayersHidden = (nodeIds: string[]) => { if (nodeIds.length === 0) return; setHiddenNodeIds((current) => toggleSetValues(current, Array.from(frameAwareNodeIds(nodeIds)))); setDirty(true); }; const toggleLayersLocked = (nodeIds: string[]) => { if (nodeIds.length === 0) return; setLockedNodeIds((current) => toggleSetValues(current, Array.from(frameAwareNodeIds(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 = frameAwareNodeIds(selectedNodes.filter((node) => !lockedNodeIds.has(node.id) && !hiddenNodeIds.has(node.id)).map((node) => node.id)); if (ids.size === 0) return; nodesRef.current = removeNodesById(nodesRef.current, ids); 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 clipboardPasteAnchor = () => { const pointer = lastCanvasPointerRef.current; return pointer ? canvasPointFromClient(pointer.clientX, pointer.clientY) : canvasCenterPoint(); }; const pasteClipboardText = (value: string, anchor = clipboardPasteAnchor()) => { const content = value.replace(/\u00a0/g, " "); if (!content.trim()) return false; const currentViewport = viewportRef.current; const zoom = Math.max(currentViewport.k, 0.01); const size = { width: defaultTextVisibleBox.width / zoom, height: defaultTextVisibleBox.height / zoom }; const node = fitTextNodeToContent({ id: cryptoFallbackId(), type: "text", title: t("newTextNode"), x: anchor.x - size.width / 2, y: anchor.y - size.height / 2, width: size.width, height: size.height, content, tone: "ink", status: "draft", layerRole: "editable-text", fontFamily: "Inter", fontWeight: "Regular", fontSize: defaultTextVisibleFontSize / zoom, color: "#000000", textAlign: "left", opacity: 1, strokeColor: "#000000", strokeWidth: 0, lineHeight: 0, letterSpacing: 0, textDecoration: "none", textTransform: "none" }); setNodesWithFrameAttachment((current) => ({ nodes: [...current, node], candidateIds: new Set([node.id]) })); selectOnlyNode(node.id); setDirty(true); return true; }; const pasteFromClipboardData = (dataTransfer: DataTransfer) => { const anchor = clipboardPasteAnchor(); const imageFile = latestDataTransferImageFile(dataTransfer, "clipboard-image"); if (imageFile) { void uploadImagesToCanvas([imageFile], anchor); return true; } return pasteClipboardText(dataTransfer.getData("text/plain"), anchor); }; const pasteFromSystemClipboard = async () => { const anchor = clipboardPasteAnchor(); const imageFile = await latestNavigatorClipboardImageFile("clipboard-image").catch(() => null); if (imageFile) { await uploadImagesToCanvas([imageFile], anchor); return; } const text = await navigator.clipboard?.readText?.().catch(() => ""); if (text) pasteClipboardText(text, anchor); }; 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: () => void pasteFromSystemClipboard(), 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", "psd"], exportAs: (format) => exportCanvasNode(node, format, { nodes: nodesRef.current, hiddenNodeIds }), deleteNode: () => removeNode(node.id) }); useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { if (isEditableShortcutTarget(event.target)) return; if (!canEdit) 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 selectedCanDelete = selected && !lockedNodeIds.has(selected.id) && !hiddenNodeIds.has(selected.id); 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 || activeTool === "crop")) { return void run(() => { chooseCanvasTool("select"); 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 ?? (selectedCanDelete ? () => removeNode(selected.id) : undefined)); }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }); useEffect(() => { const handlePaste = (event: ClipboardEvent) => { if (isEditableShortcutTarget(event.target)) return; if (!canEdit) return; if (!event.clipboardData) return; if (pasteFromClipboardData(event.clipboardData)) { event.preventDefault(); } }; 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" }; setNodesWithFrameAttachment((current) => ({ nodes: [...current, node], candidateIds: new Set([node.id]) })); 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 position = point ? { x: origin.x - size.width / 2, y: origin.y - size.height / 2 } : nextImageGeneratorPosition(origin, size, nodesRef.current, viewport.k); const node: CanvasNode = { id: cryptoFallbackId(), type: "image", title: t("imageGeneratorNode"), x: position.x, y: position.y, width: size.width, height: size.height, content: "", tone: "image-generator", status: "draft", layerRole: "image-generator", imageAdjustments: serializeImageGeneratorTargetSize(composerImageSettings) }; setComposerMode("image"); setActiveTool("select"); setMode("select"); setNodesWithFrameAttachment((current) => ({ nodes: [...current, node], candidateIds: new Set([node.id]) })); 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, imageAdjustments: serializeImageGeneratorTargetSize(settings, node.imageAdjustments) }; }); 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" }; setNodesWithFrameAttachment((current) => ({ nodes: [...current, node], candidateIds: new Set([node.id]) })); selectOnlyNode(node.id); setEditingTextNodeId(node.id); setDirty(true); }; const chooseCanvasTool = (tool: CanvasTool) => { if (!canEdit) return; if (tool !== "crop") { workspaceCropDetectionRequestRef.current += 1; setWorkspaceCropDetecting(false); setWorkspaceCropDraftBounds(null); setWorkspaceCropTargetId(null); } 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; } if (tool === "crop") { setMode("select"); return; } setMode("select"); }; const chooseShapeTool = (kind: ShapeKind) => { setSelectedShapeKind(kind); setShapeToolMenuOpen(false); chooseCanvasTool("shape"); }; 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 = () => { autoScrollAgentFeedRef.current = false; selectedAgentThreadIdRef.current = newAgentConversationId; setSelectedAgentThreadId(newAgentConversationId); setAgentHistoryLoading(false); setAgentHistoryOpen(false); setPrompt(""); setMessages([]); setError(""); agentComposerRef.current?.reset(""); activeAgentThreadIdRef.current = null; }; const selectAgentStarterPrompt = (nextPrompt: string) => { if (!canEdit) return; setComposerMode("agent"); setPrompt(nextPrompt); window.requestAnimationFrame(() => agentComposerRef.current?.focus()); }; const selectAgentThread = (threadId: string) => { autoScrollAgentFeedRef.current = false; selectedAgentThreadIdRef.current = threadId; setSelectedAgentThreadId(threadId); setAgentHistoryOpen(false); const currentProject = projectRef.current; if (currentProject) { setAgentHistoryLoading(true); setMessages(visibleAgentMessagesForThread(currentProject, imageGeneratorThreadIdsRef.current, threadId)); void designGateway .getProjectHistory(currentProject.id, threadId) .then((history) => { if (selectedAgentThreadIdRef.current !== threadId) return; const nextProject = { ...currentProject, lastThreadId: history.threadId || currentProject.lastThreadId, messages: history.messages }; projectRef.current = nextProject; setProject(nextProject); setMessages(visibleAgentMessagesForThread(nextProject, imageGeneratorThreadIdsRef.current, threadId)); }) .catch((err) => { console.warn("Failed to load agent thread history", err); }) .finally(() => { if (selectedAgentThreadIdRef.current === threadId) setAgentHistoryLoading(false); }); } }; 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" && !isHiddenTextSource(content.textSource) && content.text.replace(/\u00a0/g, " ").trim()); const contents = activeMode === "image" ? withImageGeneratorTarget(baseContents, selectedGeneratorNode?.id) : baseContents; const visibleContents = activeMode === "image" ? baseContents : contents; 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", imageAdjustments: serializeImageGeneratorTargetSize(composerImageSettings, node.imageAdjustments) } : node ); nodesRef.current = next; return next; }); } } else { activeAgentThreadIdRef.current = threadId; selectedAgentThreadIdRef.current = threadId; setSelectedAgentThreadId(threadId); setAgentGenerating(true); autoScrollAgentFeedRef.current = true; } setError(""); if (source === "image-generator") { imageGeneratorThreadIdsRef.current.add(threadId); } else { setMessages((current) => [...current, ...createOptimisticAgentMessages(threadId, visibleContents)]); } try { if (dirty) { await saveCanvasSnapshot({ quiet: true }); } const modelContents = await prepareModelAgentContents(contents); startAgentSocket( project.id, { messages: [{ role: "user", contents: modelContents }], mode: source === "image-generator" ? "image-generator" : activeMode === "image" ? "image" : "design", threadId, threadIdType: 9, imageModel: toImageApiModel(selectedCanvasModel.id), imageSize: activeMode === "image" ? composerImageSize(composerImageSettings) : undefined, imageQuality: activeMode === "image" ? composerImageSettings.quality : undefined, imageCount: activeMode === "image" ? composerImageSettings.count : 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; autoScrollAgentFeedRef.current = false; 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); setVisualAnnotationMarks([]); 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) || 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) || 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); scheduleCanvasSave(); }; 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) { scheduleCanvasSave(); return; } nodesRef.current = nextNodes; setNodes(nextNodes); setDirty(true); scheduleCanvasSave(); }; 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 applyVisualAnnotation = async () => { const targetNode = selectedImageNode; if (!targetNode || visualAnnotationSubmitting || visualAnnotationMarks.length === 0 || visualAnnotationMarks.some((mark) => !mark.instruction.trim())) return; setVisualAnnotationSubmitting(true); setError(""); try { const source = await imageNodeRasterBlob(targetNode, "image/png"); const guide = await renderVisualAnnotationGuide(source, visualAnnotationMarks); const guideFile = new File([guide], `visual-annotation-${Date.now()}.png`, { type: "image/png" }); const annotationImage = await designGateway.uploadImage(guideFile); const instructions = visualAnnotationMarks .map((mark) => `${mark.index}. ${visualAnnotationToolLabel(mark.tool, t)}: ${mark.instruction.trim()}`) .join("\n"); await runImageNodeAction("visual-annotation", { prompt: t("visualAnnotationPrompt", { instructions }), annotationImage, selection: JSON.stringify(visualAnnotationPayload(visualAnnotationMarks)) }); } catch (err) { setError(toUserMessageText(err, "generateError")); } finally { setVisualAnnotationSubmitting(false); } }; 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) || 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 openCropForNode = (targetNode: CanvasNode) => { if (isImageActionBusy(targetNode)) return; chooseCanvasTool("select"); const selection = visibleCropSelectionForBounds(canvasNodeScreenBounds(targetNode, viewportRef.current), canvasStageSize); if (selectedId !== targetNode.id || selectedIds.size !== 1) { pendingWorkspaceCropRef.current = { nodeId: targetNode.id, selection }; selectOnlyNode(targetNode.id); return; } setCropSelection(selection); setImageActionMode("crop"); }; const openWorkspaceCrop = () => { if (!workspaceCropTarget || workspaceCropTargetBusy) return; if (activeTool === "crop" && workspaceCropTargetId === workspaceCropTarget.id) { chooseCanvasTool("select"); return; } setImageActionMode(null); setCropSelection(null); setCropPrompt(""); setWorkspaceCropTargetId(workspaceCropTarget.id); chooseCanvasTool("crop"); if (selectedId !== workspaceCropTarget.id || selectedIds.size !== 1) selectOnlyNode(workspaceCropTarget.id); }; 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 preserveSource = isVectorImageNode(targetNode); const croppedNode: CanvasNode | null = preserveSource ? createVectorCropSiblingNode(targetNode, { id: cryptoFallbackId(), title: t("cropResultTitle", { title: targetNode.title || t("imageNode") }), publicUrl, cropWidth: cropped.displayWidth, cropHeight: cropped.displayHeight, semanticContext: cropped.semanticContext }) : null; const nextNodes = croppedNode ? [...nodesRef.current, croppedNode] : 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, [], currentProject); applyProject(savedProject); if (croppedNode) { selectOnlyNode(croppedNode.id); showCanvasToast(t("cropCreated")); } 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 importFiles = files.filter(isCanvasImportFile); if (!currentProject || importFiles.length === 0) return; setSaving(true); setError(""); const preparedUploads: Array<{ asset: PreparedCanvasImportAsset; sourcePreviewUrl: string; renderPreviewUrl?: string; node: CanvasNode }> = []; try { const imageAssets: PreparedCanvasImportAsset[] = []; let cadImportCount = 0; let cadImportError: unknown = null; if (importFiles.some(isCadFile)) showCanvasToast(t("cadImportConverting")); for (const file of importFiles) { try { imageAssets.push(await prepareCanvasImportAsset(file)); if (isCadFile(file)) cadImportCount += 1; } catch (error) { cadImportError ??= error; } } if (cadImportError) { const message = t(cadImportMessageKey(cadImportError)); setError(message); showCanvasToast(message, "error"); } if (imageAssets.length === 0) return; for (const [index, asset] of imageAssets.entries()) { const { file, renderFile, semanticContext } = asset; const sourcePreviewUrl = URL.createObjectURL(file); const renderPreviewUrl = renderFile ? URL.createObjectURL(renderFile) : undefined; const dimensions = await readImageDimensions(file).catch(() => ({ width: 512, height: 512 })); const size = originalCanvasImageSize(dimensions.width, dimensions.height); const tone = imageMaySupportTransparency(file) ? "transparent-image" : "natural"; preparedUploads.push({ asset, sourcePreviewUrl, renderPreviewUrl, 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: sourcePreviewUrl, renderContent: renderPreviewUrl, tone, layerRole: semanticContext ? "cad-vector" : isSvgSource(file.name) || file.type.toLowerCase().includes("svg") ? "vector-image" : undefined, semanticContext, status: "uploading" } }); } const optimisticNodes = preparedUploads.map((item) => item.node); const nextNodesWithUploads = attachFrameChildren([...nodesRef.current, ...optimisticNodes], new Set(optimisticNodes.map((node) => node.id))); nodesRef.current = nextNodesWithUploads; setNodes(nextNodesWithUploads); selectNodes(optimisticNodes.map((node) => node.id)); setDirty(true); const uploadedResults = await Promise.allSettled( preparedUploads.map(async ({ asset, node }) => ({ id: node.id, uploaded: await uploadPreparedCanvasImportAsset(asset, designGateway.uploadImage, designGateway.deleteAsset) })) ); 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.uploaded); else failedIds.add(nodeId); }); const finalUploadedNodeIds = new Set(uploadedById.keys()); const finalNodes = nodesRef.current .filter((node) => !failedIds.has(node.id)) .map((node) => { const uploaded = uploadedById.get(node.id); return uploaded ? { ...node, content: uploaded.content, renderContent: uploaded.renderContent, status: "success" } : node; }); nodesRef.current = finalNodes; setNodes(finalNodes); selectNodes(finalNodes.filter((node) => finalUploadedNodeIds.has(node.id)).map((node) => node.id)); preparedUploads.forEach(revokePreparedUploadUrls); if (failedIds.size > 0) { setError(t("generateError")); } const { project: savedProject } = await designGateway.saveCanvas(currentProject.id, viewportRef.current, finalNodes, [], currentProject); applyProject(savedProject); setDirty(false); if (failedIds.size === 0 && finalUploadedNodeIds.size > 0) { showCanvasToast(cadImportCount > 0 ? t("cadImportSuccess", { count: cadImportCount }) : t("imageUploadSuccess")); } } catch (err) { preparedUploads.forEach(revokePreparedUploadUrls); 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 handleCanvasAssetInput = (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 (!dataTransferHasCanvasFile(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 = dataTransferCanvasFiles(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")}
); } const displayProjectTitle = localizedCanvasTitle(project.title, t("canvasUntitled")); return (
0 1 : 1 7 : 1 4 : 5 3
{t("promo")} {t("upgradeNow")}
{canManageBrandKit && ( )}
{canViewChat ? ( assistantPanelOpen ? ( ) : ( setAssistantPanelOpen(true)} /> ) ) : ( )} {!canViewChat && (
)} {layersPanelOpen && ( setLayersPanelOpen(false)} /> )} {generatedFilesPanelOpen && ( exportImageNode(node, isVectorImageNode(node) ? "svg" : "png")} onClose={() => setGeneratedFilesPanelOpen(false)} /> )} {showGrid && }
); } function CanvasNodeView({ node, selected, mockupDragging, editing, showResizeHandles, hidden, locked, mockupClipPath, transform, selectionScale, viewportScale, imageGeneratorTargetSize, 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; imageGeneratorTargetSize?: ImageGeneratorTargetSize; 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 imageGenerator = node.type === "image" && node.layerRole === "image-generator"; 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 visibleSelectionSize = imageGenerator && imageGeneratorTargetSize ? imageGeneratorTargetSize : { width: node.width, height: node.height }; const selectionSize = `${Math.round(visibleSelectionSize.width)} × ${Math.round(visibleSelectionSize.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 ? (