Files
moteva/frontend/src/ui/pages/CanvasWorkspace/index.tsx
T
2026-07-08 01:23:57 +08:00

5376 lines
218 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { BookOpen, ChevronDown, Circle, Folder, Frame, Globe2, Image as ImageIcon, Layers, Lock, LogOut, Map as MapIcon, Loader2, MessageCircle, MessageSquarePlus, PanelRightClose, PenLine, Search, Share2, Trash2, Type, User, Zap } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
import { openHome } from "@/application/route";
import { ContextMenu, ContextMenuTrigger } from "@/components/ui/context-menu";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import type { AgentContent, AgentMessage, AgentThreadSummary, CanvasNode, CanvasViewport, ExtractedTextLayer, ExtractNodeTextResponse, MockupModel, MockupModelMap, MockupPlacement, MockupSurface, NodeActionRequest, Project } from "@/domain/design";
import { useI18n, type Locale } from "@/i18n/i18n";
import { designGateway } from "@/infrastructure/designGateway";
import { useAuth } from "@/ui/auth/AuthProvider";
import { AccountManagementDialog } from "@/ui/components/AccountManagementDialog";
import { ArtboardPreview } from "@/ui/components/ArtboardPreview";
import { CanvasAnnotationPreview } from "@/ui/components/CanvasAnnotationPreview";
import { CanvasAnnotations } from "@/ui/components/CanvasAnnotations";
import { CanvasAgentComposer, composerImageSize, defaultComposerImageSettings, type ComposerImageSettings, type ComposerMode } from "@/ui/components/CanvasAgentComposer";
import { CanvasAgentTimeline } from "@/ui/components/CanvasAgentTimeline";
import { CanvasGeneratedFilesPanel, CanvasGrid, CanvasLayerPanel } from "@/ui/components/CanvasPanels";
import { CanvasMinimap } from "@/ui/components/CanvasMinimap";
import { CanvasNodeContextMenu, type NodeContextMenuHandlers } from "@/ui/components/CanvasNodeContextMenu";
import { useGlobalToast } from "@/ui/components/CanvasToast";
import { CanvasZoomMenu } from "@/ui/components/CanvasZoomMenu";
import { useUserMessage } from "@/ui/hooks/useUserMessage";
import { ImageGeneratorFloatingComposer } from "@/ui/components/ImageGeneratorFloatingComposer";
import { AdjustPanel, CropOverlay, defaultToolbarVisibility, EraserOverlay, ExpandPanel, FlipRotatePanel, ImageNodeToolbar, MockupDropOverlay, MoveObjectOverlay, MultiAnglePanel, QuickEditPanel, VectorizedImageToolbar, type AdjustOptions, type ExpandOptions, type ImageToolbarVisibility, type MaskPoint, type MoveObjectSelection, type MultiAngleOptions, type NormalizedRect } from "@/ui/components/ImageNodeActions";
import { ImageTextEditPanel, type EditableTextLayer } from "@/ui/components/ImageTextEditPanel";
import { MultiSelectionOverlay, type MultiSelectionResizeHandle } from "@/ui/components/MultiSelectionOverlay";
import { TextStyleToolbar, type TextStylePatch } from "@/ui/components/TextStyleToolbar";
import { loadMotevaFontCatalog } from "@/ui/components/TextStyleToolbar/fontCatalog";
import { WorkspaceTitle } from "@/ui/components/WorkspaceTitle";
import { autoImageAdjustments, defaultImageAdjustments, parseImageAdjustments, serializeImageAdjustments } from "@/ui/lib/imageAdjustments";
import { canvasImageUrl } from "@/ui/lib/imageDelivery";
import { isImageGeneratorThreadMessage, visibleAgentMessages } from "./canvas/agentMessageFilters";
import { CanvasColorPopover } from "./canvas/CanvasColorPopover";
import { buildAlignmentGuides, clamp, minGroupNodeScale, nodeBounds, nodeIntersectsRect, normalizeScreenRect, pointInsideNode, scaleGroupNode, screenBoundsForNodes, screenRectToWorldRect, type AlignmentGuide, type NodeAlignment, type NodeDistribution, type NodeTransform, type ResizeHandle, type SelectionBox } from "@/ui/pages/CanvasWorkspace/canvas/canvasGeometry";
import { alignNodes, autoLayoutAroundNode as autoLayoutAroundNodeOp, autoLayoutNodes, canResizeCanvasNode, canUseNodeContextMenu, cloneCanvasNode, distributeNodes, groupNodes, isCanvasPenStrokeNode, isRealCanvasImageNode, moveNodesByDelta, patchFrameStyleNode, removeNodeById, removeNodesById, reorderNode as reorderNodeOp, resizeVisualNodeByHandle, toggleSetValue, toggleSetValues, ungroupNodes, updateNodeContent as updateNodeContentOp, updateNodeTransform as updateNodeTransformOp, visibleCanvasNodes as getVisibleCanvasNodes } from "@/ui/pages/CanvasWorkspace/canvas/canvasNodeOps";
import { CanvasSelectionFrame } from "@/ui/pages/CanvasWorkspace/canvas/CanvasSelectionFrame";
import { beginFrameDrawing, FrameDraftOverlay, type FrameDraft, type FrameWorldRect } from "./canvas/FrameToolOverlay";
import { FrameStyleToolbar } from "./canvas/FrameStyleToolbar";
import { defaultFrameFillColor, defaultFrameStrokeColor, defaultFrameStrokeStyle, defaultFrameStrokeWidth, normalizeHexColor } from "./canvas/frameStyle";
import { generatorTaskPollIntervalMs } from "@/ui/pages/CanvasWorkspace/canvas/generatorTaskPolling";
import { useCanvasDocument } from "./canvas/hooks/useCanvasDocument";
import { useCanvasPersistence } from "./canvas/hooks/useCanvasPersistence";
import { useCanvasSelection } from "./canvas/hooks/useCanvasSelection";
import { useGenerationStream, type GenerationChannel } from "./canvas/hooks/useGenerationStream";
import { imageGeneratorPlaceholderSize } from "./canvas/imageGeneratorSizing";
import { canvasNodeToReferenceImage, isAssetReferencedByCanvas, isCanvasNodeReferenceImage } from "./canvas/nodeReferenceImages";
import { canvasNodeSvg, exportCanvasNode, exportImageNode, exportTextNode, imageNodeRasterBlob, svgToRasterBlob } from "./canvas/nodeExport";
import { beginPenStroke, createPenStrokeNode, PenStrokeDraftOverlay, PenToolControls, type PenDraft, type PenSettings } from "@/ui/pages/CanvasWorkspace/canvas/PenTool";
import { PenStrokeToolbar, updatePenStrokeNode, type PenStrokePatch } from "./canvas/PenStrokeToolbar";
import { ShapeNodePreview } from "@/ui/pages/CanvasWorkspace/canvas/ShapeNodePreview";
import { ShapeStyleToolbar } from "@/ui/pages/CanvasWorkspace/canvas/ShapeStyleToolbar";
import { beginShapeDrawing, ShapeDraftOverlay, type ShapeDraft } from "@/ui/pages/CanvasWorkspace/canvas/ShapeToolOverlay";
import {
createBooleanShapeGroupNode,
createShapeNode,
isBooleanCapableShapeNode,
isBooleanShapeGroupNode,
isBooleanShapeOperandNode,
isLineShapeNode,
isShapeNode,
patchShapeStyleNode,
resizeLineShapeEndpoint,
shapeTitle,
updateBooleanShapeOperation,
type BooleanShapeOperation,
type ShapeEndpointKey,
type ShapeKind,
type ShapeStylePatch,
type ShapeWorldDraft
} from "@/ui/pages/CanvasWorkspace/canvas/shapeNode";
import { defaultTextVisibleBox, defaultTextVisibleFontSize } from "./canvas/textNodeDefaults";
import { WorkspaceToolbar } from "./canvas/WorkspaceToolbar";
import type { CanvasTool, FrameStylePatch, NodeScreenBounds } from "./canvas/types";
import { revokeReferenceImage, revokeReferenceImages, type AnnotationPreviewRequest, type CanvasAnnotation, type PromptComposerHandle, type UploadedReferenceImage } from "@/ui/components/PromptComposer";
const minCanvasZoom = 0.1;
const maxCanvasZoom = 2.8;
const defaultCanvasBackgroundColor = "#F7F7F4";
const canvasBackgroundSwatches = ["#F5F5F5", "#000000", "#FFFFFF", "#00F016", "#A855F7", "#E9D5FF"];
const generatedFilesPanelWidth = 408;
const layersPanelWidth = 274;
const newAgentConversationId = "__new_agent_conversation__";
const canvasWheelGuardSelector = [
".ui-popover-content",
".ui-dropdown-menu-content",
".ui-context-menu-content",
".canvas-layer-panel",
".canvas-generated-files-panel",
".image-action-panel",
".image-node-toolbar",
".vector-result-toolbar",
".text-style-toolbar",
".workspace-toolbar",
".canvas-statusbar",
".canvas-minimap",
".account-management-backdrop",
".account-management-dialog"
].join(",");
type LocalizedText = Record<Locale, string>;
type HomeImageModel = {
id: string;
label: string;
description: string;
duration: string;
credits: number;
};
type ImageActionMode = "quick-edit" | "eraser" | "edit-text" | "multi-angle" | "move-object" | "mockup" | "adjust" | "expand" | "crop" | "flip-rotate" | null;
const defaultCropSelection: NormalizedRect = { x: 0, y: 0, width: 1, height: 1 };
const imageModelBlueprints = [
{
id: "gpt-image-2-auto",
label: { "zh-CN": "GPT Image 2", "en-US": "GPT Image 2" },
description: {
"zh-CN": "根据提示词自动调整质量,成本可能变化。",
"en-US": "Automatically adjusts quality per prompt. Costs may vary."
},
duration: "30s",
credits: 15
},
{
id: "gpt-image-2-low",
label: { "zh-CN": "GPT Image 2 Low", "en-US": "GPT Image 2 Low" },
description: {
"zh-CN": "速度快、成本低,适合多数探索任务。",
"en-US": "Fast, affordable, great for most tasks."
},
duration: "30s",
credits: 8
},
{
id: "gpt-image-2-medium",
label: { "zh-CN": "GPT Image 2 Medium", "en-US": "GPT Image 2 Medium" },
description: {
"zh-CN": "更多细节,中等成本,适合精修视觉。",
"en-US": "More detail, moderate cost."
},
duration: "30s",
credits: 12
},
{
id: "nano-banana-pro",
label: { "zh-CN": "Nano Banana Pro", "en-US": "Nano Banana Pro" },
description: {
"zh-CN": "适合快速生成商品图、品牌视觉和社媒图。",
"en-US": "Good for fast product visuals, brand images, and social posts."
},
duration: "20s",
credits: 20
},
{
id: "nano-banana-2",
label: { "zh-CN": "Nano Banana 2", "en-US": "Nano Banana 2" },
description: {
"zh-CN": "平衡速度和质量,适合日常创意探索。",
"en-US": "Balanced speed and quality for everyday exploration."
},
duration: "20s",
credits: 14
},
{
id: "nano-banana-2-lite",
label: { "zh-CN": "Nano Banana 2 Lite", "en-US": "Nano Banana 2 Lite" },
description: {
"zh-CN": "轻量低成本,适合草稿与快速迭代。",
"en-US": "Lightweight and lower cost for drafts and quick iteration."
},
duration: "15s",
credits: 8
},
{
id: "gpt-image-1.5",
label: { "zh-CN": "GPT Image 1.5", "en-US": "GPT Image 1.5" },
description: {
"zh-CN": "经典图片模型,适合成本敏感的基础生图。",
"en-US": "Classic image model for cost-sensitive generation."
},
duration: "30s",
credits: 10
},
{
id: "luma-uni-1",
label: { "zh-CN": "Luma Uni-1", "en-US": "Luma Uni-1" },
description: {
"zh-CN": "适合风格化视觉与创意氛围图。",
"en-US": "Good for stylized creative visuals."
},
duration: "25s",
credits: 18
},
{
id: "luma-uni-1-max",
label: { "zh-CN": "Luma Uni-1 Max", "en-US": "Luma Uni-1 Max" },
description: {
"zh-CN": "高质量版本,适合关键视觉和精修输出。",
"en-US": "Higher quality for key visuals and polished output."
},
duration: "35s",
credits: 30
},
{
id: "flux-2-pro",
label: { "zh-CN": "Flux 2 Pro", "en-US": "Flux 2 Pro" },
description: {
"zh-CN": "适合产品、字体感和商业海报探索。",
"en-US": "Good for products, typography-heavy visuals, and commercial posters."
},
duration: "30s",
credits: 16
}
] satisfies Array<{
id: string;
label: LocalizedText;
description: LocalizedText;
duration: string;
credits: number;
}>;
function getImageModels(locale: Locale): HomeImageModel[] {
return imageModelBlueprints.map((model) => ({
id: model.id,
label: model.label[locale],
description: model.description[locale],
duration: model.duration,
credits: model.credits
}));
}
function toImageApiModel(modelId: string) {
return modelId.startsWith("gpt-image-2") ? "gpt-image-2" : modelId;
}
function defaultImageSize() {
return "1024x1024";
}
function backgroundRemovalOptions(node: CanvasNode): Record<string, string> {
const width = Math.max(1, Math.round(node.width));
const height = Math.max(1, Math.round(node.height));
return {
imageWidth: String(width),
imageHeight: String(height),
inputSize: String(backgroundRemovalInputSize(width, height))
};
}
function backgroundRemovalInputSize(width: number, height: number) {
const maxSide = Math.max(width, height, 1);
const rounded = Math.ceil(maxSide / 32) * 32;
return Math.min(Math.max(rounded, 256), 2048);
}
function shouldBypassCanvasWheel(target: EventTarget | null) {
return target instanceof Element && Boolean(target.closest(canvasWheelGuardSelector));
}
function expandTargetGeometryForNode(node: CanvasNode, options: ExpandOptions) {
const ratio = expandRatioValue(options.ratio, node.width / Math.max(node.height, 1));
const sourceRatio = node.width / Math.max(node.height, 1);
const scale = normalizeExpandScale(options.scale);
let width = node.width;
let height = node.height;
if (ratio > sourceRatio) {
width = node.height * ratio;
} else {
height = node.width / Math.max(ratio, 0.01);
}
width *= scale;
height *= scale;
const imageRect = normalizeExpandSourceRect(options.imageRect, node, width, height);
return {
x: node.x - imageRect.x * width,
y: node.y - imageRect.y * height,
width,
height,
imageRect
};
}
function normalizeExpandSourceRect(rect: NormalizedRect | undefined, node: CanvasNode, targetWidth: number, targetHeight: number): NormalizedRect {
const defaultRect = {
x: (1 - node.width / Math.max(targetWidth, 1)) / 2,
y: (1 - node.height / Math.max(targetHeight, 1)) / 2,
width: node.width / Math.max(targetWidth, 1),
height: node.height / Math.max(targetHeight, 1)
};
if (!rect) return clampExpandRect(defaultRect);
return clampExpandRect({ ...rect, width: defaultRect.width, height: defaultRect.height });
}
function clampExpandRect(rect: NormalizedRect): NormalizedRect {
const width = clamp(rect.width, 0.02, 1);
const height = clamp(rect.height, 0.02, 1);
return {
x: clamp(rect.x, 0, Math.max(0, 1 - width)),
y: clamp(rect.y, 0, Math.max(0, 1 - height)),
width,
height
};
}
function expandRatioValue(ratio: ExpandOptions["ratio"], fallbackRatio: number) {
switch (ratio) {
case "1:1":
return 1;
case "3:4":
return 3 / 4;
case "2:3":
return 2 / 3;
case "9:16":
return 9 / 16;
case "4:3":
return 4 / 3;
case "3:2":
return 3 / 2;
case "16:9":
return 16 / 9;
case "4:5":
return 4 / 5;
case "5:4":
return 5 / 4;
default:
return Math.max(0.01, fallbackRatio);
}
}
function normalizeExpandScale(scale: number) {
if (!Number.isFinite(scale)) return 1;
return clamp(scale, 1, 2);
}
function expandImageSize(ratio: ExpandOptions["ratio"], fallbackRatio: number) {
const fixedSizes: Partial<Record<ExpandOptions["ratio"], string>> = {
"1:1": "1024x1024",
"3:4": "1024x1365",
"2:3": "1024x1536",
"9:16": "1152x2048",
"4:3": "1365x1024",
"3:2": "1536x1024",
"16:9": "2048x1152",
"4:5": "1024x1280",
"5:4": "1280x1024"
};
if (ratio !== "original" && fixedSizes[ratio]) return fixedSizes[ratio];
const safeRatio = Math.max(0.01, fallbackRatio);
if (safeRatio >= 1) return `${Math.round(1024 * safeRatio)}x1024`;
return `1024x${Math.round(1024 / safeRatio)}`;
}
function withImageModeSettings(
contents: AgentContent[],
settings: ComposerImageSettings,
t: (key: string, values?: Record<string, string | number>) => string,
targetNodeId?: string
) {
return [
...contents,
{
type: "text" as const,
text: t("imageModeDirective", {
quality: t(`quality${settings.quality === "auto" ? "Auto" : settings.quality === "high" ? "High" : settings.quality === "medium" ? "Medium" : "Low"}`),
ratio: settings.ratio,
width: settings.width,
height: settings.height,
count: settings.count
}),
textSource: "settings"
},
...(targetNodeId
? [
{
type: "text" as const,
text: `Image generator target node: ${targetNodeId}`,
textSource: "settings"
}
]
: [])
];
}
function CanvasAccountControls({ credits, collapsed = false, onOpenConversation }: { credits: number; collapsed?: boolean; onOpenConversation?: () => void }) {
const { t, locale, toggleLocale } = useI18n();
const { isAuthenticated, user, openLogin, logout, updateUser } = useAuth();
const accountRef = useRef<HTMLDivElement | null>(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 (
<div className={`canvas-account-controls ${collapsed ? "canvas-collapsed-controls" : "canvas-open-account-controls"}`}>
<span className="canvas-collapsed-credit" aria-label={t("credits", { count: credits })}>
<Zap size={14} />
<span>{credits}</span>
</span>
<div className="canvas-account-wrap" ref={accountRef}>
<button
className="canvas-account-avatar"
type="button"
onClick={openAccountMenu}
aria-label={avatarLabel}
title={avatarLabel}
aria-expanded={accountOpen}
aria-haspopup="menu"
>
{displayAvatar ? <img alt="" src={displayAvatar} /> : initials}
</button>
{isAuthenticated && accountOpen && (
<div className="canvas-account-menu" role="menu" data-testid="avatar-popover-content">
<div className="canvas-account-menu-profile">
<span className="canvas-account-menu-avatar">{displayAvatar ? <img alt="" src={displayAvatar} /> : initials}</span>
<span>
<strong>{displayName}</strong>
{displayEmail && <small>{displayEmail}</small>}
</span>
</div>
<div className="canvas-account-menu-separator" />
<div className="canvas-account-menu-plan">
<button type="button">
<span>{t("freePlan")}</span>
<b>
<Zap size={14} />
{credits}
</b>
</button>
<button className="canvas-account-menu-upgrade" type="button">
{t("upgrade")}
</button>
</div>
<div className="canvas-account-menu-separator" />
<button
type="button"
role="menuitem"
onClick={() => {
setAccountManagementOpen(true);
setAccountOpen(false);
}}
>
<User size={18} />
{t("accountManagement")}
</button>
<button type="button" role="menuitem">
<BookOpen size={18} />
{t("guides")}
</button>
<button type="button" role="menuitem">
<MessageCircle size={18} />
{t("contactUs")}
</button>
<button type="button" role="menuitem" onClick={toggleLocale}>
<Globe2 size={18} />
{locale === "zh-CN" ? "简体中文" : "English"}
</button>
<div className="canvas-account-menu-separator" />
<button
type="button"
role="menuitem"
onClick={() => {
void logout();
setAccountOpen(false);
}}
>
<LogOut size={18} />
{t("logout")}
</button>
</div>
)}
</div>
{collapsed && (
<>
<span className="canvas-collapsed-separator" aria-hidden="true" />
<button className="canvas-conversation-button" type="button" onClick={onOpenConversation} aria-label={t("conversation")} title={t("conversation")}>
<MessageCircle size={16} />
<span>{t("conversation")}</span>
</button>
</>
)}
<AccountManagementDialog
open={accountManagementOpen}
user={user}
onClose={() => setAccountManagementOpen(false)}
onLogout={logout}
onUserUpdate={updateUser}
/>
</div>
);
}
function canvasAvatarInitials(value: string) {
const cleaned = value.trim();
if (!cleaned) return "M";
const words = cleaned.split(/\s+/).filter(Boolean);
if (words.length >= 2) return `${words[0][0] ?? ""}${words[1][0] ?? ""}`.toUpperCase();
return Array.from(cleaned)[0]?.toUpperCase() ?? "M";
}
function cleanCanvasAvatarUrl(value: string | undefined) {
const avatarUrl = value?.trim() || "";
const lowerAvatarUrl = avatarUrl.toLowerCase();
if (lowerAvatarUrl.includes("assets-persist.") && lowerAvatarUrl.includes("/img/") && lowerAvatarUrl.includes("-avatar.png")) return "";
return avatarUrl;
}
export function CanvasWorkspace({ projectId }: { projectId: string }) {
const { t, locale } = useI18n();
const { isAuthenticated } = useAuth();
const toUserMessageText = useUserMessage();
const { showToast } = useGlobalToast();
const containerRef = useRef<HTMLDivElement | null>(null);
const canvasImageInputRef = useRef<HTMLInputElement | null>(null);
const agentComposerRef = useRef<PromptComposerHandle | null>(null);
const referenceImagesRef = useRef<UploadedReferenceImage[]>([]);
const annotationsRef = useRef<CanvasAnnotation[]>([]);
const imageGeneratorThreadIdsRef = useRef<Set<string>>(new Set());
const activeAgentThreadIdRef = useRef<string | null>(null);
const selectedAgentThreadIdRef = useRef<string | null>(null);
const lastCanvasPointerRef = useRef<{ clientX: number; clientY: number } | null>(null);
const textExtractionEventsCloseRef = useRef<(() => void) | null>(null);
const mockupPrepareTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const mockupPrepareRequestRef = useRef(0);
const moveRecognitionRequestRef = useRef(0);
const quickEditFocusRef = useRef<{ viewport: CanvasViewport; dirty: boolean } | null>(null);
const webSearchHydratedRef = useRef(false);
const { project, setProject, projectRef, viewport, setViewport, viewportRef, nodes, setNodes, nodesRef, applyProjectDocument } = useCanvasDocument();
const { selectedId, selectedIds, setSelectedIds, selectedNodes, selectedNode, selectOnlyNode, selectNodes, toggleNodeSelection } = useCanvasSelection(nodes);
const [messages, setMessages] = useState<AgentMessage[]>([]);
const [prompt, setPrompt] = useState("");
const [agentThreads, setAgentThreads] = useState<AgentThreadSummary[]>([]);
const [credits, setCredits] = useState(30);
const [assistantPanelOpen, setAssistantPanelOpen] = useState(true);
const [agentHistoryOpen, setAgentHistoryOpen] = useState(false);
const [agentHistorySearch, setAgentHistorySearch] = useState("");
const [selectedAgentThreadId, setSelectedAgentThreadId] = useState<string | null>(null);
const [referenceImages, setReferenceImages] = useState<UploadedReferenceImage[]>([]);
const [annotations, setAnnotations] = useState<CanvasAnnotation[]>([]);
const [annotationPreview, setAnnotationPreview] = useState<AnnotationPreviewRequest | null>(null);
const [composerMode, setComposerMode] = useState<ComposerMode>("agent");
const [useAgentWebSearch, setUseAgentWebSearch] = useState(false);
const [selectedCanvasModelId, setSelectedCanvasModelId] = useState("gpt-image-2-auto");
const [composerImageSettings, setComposerImageSettings] = useState<ComposerImageSettings>(defaultComposerImageSettings);
const [canvasStageSize, setCanvasStageSize] = useState({ width: 1100, height: 760 });
const [mode, setMode] = useState<"select" | "pan">("select");
const [activeTool, setActiveTool] = useState<CanvasTool>("select");
const [editingTextNodeId, setEditingTextNodeId] = useState<string | null>(null);
const [canvasToolMenuOpen, setCanvasToolMenuOpen] = useState(false);
const [shapeToolMenuOpen, setShapeToolMenuOpen] = useState(false);
const [selectedShapeKind, setSelectedShapeKind] = useState<ShapeKind>("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<SelectionBox | null>(null);
const [frameDraft, setFrameDraft] = useState<FrameDraft | null>(null);
const [shapeDraft, setShapeDraft] = useState<ShapeDraft | null>(null);
const [penDraft, setPenDraft] = useState<PenDraft | null>(null);
const [penSettings, setPenSettings] = useState<PenSettings>({ color: "#000000", width: 3 });
const [canvasPanning, setCanvasPanning] = useState(false);
const [alignmentGuides, setAlignmentGuides] = useState<AlignmentGuide[]>([]);
const [canvasFileDragging, setCanvasFileDragging] = useState(false);
const [error, setError] = useState("");
const [clipboardNode, setClipboardNode] = useState<CanvasNode | null>(null);
const [hiddenNodeIds, setHiddenNodeIds] = useState<Set<string>>(() => new Set());
const [lockedNodeIds, setLockedNodeIds] = useState<Set<string>>(() => new Set());
const [toolbarVisibility, setToolbarVisibility] = useState<ImageToolbarVisibility>(defaultToolbarVisibility);
const [imageActionMode, setImageActionMode] = useState<ImageActionMode>(null);
const [quickEditPrompt, setQuickEditPrompt] = useState("");
const [eraserBrushSize, setEraserBrushSize] = useState(42);
const [eraserBrushColor, setEraserBrushColor] = useState("#5E6CFF");
const [eraserBrushOpacity, setEraserBrushOpacity] = useState(72);
const [eraserMarks, setEraserMarks] = useState<MaskPoint[]>([]);
const [textEditItems, setTextEditItems] = useState<EditableTextLayer[]>([]);
const [textEditLoading, setTextEditLoading] = useState(false);
const [textEditError, setTextEditError] = useState("");
const [mockupDropActive, setMockupDropActive] = useState(false);
const [mockupDragNodeId, setMockupDragNodeId] = useState<string | null>(null);
const [mockupPendingTargetId, setMockupPendingTargetId] = useState<string | null>(null);
const [mockupReadyTargetId, setMockupReadyTargetId] = useState<string | null>(null);
const [mockupPlacement, setMockupPlacement] = useState<MockupPlacement | null>(null);
const [mockupSurface, setMockupSurface] = useState<MockupSurface | null>(null);
const [mockupModel, setMockupModel] = useState<MockupModel | null>(null);
const [movePrompt, setMovePrompt] = useState("");
const [moveSelection, setMoveSelection] = useState<MoveObjectSelection | null>(null);
const [moveRecognitionLabel, setMoveRecognitionLabel] = useState("");
const [moveRecognitionLoading, setMoveRecognitionLoading] = useState(false);
const [cropPrompt, setCropPrompt] = useState("");
const [cropSelection, setCropSelection] = useState<NormalizedRect | null>(null);
const [adjustOptions, setAdjustOptions] = useState<AdjustOptions>(defaultImageAdjustments);
const [expandOptions, setExpandOptions] = useState<ExpandOptions>({
ratio: "3:4",
scale: 1,
prompt: ""
});
const [multiAngleOptions, setMultiAngleOptions] = useState<MultiAngleOptions>({
tab: "subject",
yaw: 29,
pitch: 25,
zoom: 50,
prompt: ""
});
const canvasActionWorkActive = canvasActionGenerating || textEditLoading || nodes.some(isCanvasActionWorkingNode);
const agentTimelineActive = agentGenerating || Boolean(project && !projectHasCanvasActionGeneration(project) && nodes.some(isAgentVisibleGeneratingNode));
const mockupPreparing = Boolean(mockupPendingTargetId);
const anyGenerating = agentGenerating || imageGeneratorGenerating || canvasActionWorkActive || mockupPreparing;
useEffect(() => {
window.localStorage.setItem("fluxo:show-minimap", showMinimap ? "true" : "false");
}, [showMinimap]);
useEffect(() => {
if (!isAuthenticated) {
setCredits(30);
return;
}
designGateway
.dashboard()
.then((dashboard) => setCredits(dashboard.credits))
.catch(() => undefined);
}, [isAuthenticated]);
useEffect(() => {
webSearchHydratedRef.current = false;
const stored = window.localStorage.getItem(canvasBackgroundStorageKey(projectId));
setCanvasBackgroundColor(normalizeHexColor(stored ?? "", defaultCanvasBackgroundColor));
}, [projectId]);
useEffect(() => {
if (editingTextNodeId && !nodes.some((node) => node.id === editingTextNodeId)) {
setEditingTextNodeId(null);
}
}, [editingTextNodeId, nodes]);
const applyProject = useCallback((nextProject: Project, options: { preserveMessages?: boolean } = {}) => {
applyProjectDocument(nextProject);
if (!options.preserveMessages) {
setMessages(visibleAgentMessagesForThread(nextProject, imageGeneratorThreadIdsRef.current, selectedAgentThreadIdRef.current));
}
setEditingTextNodeId(null);
}, [applyProjectDocument]);
const appendThinkingMessage = useCallback((message: AgentMessage) => {
if (isImageGeneratorThreadMessage(message, imageGeneratorThreadIdsRef.current)) return;
if (message.type === "text_extraction_result" || message.name === "text_extraction") return;
const selectedThreadId = selectedAgentThreadIdRef.current;
if (selectedThreadId === newAgentConversationId) return;
if (selectedThreadId) {
const messageThreadId = message.threadId || projectRef.current?.lastThreadId || "";
if (messageThreadId !== selectedThreadId) return;
}
setMessages((current) => {
const existingIndex = current.findIndex((item) => item.id === message.id);
if (existingIndex < 0) return [...current, message];
const next = [...current];
next[existingIndex] = message;
return next;
});
}, []);
const { startProjectEvents, startAgentSocket, stopGenerationStream, agentStreamingProjectIdRef } = useGenerationStream({
applyProject,
appendThinkingMessage,
imageGeneratorThreadIdsRef,
setAgentGenerating,
setImageGeneratorGenerating,
setCanvasActionGenerating,
setError,
messages: {
generateError: t("generateError"),
generateTimeout: t("generateTimeout")
}
});
const loadAgentThreads = useCallback(async () => {
const currentProject = projectRef.current;
if (!currentProject) return;
try {
const response = await designGateway.listAgentThreads(currentProject.id);
setAgentThreads(response.threads);
} catch (err) {
console.warn("Failed to load agent thread history", err);
}
}, [projectRef]);
const { saveCanvasSnapshot, updateProjectTitle } = useCanvasPersistence({
project,
projectRef,
viewport,
viewportRef,
nodes,
nodesRef,
dirty,
anyGenerating,
setProject,
setDirty,
setSaving,
setError,
messages: {
saveError: t("saveError"),
renameProjectError: t("renameProjectError")
}
});
useEffect(() => {
if (!agentGenerating) {
activeAgentThreadIdRef.current = null;
}
}, [agentGenerating]);
useEffect(() => {
selectedAgentThreadIdRef.current = null;
setSelectedAgentThreadId(null);
setAgentThreads([]);
setAgentHistorySearch("");
setAgentHistoryOpen(false);
}, [projectId]);
useEffect(() => {
if (agentHistoryOpen) void loadAgentThreads();
}, [agentHistoryOpen, loadAgentThreads]);
useEffect(() => {
designGateway
.getProject(projectId)
.then(({ project: nextProject }) => {
applyProject(nextProject);
if (!webSearchHydratedRef.current) {
setUseAgentWebSearch(projectHasWebSearchEnabled(nextProject));
webSearchHydratedRef.current = true;
}
if (isProjectGenerating(nextProject)) {
const channel: GenerationChannel = projectHasImageGeneratorGeneration(nextProject) ? "image-generator" : projectHasCanvasActionGeneration(nextProject) ? "canvas-action" : "agent";
if (channel === "image-generator") {
setImageGeneratorGenerating(true);
} else if (channel === "canvas-action") {
setCanvasActionGenerating(true);
runningCanvasActionThreadIds(nextProject).forEach((threadId) => startProjectEvents(nextProject.id, threadId, "canvas-action"));
} else {
setAgentGenerating(true);
activeAgentThreadIdRef.current = nextProject.lastThreadId || null;
}
if (channel !== "canvas-action") {
startProjectEvents(nextProject.id, nextProject.lastThreadId, channel);
}
}
return designGateway.getProjectHistory(nextProject.id);
})
.then((history) => {
if (history && !agentStreamingProjectIdRef.current) {
const currentProject = projectRef.current;
if (currentProject) {
setMessages(visibleAgentMessagesForThread({ ...currentProject, messages: history.messages }, imageGeneratorThreadIdsRef.current, selectedAgentThreadIdRef.current));
} else {
setMessages(visibleAgentMessages(history.messages, imageGeneratorThreadIdsRef.current));
}
}
})
.catch((err: unknown) => setError(toUserMessageText(err, "generateError")));
}, [applyProject, projectId, startProjectEvents]);
useEffect(() => {
referenceImagesRef.current = referenceImages;
}, [referenceImages]);
useEffect(() => {
annotationsRef.current = annotations;
}, [annotations]);
useEffect(() => () => revokeReferenceImages(referenceImagesRef.current), []);
useEffect(() => {
void loadMotevaFontCatalog();
}, []);
useEffect(() => {
return () => {
textExtractionEventsCloseRef.current?.();
mockupPrepareRequestRef.current += 1;
if (mockupPrepareTimerRef.current) clearTimeout(mockupPrepareTimerRef.current);
};
}, []);
useEffect(() => {
const stage = containerRef.current;
if (!stage) return;
const updateSize = () => {
const nextSize = { width: stage.clientWidth, height: stage.clientHeight };
setCanvasStageSize((current) => (current.width === nextSize.width && current.height === nextSize.height ? current : nextSize));
};
updateSize();
const observer = new ResizeObserver(updateSize);
observer.observe(stage);
window.addEventListener("resize", updateSize);
return () => {
observer.disconnect();
window.removeEventListener("resize", updateSize);
};
}, [project?.id, projectId]);
const visibleCanvasNodes = useMemo(() => getVisibleCanvasNodes(nodes, hiddenNodeIds), [hiddenNodeIds, nodes]);
const renderedCanvasNodes = useMemo(() => nodes.filter((node) => !isBooleanShapeOperandNode(node, nodes) && (!hiddenNodeIds.has(node.id) || selectedIds.has(node.id))), [hiddenNodeIds, nodes, selectedIds]);
const imageModels = useMemo(() => getImageModels(locale), [locale]);
const agentHistoryThreads = useMemo(() => agentHistoryListItems(agentThreads, project, imageGeneratorThreadIdsRef.current), [agentThreads, project]);
const filteredAgentHistoryThreads = useMemo(() => {
const keyword = agentHistorySearch.trim().toLowerCase();
if (!keyword) return agentHistoryThreads;
return agentHistoryThreads.filter((thread) => agentThreadTitle(thread).toLowerCase().includes(keyword));
}, [agentHistorySearch, agentHistoryThreads]);
const selectedCanvasModel = imageModels.find((model) => model.id === selectedCanvasModelId) ?? imageModels[0];
const activeHistoryThreadId = selectedAgentThreadId === newAgentConversationId ? newAgentConversationId : selectedAgentThreadId || agentHistoryThreads[0]?.agentThreadId || "";
const selectedNodeControlsEnabled = selectedNodes.length <= 1 && selectedNode ? !lockedNodeIds.has(selectedNode.id) : false;
const selectedControlNode = selectedNodeControlsEnabled ? selectedNode : null;
const selectedImageNode = selectedControlNode && isRealCanvasImageNode(selectedControlNode) ? selectedControlNode : null;
const selectedImageNodeBusy = selectedImageNode ? isImageActionBusy(selectedImageNode) : false;
const selectedImageGeneratorNode = selectedControlNode?.type === "image" && selectedControlNode.layerRole === "image-generator" ? selectedControlNode : null;
const selectedImageGeneratorBusy = imageGeneratorGenerating || selectedImageGeneratorNode?.status === "generating";
const selectedPenStrokeNode = selectedControlNode && isCanvasPenStrokeNode(selectedControlNode) ? selectedControlNode : null;
const selectedFrameNode = selectedControlNode?.type === "frame" && selectedControlNode.layerRole === "manual-frame" ? selectedControlNode : null;
const selectedShapeNode = selectedControlNode && isShapeNode(selectedControlNode) ? selectedControlNode : null;
const selectedBooleanShapeGroupNode = selectedControlNode && isBooleanShapeGroupNode(selectedControlNode) ? selectedControlNode : null;
const selectedTextNode = selectedControlNode?.type === "text" ? selectedControlNode : null;
const hasCurrentConversation = messages.some((message) => message.content.trim() || message.title.trim());
const selectedNodeBounds = useMemo<NodeScreenBounds | null>(() => {
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<NodeScreenBounds | null>(() => {
if (!selectedImageGeneratorNode) return null;
return {
left: viewport.x + selectedImageGeneratorNode.x * viewport.k,
top: viewport.y + selectedImageGeneratorNode.y * viewport.k,
width: selectedImageGeneratorNode.width * viewport.k,
height: selectedImageGeneratorNode.height * viewport.k
};
}, [selectedImageGeneratorNode, viewport]);
const selectedFrameBounds = useMemo<NodeScreenBounds | null>(() => {
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<NodeScreenBounds | null>(() => {
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<NodeScreenBounds | null>(() => {
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<NodeScreenBounds | null>(() => {
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<NodeScreenBounds | null>(() => {
if (!selectedTextNode) return null;
return {
left: viewport.x + selectedTextNode.x * viewport.k,
top: viewport.y + selectedTextNode.y * viewport.k,
width: selectedTextNode.width * viewport.k,
height: selectedTextNode.height * viewport.k
};
}, [selectedTextNode, viewport]);
const floatingToolbarLeftInset = generatedFilesPanelOpen ? generatedFilesPanelWidth : layersPanelOpen ? layersPanelWidth : 0;
const insertCanvasImageReference = useCallback((node: CanvasNode, options: { focus?: boolean; pending?: boolean; replacePending?: boolean } = {}) => {
const reference = canvasNodeToReferenceImage(node);
if (!reference) return;
agentComposerRef.current?.upsertReference(reference, options);
}, []);
useEffect(() => {
if (!selectedImageNode) return;
insertCanvasImageReference(selectedImageNode, {
focus: false,
pending: true,
replacePending: true
});
}, [insertCanvasImageReference, selectedImageNode?.content, selectedImageNode?.id]);
const multiSelectionBounds = useMemo<NodeScreenBounds | null>(() => {
if (selectedNodes.length < 2) return null;
if (selectedNodes.some((node) => lockedNodeIds.has(node.id))) return null;
return screenBoundsForNodes(selectedNodes, viewport);
}, [lockedNodeIds, selectedNodes, viewport]);
const selectedGroupId = useMemo(() => {
if (selectedNodes.length < 2) return null;
const groupId = selectedNodes[0]?.parentId;
if (!groupId) return null;
const groupNodes = nodes.filter((node) => node.parentId === groupId);
if (groupNodes.length < 2) return null;
return groupNodes.every((node) => selectedIds.has(node.id)) && selectedNodes.every((node) => node.parentId === groupId) ? groupId : null;
}, [nodes, selectedIds, selectedNodes]);
const canBooleanOperateSelection = useMemo(() => {
if (selectedBooleanShapeGroupNode) return true;
return selectedNodes.length >= 2 && selectedNodes.every(isBooleanCapableShapeNode);
}, [selectedBooleanShapeGroupNode, selectedNodes]);
useEffect(() => {
setImageActionMode(null);
setQuickEditPrompt("");
setEraserMarks([]);
setTextEditItems([]);
setTextEditLoading(false);
setTextEditError("");
setMockupDropActive(false);
setMockupDragNodeId(null);
setMockupPendingTargetId(null);
setMockupReadyTargetId(null);
setMockupPlacement(null);
setMockupSurface(null);
setMockupModel(null);
mockupPrepareRequestRef.current += 1;
if (mockupPrepareTimerRef.current) {
clearTimeout(mockupPrepareTimerRef.current);
mockupPrepareTimerRef.current = null;
}
setMoveSelection(null);
setMoveRecognitionLabel("");
setMoveRecognitionLoading(false);
setCropSelection(null);
setCropPrompt("");
}, [selectedId, selectedIds.size]);
useEffect(() => {
if (imageActionMode !== "move-object" || !selectedImageNode || !moveSelection || moveSelection.srcBox.width < 0.02 || moveSelection.srcBox.height < 0.02) {
moveRecognitionRequestRef.current += 1;
setMoveRecognitionLabel("");
setMoveRecognitionLoading(false);
return;
}
if (moveSelection.dstBox) {
moveRecognitionRequestRef.current += 1;
setMoveRecognitionLoading(false);
return;
}
const requestId = moveRecognitionRequestRef.current + 1;
moveRecognitionRequestRef.current = requestId;
setMoveRecognitionLabel("");
setMoveRecognitionLoading(true);
const timer = window.setTimeout(() => {
designGateway
.recognizeObject(selectedImageNode.content, moveSelection.srcBox, locale === "zh-CN" ? "zh" : "en")
.then((result) => {
if (moveRecognitionRequestRef.current !== requestId) return;
setMoveRecognitionLabel(result.label);
const bbox = result.bbox;
if (bbox && bbox.width >= 0.02 && bbox.height >= 0.02) {
setMoveSelection((current) => {
if (!current || current.dstBox) return current;
if (Math.abs(current.srcBox.x - bbox.x) < 0.004 && Math.abs(current.srcBox.y - bbox.y) < 0.004 && Math.abs(current.srcBox.width - bbox.width) < 0.004 && Math.abs(current.srcBox.height - bbox.height) < 0.004) {
return current;
}
return {
srcBox: {
x: bbox.x,
y: bbox.y,
width: bbox.width,
height: bbox.height
}
};
});
}
})
.catch(() => {
if (moveRecognitionRequestRef.current !== requestId) return;
setMoveRecognitionLabel("");
})
.finally(() => {
if (moveRecognitionRequestRef.current !== requestId) return;
setMoveRecognitionLoading(false);
});
}, 300);
return () => window.clearTimeout(timer);
}, [imageActionMode, locale, moveSelection, selectedImageNode?.content]);
const handleWheel = (event: React.WheelEvent<HTMLDivElement>) => {
if (shouldBypassCanvasWheel(event.target)) {
return;
}
event.preventDefault();
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) return;
const factor = Math.pow(1.1, -event.deltaY / 120);
const nextScale = clamp(viewport.k * factor, minCanvasZoom, maxCanvasZoom);
const mouseX = event.clientX - rect.left;
const mouseY = event.clientY - rect.top;
const worldX = (mouseX - viewport.x) / viewport.k;
const worldY = (mouseY - viewport.y) / viewport.k;
setViewport({
x: mouseX - worldX * nextScale,
y: mouseY - worldY * nextScale,
k: nextScale
});
setDirty(true);
};
const zoomCanvasTo = (nextZoom: number) => {
const current = viewportRef.current;
const nextScale = clamp(nextZoom, minCanvasZoom, maxCanvasZoom);
const centerX = canvasStageSize.width / 2;
const centerY = canvasStageSize.height / 2;
const worldX = (centerX - current.x) / current.k;
const worldY = (centerY - current.y) / current.k;
const nextViewport = {
x: centerX - worldX * nextScale,
y: centerY - worldY * nextScale,
k: nextScale
};
viewportRef.current = nextViewport;
setViewport(nextViewport);
setDirty(true);
};
const fitCanvasToAllNodes = () => {
const bounds = nodesRef.current.length > 0 ? nodeBounds(nodesRef.current) : { x: 0, y: 0, width: 1200, height: 800 };
const padding = Math.max(96, Math.min(Math.max(bounds.width, bounds.height) * 0.08, 240));
const nextScale = clamp(
Math.min(canvasStageSize.width / Math.max(bounds.width + padding * 2, 1), canvasStageSize.height / Math.max(bounds.height + padding * 2, 1)),
minCanvasZoom,
maxCanvasZoom
);
const nextViewport = {
x: canvasStageSize.width / 2 - (bounds.x + bounds.width / 2) * nextScale,
y: canvasStageSize.height / 2 - (bounds.y + bounds.height / 2) * nextScale,
k: nextScale
};
viewportRef.current = nextViewport;
setViewport(nextViewport);
setDirty(true);
};
const updateCanvasBackgroundColor = (color: string) => {
const nextColor = normalizeHexColor(color, defaultCanvasBackgroundColor);
setCanvasBackgroundColor(nextColor);
window.localStorage.setItem(canvasBackgroundStorageKey(projectId), nextColor);
};
const startCanvasPan = (event: React.PointerEvent<HTMLElement>) => {
event.preventDefault();
setCanvasPanning(true);
const start = {
pointerX: event.clientX,
pointerY: event.clientY,
x: viewport.x,
y: viewport.y
};
const handleMove = (moveEvent: PointerEvent) => {
setViewport((current) => ({
...current,
x: start.x + moveEvent.clientX - start.pointerX,
y: start.y + moveEvent.clientY - start.pointerY
}));
setDirty(true);
};
const handleUp = () => {
window.removeEventListener("pointermove", handleMove);
window.removeEventListener("pointerup", handleUp);
window.removeEventListener("pointercancel", handleUp);
setCanvasPanning(false);
void saveCanvasSnapshot({ quiet: true });
};
window.addEventListener("pointermove", handleMove);
window.addEventListener("pointerup", handleUp);
window.addEventListener("pointercancel", handleUp);
};
const syncAnnotations = (nextAnnotations: CanvasAnnotation[]) => {
annotationsRef.current = nextAnnotations;
setAnnotations(nextAnnotations);
setAnnotationPreview((current) => (current && nextAnnotations.some((annotation) => annotation.id === current.annotation.id) ? current : null));
};
const canvasPointFromClient = (clientX: number, clientY: number) => {
const rect = containerRef.current?.getBoundingClientRect();
const currentViewport = viewportRef.current;
if (!rect) {
return {
x: -currentViewport.x / currentViewport.k,
y: -currentViewport.y / currentViewport.k
};
}
return {
x: (clientX - rect.left - currentViewport.x) / currentViewport.k,
y: (clientY - rect.top - currentViewport.y) / currentViewport.k
};
};
const showCanvasToast = (message: string, tone: "success" | "error" = "success") => {
showToast(message, tone);
};
const findAnnotationTarget = (x: number, y: number, candidate?: CanvasNode) => {
const visualNodes = nodesRef.current.filter((node) => (node.type === "image" || node.type === "frame") && node.status !== "generating");
if (candidate && visualNodes.some((node) => node.id === candidate.id) && pointInsideNode(x, y, candidate)) return candidate;
return [...visualNodes].reverse().find((node) => pointInsideNode(x, y, node)) ?? null;
};
const addAnnotationAtClientPoint = (clientX: number, clientY: number, candidate?: CanvasNode) => {
const point = canvasPointFromClient(clientX, clientY);
const target = findAnnotationTarget(point.x, point.y, candidate);
if (!target) return false;
const nextIndex = annotationsRef.current.reduce((max, annotation) => Math.max(max, annotation.index), 0) + 1;
const annotation: CanvasAnnotation = {
id: cryptoFallbackId(),
index: nextIndex,
nodeId: target.id,
x: point.x,
y: point.y,
label: t("annotationLabel", { index: nextIndex })
};
syncAnnotations([...annotationsRef.current, annotation]);
agentComposerRef.current?.insertAnnotation(annotation);
return true;
};
const addFrameFromRect = useCallback(
(rect: FrameWorldRect) => {
const node: CanvasNode = {
id: cryptoFallbackId(),
type: "frame",
title: t("newFrameNode"),
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height,
content: "",
tone: "natural",
status: "draft",
layerRole: "manual-frame",
opacity: 1,
fillColor: defaultFrameFillColor,
strokeColor: defaultFrameStrokeColor,
strokeWidth: defaultFrameStrokeWidth,
strokeStyle: defaultFrameStrokeStyle
};
setNodes((current) => [...current, node]);
selectOnlyNode(node.id);
setActiveTool("select");
setMode("select");
setDirty(true);
},
[selectOnlyNode, t]
);
const addShapeFromDraft = useCallback(
(draft: ShapeWorldDraft) => {
const node = createShapeNode({
id: cryptoFallbackId(),
title: shapeTitle(selectedShapeKind, t),
kind: selectedShapeKind,
draft
});
setNodes((current) => {
const next = [...current, node];
nodesRef.current = next;
return next;
});
selectOnlyNode(node.id);
setActiveTool("select");
setMode("select");
setDirty(true);
},
[nodesRef, selectOnlyNode, selectedShapeKind, t]
);
const startPenDrawing = useCallback(
(event: React.PointerEvent<HTMLElement>) => {
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) return;
event.preventDefault();
selectOnlyNode(null);
beginPenStroke({
clientX: event.clientX,
clientY: event.clientY,
stageRect: rect,
viewport: viewportRef.current,
settings: penSettings,
onDraftChange: setPenDraft,
onCommit: (draft) => {
const node = createPenStrokeNode(draft, t("newPenStrokeNode"));
setNodes((current) => {
const next = [...current, node];
nodesRef.current = next;
return next;
});
setDirty(true);
}
});
},
[penSettings, selectOnlyNode, t]
);
const startShapeDrawing = useCallback(
(event: React.PointerEvent<HTMLElement>) => {
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) return;
event.preventDefault();
selectOnlyNode(null);
beginShapeDrawing({
clientX: event.clientX,
clientY: event.clientY,
stageRect: rect,
viewport: viewportRef.current,
onDraftChange: setShapeDraft,
onCommit: addShapeFromDraft
});
},
[addShapeFromDraft, selectOnlyNode, viewportRef]
);
const handleBackgroundPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
if (event.button !== 0) return;
const target = event.target as HTMLElement;
if (
target.closest(".canvas-node") ||
target.closest(".workspace-toolbar") ||
target.closest(".shape-tool-popover") ||
target.closest(".canvas-bottom-left") ||
target.closest(".canvas-layer-panel") ||
target.closest(".canvas-generated-files-panel") ||
target.closest(".image-node-toolbar") ||
target.closest(".frame-style-toolbar") ||
target.closest(".shape-style-toolbar") ||
target.closest(".shape-radius-popover") ||
target.closest(".frame-color-popover") ||
target.closest(".pen-tool-controls") ||
target.closest(".pen-color-popover") ||
target.closest(".image-action-panel") ||
target.closest(".image-mask-overlay") ||
target.closest(".multi-selection-toolbar") ||
target.closest(".text-style-toolbar") ||
target.closest(".text-font-popover") ||
target.closest(".text-weight-popover") ||
target.closest(".text-size-popover") ||
target.closest(".text-align-popover") ||
target.closest(".text-advanced-popover")
) {
return;
}
if (editingTextNodeId) {
event.preventDefault();
finishTextEditing(editingTextNodeId);
return;
}
if (activeTool === "pin") {
event.preventDefault();
return;
}
if (activeTool === "text") {
event.preventDefault();
addTextNodeAt(canvasPointFromClient(event.clientX, event.clientY));
return;
}
if (activeTool === "pen") {
startPenDrawing(event);
return;
}
if (activeTool === "frame") {
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) return;
event.preventDefault();
selectOnlyNode(null);
beginFrameDrawing({
clientX: event.clientX,
clientY: event.clientY,
stageRect: rect,
viewport: viewportRef.current,
onDraftChange: setFrameDraft,
onCommit: addFrameFromRect
});
return;
}
if (activeTool === "shape") {
startShapeDrawing(event);
return;
}
const start = {
pointerX: event.clientX,
pointerY: event.clientY,
stageLeft: containerRef.current?.getBoundingClientRect().left ?? 0,
stageTop: containerRef.current?.getBoundingClientRect().top ?? 0,
x: viewport.x,
y: viewport.y
};
if (mode === "select") {
const additiveIds = event.shiftKey ? selectedNodes.map((node) => node.id) : [];
let dragged = false;
const handleMove = (moveEvent: PointerEvent) => {
const box = normalizeScreenRect(start.pointerX - start.stageLeft, start.pointerY - start.stageTop, moveEvent.clientX - start.stageLeft, moveEvent.clientY - start.stageTop);
if (box.width < 4 && box.height < 4) return;
dragged = true;
setSelectionBox(box);
const worldRect = screenRectToWorldRect(box, viewportRef.current);
const hitIds = nodesRef.current.filter((node) => !hiddenNodeIds.has(node.id) && nodeIntersectsRect(node, worldRect)).map((node) => node.id);
selectNodes([...additiveIds, ...hitIds]);
};
const handleUp = () => {
window.removeEventListener("pointermove", handleMove);
window.removeEventListener("pointerup", handleUp);
setSelectionBox(null);
if (!dragged) selectOnlyNode(null);
};
window.addEventListener("pointermove", handleMove);
window.addEventListener("pointerup", handleUp);
return;
}
selectOnlyNode(null);
startCanvasPan(event);
};
const startMockupSourceDrag = (event: React.PointerEvent<HTMLDivElement>, sourceNode: CanvasNode, targetNode: CanvasNode) => {
const targetPlacement = mockupPlacement;
const targetSurface = mockupSurface?.targetId === targetNode.id ? mockupSurface : { targetId: targetNode.id, polygon: [] };
const targetModel = mockupModel;
if (!targetPlacement || !targetModel) return;
event.preventDefault();
const wasDirty = dirty;
setMockupDragNodeId(sourceNode.id);
const start = {
pointerX: event.clientX,
pointerY: event.clientY,
x: sourceNode.x,
y: sourceNode.y,
scale: viewport.k
};
let dragged = false;
const updateDropState = (clientX: number, clientY: number) => {
const point = canvasPointFromClient(clientX, clientY);
setMockupDropActive(pointInsideMockupPlacement(point.x, point.y, targetNode, targetPlacement));
return point;
};
updateDropState(event.clientX, event.clientY);
const handleMove = (moveEvent: PointerEvent) => {
const movement = Math.hypot(moveEvent.clientX - start.pointerX, moveEvent.clientY - start.pointerY);
if (movement > 3) dragged = true;
const dx = (moveEvent.clientX - start.pointerX) / start.scale;
const dy = (moveEvent.clientY - start.pointerY) / start.scale;
const nextSourceNode = { ...sourceNode, x: start.x + dx, y: start.y + dy };
const nextNodes = nodesRef.current.map((node) => (node.id === sourceNode.id ? nextSourceNode : node));
nodesRef.current = nextNodes;
setNodes(nextNodes);
updateDropState(moveEvent.clientX, moveEvent.clientY);
setDirty(true);
};
const handleUp = (upEvent: PointerEvent) => {
window.removeEventListener("pointermove", handleMove);
window.removeEventListener("pointerup", handleUp);
setMockupDropActive(false);
setMockupDragNodeId(null);
const dropPoint = canvasPointFromClient(upEvent.clientX, upEvent.clientY);
const droppedOnTarget = pointInsideMockupPlacement(dropPoint.x, dropPoint.y, targetNode, targetPlacement);
const droppedNode = nodesRef.current.find((node) => node.id === sourceNode.id);
const restoredSourceNode = droppedNode ? { ...droppedNode, x: start.x, y: start.y } : sourceNode;
const restoredNodes = nodesRef.current.map((node) => (node.id === sourceNode.id ? restoredSourceNode : node));
nodesRef.current = restoredNodes;
setNodes(restoredNodes);
if (droppedOnTarget && droppedNode && dragged) {
void applyMockupPrint(restoredSourceNode, targetNode, targetPlacement, targetSurface, targetModel, dropPoint);
return;
}
setDirty(wasDirty);
};
window.addEventListener("pointermove", handleMove);
window.addEventListener("pointerup", handleUp);
};
const handleNodePointerDown = (event: React.PointerEvent<HTMLDivElement>, node: CanvasNode) => {
event.stopPropagation();
if (event.button !== 0) return;
if (
imageActionMode === "mockup" &&
selectedImageNode &&
mockupReadyTargetId === selectedImageNode.id &&
mockupPlacement &&
node.id !== selectedImageNode.id &&
isRealCanvasImageNode(node) &&
!lockedNodeIds.has(node.id) &&
!hiddenNodeIds.has(node.id)
) {
startMockupSourceDrag(event, node, selectedImageNode);
return;
}
if (activeTool === "text" && (node.type === "image" || node.type === "frame")) {
event.preventDefault();
addTextNodeAt(canvasPointFromClient(event.clientX, event.clientY));
return;
}
if (activeTool === "pen") {
startPenDrawing(event);
return;
}
if (activeTool === "shape") {
startShapeDrawing(event);
return;
}
if (activeTool === "pin") {
event.preventDefault();
addAnnotationAtClientPoint(event.clientX, event.clientY, node);
return;
}
if (mode === "pan") {
startCanvasPan(event);
return;
}
if (editingTextNodeId && editingTextNodeId !== node.id) {
finishTextEditing(editingTextNodeId);
}
const additive = event.shiftKey || event.metaKey || event.ctrlKey;
if (additive) {
toggleNodeSelection(node.id);
return;
}
const shouldEditTextOnClick =
node.type === "text" &&
node.layerRole === "editable-text" &&
selectedIds.has(node.id) &&
selectedIds.size === 1 &&
activeTool === "select" &&
mode === "select" &&
!lockedNodeIds.has(node.id) &&
!node.parentId &&
!editingTextNodeId;
const nodeGroupIds = node.parentId ? nodesRef.current.filter((item) => item.parentId === node.parentId).map((item) => item.id) : [];
const useExistingSelection = selectedIds.has(node.id) && selectedIds.size > 1;
const groupDrag = useExistingSelection || nodeGroupIds.length > 1;
const dragIds = groupDrag ? new Set(useExistingSelection ? selectedIds : nodeGroupIds) : new Set([node.id]);
if (groupDrag) {
selectNodes(Array.from(dragIds));
} else {
selectOnlyNode(node.id);
}
if (lockedNodeIds.has(node.id)) return;
const start = {
pointerX: event.clientX,
pointerY: event.clientY,
positions: nodesRef.current
.filter((item) => dragIds.has(item.id) && !lockedNodeIds.has(item.id))
.map((item) => ({ id: item.id, x: item.x, y: item.y })),
scale: viewport.k
};
let dragged = false;
const handleMove = (moveEvent: PointerEvent) => {
const movement = Math.hypot(moveEvent.clientX - start.pointerX, moveEvent.clientY - start.pointerY);
if (movement > 3) dragged = true;
if (shouldEditTextOnClick && !dragged) return;
const dx = (moveEvent.clientX - start.pointerX) / start.scale;
const dy = (moveEvent.clientY - start.pointerY) / start.scale;
const nextNodes = moveNodesByDelta(nodesRef.current, start.positions, dx, dy);
const movedNodes = nextNodes.filter((item) => dragIds.has(item.id) && !lockedNodeIds.has(item.id));
nodesRef.current = nextNodes;
setNodes(nextNodes);
setAlignmentGuides(buildAlignmentGuides(movedNodes, nextNodes, viewportRef.current));
setDirty(true);
};
const handleUp = () => {
window.removeEventListener("pointermove", handleMove);
window.removeEventListener("pointerup", handleUp);
setAlignmentGuides([]);
if (shouldEditTextOnClick && !dragged) {
setEditingTextNodeId(node.id);
return;
}
if (dragged && Array.from(dragIds).some((nodeId) => nodesRef.current.find((item) => item.id === nodeId)?.layerRole === "mockup-print")) {
void refreshMockupPrintTextures(Array.from(dragIds));
return;
}
void saveCanvasSnapshot({ quiet: true });
};
window.addEventListener("pointermove", handleMove);
window.addEventListener("pointerup", handleUp);
};
const handleTextNodeResizePointerDown = (event: React.PointerEvent<HTMLButtonElement>, node: CanvasNode, handle: ResizeHandle) => {
const horizontalDirection = handle.includes("right") ? 1 : handle.includes("left") ? -1 : 0;
const verticalDirection = handle.includes("bottom") ? 1 : handle.includes("top") ? -1 : 0;
const start = {
pointerX: event.clientX,
pointerY: event.clientY,
x: node.x,
y: node.y,
width: node.width,
height: node.height,
fontSize: node.fontSize || 48,
scale: viewport.k
};
const handleMove = (moveEvent: PointerEvent) => {
const dx = (moveEvent.clientX - start.pointerX) / start.scale;
const dy = (moveEvent.clientY - start.pointerY) / start.scale;
const horizontalActive = horizontalDirection !== 0 && (verticalDirection === 0 || Math.abs(dx) >= Math.abs(dy) * 0.35);
const verticalActive = verticalDirection !== 0 && (horizontalDirection === 0 || Math.abs(dy) >= Math.abs(dx) * 0.35);
const pointerWidth = horizontalActive ? start.width + dx * horizontalDirection : start.width;
const pointerHeight = verticalActive ? start.height + dy * verticalDirection : start.height;
const nextFontSize = verticalActive ? clamp(Math.round(start.fontSize * Math.max(0.15, pointerHeight / Math.max(start.height, 1))), 8, 512) : start.fontSize;
const fontNode = { ...node, fontSize: nextFontSize };
const naturalSize = measureTextNodeContent(fontNode);
const nextWidth = horizontalActive ? Math.max(24, pointerWidth) : naturalSize.width;
const wrappedSize = measureTextNodeContent(fontNode, nextWidth);
const nextHeight = Math.max(12, wrappedSize.height);
const nextX = horizontalActive && horizontalDirection < 0 ? start.x + start.width - nextWidth : horizontalActive && horizontalDirection > 0 ? start.x : start.x + (start.width - nextWidth) / 2;
const nextY = verticalActive && verticalDirection < 0 ? start.y + start.height - nextHeight : start.y;
const resizedNode = {
...node,
x: nextX,
y: nextY,
width: nextWidth,
height: nextHeight,
fontSize: nextFontSize
};
nodesRef.current = nodesRef.current.map((item) => (item.id === node.id ? resizedNode : item));
setNodes((current) => current.map((item) => (item.id === node.id ? resizedNode : item)));
setAlignmentGuides(buildAlignmentGuides([resizedNode], nodesRef.current, viewportRef.current));
setDirty(true);
};
const handleUp = () => {
window.removeEventListener("pointermove", handleMove);
window.removeEventListener("pointerup", handleUp);
setAlignmentGuides([]);
if (node.layerRole === "mockup-print") {
void refreshMockupPrintTextures([node.id]);
return;
}
void saveCanvasSnapshot({ quiet: true });
};
window.addEventListener("pointermove", handleMove);
window.addEventListener("pointerup", handleUp);
};
const handleNodeResizePointerDown = (event: React.PointerEvent<HTMLButtonElement>, node: CanvasNode, handle: ResizeHandle) => {
event.stopPropagation();
if (mode === "pan") return;
selectOnlyNode(node.id);
if (lockedNodeIds.has(node.id)) return;
if (node.type === "text" && node.layerRole === "editable-text") {
handleTextNodeResizePointerDown(event, node, handle);
return;
}
const start = {
pointerX: event.clientX,
pointerY: event.clientY,
scale: viewport.k
};
const handleMove = (moveEvent: PointerEvent) => {
const dx = (moveEvent.clientX - start.pointerX) / start.scale;
const dy = (moveEvent.clientY - start.pointerY) / start.scale;
const resizedNode = resizeVisualNodeByHandle(node, handle, { dx, dy });
nodesRef.current = nodesRef.current.map((item) => (item.id === node.id ? resizedNode : item));
setNodes((current) => current.map((item) => (item.id === node.id ? resizedNode : item)));
setAlignmentGuides(buildAlignmentGuides([resizedNode], nodesRef.current, viewportRef.current));
setDirty(true);
};
const handleUp = () => {
window.removeEventListener("pointermove", handleMove);
window.removeEventListener("pointerup", handleUp);
setAlignmentGuides([]);
void saveCanvasSnapshot({ quiet: true });
};
window.addEventListener("pointermove", handleMove);
window.addEventListener("pointerup", handleUp);
};
const handleShapeEndpointPointerDown = (event: React.PointerEvent<HTMLButtonElement>, node: CanvasNode, endpoint: ShapeEndpointKey) => {
event.preventDefault();
event.stopPropagation();
if (mode === "pan" || !isLineShapeNode(node)) return;
selectOnlyNode(node.id);
if (lockedNodeIds.has(node.id)) return;
const startNode = node;
const handleMove = (moveEvent: PointerEvent) => {
const resizedNode = resizeLineShapeEndpoint(startNode, endpoint, canvasPointFromClient(moveEvent.clientX, moveEvent.clientY));
nodesRef.current = nodesRef.current.map((item) => (item.id === node.id ? resizedNode : item));
setNodes((current) => current.map((item) => (item.id === node.id ? resizedNode : item)));
setAlignmentGuides(buildAlignmentGuides([resizedNode], nodesRef.current, viewportRef.current));
setDirty(true);
};
const handleUp = () => {
window.removeEventListener("pointermove", handleMove);
window.removeEventListener("pointerup", handleUp);
setAlignmentGuides([]);
void saveCanvasSnapshot({ quiet: true });
};
window.addEventListener("pointermove", handleMove);
window.addEventListener("pointerup", handleUp);
};
const handleMultiSelectionResizePointerDown = (event: React.PointerEvent<HTMLButtonElement>, handle: MultiSelectionResizeHandle) => {
event.preventDefault();
event.stopPropagation();
if (mode === "pan" || selectedIds.size < 2) return;
const selected = nodesRef.current.filter((node) => selectedIds.has(node.id));
const scalableNodes = selected.filter((node) => !lockedNodeIds.has(node.id) && canResizeCanvasNode(node));
if (selected.length < 2 || scalableNodes.length === 0) return;
const bounds = nodeBounds(selected);
const horizontalDirection = handle.includes("right") ? 1 : -1;
const verticalDirection = handle.includes("bottom") ? 1 : -1;
const anchorX = horizontalDirection > 0 ? bounds.x : bounds.x + bounds.width;
const anchorY = verticalDirection > 0 ? bounds.y : bounds.y + bounds.height;
const start = {
pointerX: event.clientX,
pointerY: event.clientY,
bounds,
anchorX,
anchorY,
nodes: scalableNodes.map((node) => ({ ...node })),
scale: viewport.k
};
const minScale = Math.max(0.04, ...start.nodes.map(minGroupNodeScale));
const handleMove = (moveEvent: PointerEvent) => {
const dx = (moveEvent.clientX - start.pointerX) / start.scale;
const dy = (moveEvent.clientY - start.pointerY) / start.scale;
const widthScale = (start.bounds.width + dx * horizontalDirection) / Math.max(start.bounds.width, 1);
const heightScale = (start.bounds.height + dy * verticalDirection) / Math.max(start.bounds.height, 1);
const pointerScale = Math.abs(widthScale - 1) >= Math.abs(heightScale - 1) ? widthScale : heightScale;
const nextScale = Math.max(minScale, pointerScale);
const resizedById = new Map(start.nodes.map((node) => [node.id, scaleGroupNode(node, start.anchorX, start.anchorY, nextScale)]));
const nextNodes = nodesRef.current.map((node) => resizedById.get(node.id) ?? node);
const resizedNodes = nextNodes.filter((node) => resizedById.has(node.id));
nodesRef.current = nextNodes;
setNodes(nextNodes);
setAlignmentGuides(buildAlignmentGuides(resizedNodes, nextNodes, viewportRef.current));
setDirty(true);
};
const handleUp = () => {
window.removeEventListener("pointermove", handleMove);
window.removeEventListener("pointerup", handleUp);
setAlignmentGuides([]);
void saveCanvasSnapshot({ quiet: true });
};
window.addEventListener("pointermove", handleMove);
window.addEventListener("pointerup", handleUp);
};
const updateNodeContent = (nodeId: string, content: string) => {
const nextNodes = updateNodeContentOp(nodesRef.current, nodeId, content);
nodesRef.current = nextNodes;
setNodes(nextNodes);
setDirty(true);
};
const renameNodeTitle = (nodeId: string, title: string) => {
const nextTitle = title.trim();
if (!nextTitle) return;
const applyPatch = (item: CanvasNode) => (item.id === nodeId ? { ...item, title: nextTitle } : item);
nodesRef.current = nodesRef.current.map(applyPatch);
setNodes((current) => current.map(applyPatch));
setDirty(true);
};
const updateFrameStyle = (nodeId: string, patch: FrameStylePatch) => {
const applyPatch = (item: CanvasNode) => (item.id === nodeId ? patchFrameStyleNode(item, patch) : item);
nodesRef.current = nodesRef.current.map(applyPatch);
setNodes((current) => current.map(applyPatch));
setDirty(true);
};
const updateShapeStyle = (nodeId: string, patch: ShapeStylePatch) => {
const applyPatch = (item: CanvasNode) => (item.id === nodeId && isShapeNode(item) ? patchShapeStyleNode(item, patch) : item);
nodesRef.current = nodesRef.current.map(applyPatch);
setNodes((current) => current.map(applyPatch));
setDirty(true);
};
const updatePenStrokeStyle = (nodeId: string, patch: PenStrokePatch) => {
const applyPatch = (item: CanvasNode) => (item.id === nodeId && isCanvasPenStrokeNode(item) ? updatePenStrokeNode(item, patch, viewportRef.current.k) : item);
nodesRef.current = nodesRef.current.map(applyPatch);
setNodes((current) => current.map(applyPatch));
setDirty(true);
};
const updateTextStyle = (nodeId: string, patch: TextStylePatch) => {
const shouldFit = textStylePatchChangesMetrics(patch);
const applyPatch = (item: CanvasNode) => {
if (item.id !== nodeId || item.type !== "text") return item;
const nextNode = {
...item,
...patch
};
return shouldFit ? fitTextNodeToContent(nextNode, { keepX: true, keepY: true }) : nextNode;
};
nodesRef.current = nodesRef.current.map(applyPatch);
setNodes((current) => current.map(applyPatch));
setDirty(true);
};
const removeNode = (nodeId: string) => {
nodesRef.current = removeNodeById(nodesRef.current, nodeId);
setNodes((current) => removeNodeById(current, nodeId));
setEditingTextNodeId((current) => (current === nodeId ? null : current));
setHiddenNodeIds((current) => {
const next = new Set(current);
next.delete(nodeId);
return next;
});
setLockedNodeIds((current) => {
const next = new Set(current);
next.delete(nodeId);
return next;
});
setSelectedIds((current) => {
const next = new Set(current);
next.delete(nodeId);
return next;
});
if (selectedId === nodeId) selectOnlyNode(null);
setDirty(true);
};
const finishTextEditing = (nodeId: string, contentOverride?: string) => {
const node = nodesRef.current.find((item) => item.id === nodeId);
const content = contentOverride ?? node?.content ?? "";
setEditingTextNodeId(null);
setActiveTool("select");
setMode("select");
if (!node) return;
if (node.type === "text" && node.layerRole === "editable-text" && isBlankTextContent(content)) {
removeNode(nodeId);
return;
}
const nextNode = fitTextNodeToContent({ ...node, content }, { keepX: true, keepY: true });
nodesRef.current = nodesRef.current.map((item) => (item.id === nodeId ? nextNode : item));
setNodes((current) => current.map((item) => (item.id === nodeId ? nextNode : item)));
selectOnlyNode(nodeId);
setDirty(true);
};
const duplicateNode = (node: CanvasNode) => {
const copy = cloneCanvasNode(node, cryptoFallbackId());
setNodes((current) => [...current, copy]);
selectOnlyNode(copy.id);
setDirty(true);
};
const copyNode = (node: CanvasNode) => {
setClipboardNode(node);
void writeCanvasNodeToSystemClipboard(node)
.then(() => showCanvasToast(t("clipboardCopied")))
.catch(() => showCanvasToast(t("clipboardCopyFailed"), "error"));
};
const pasteNode = (sourceNode = clipboardNode) => {
if (!sourceNode) return;
const copy = cloneCanvasNode(sourceNode, cryptoFallbackId());
setNodes((current) => [...current, copy]);
selectOnlyNode(copy.id);
setDirty(true);
};
const reorderNode = (nodeId: string, action: "forward" | "backward" | "front" | "back") => {
setNodes((current) => reorderNodeOp(current, nodeId, action));
setDirty(true);
};
const toggleLayerHidden = (nodeId: string) => {
setHiddenNodeIds((current) => toggleSetValue(current, nodeId));
setDirty(true);
};
const toggleLayerLocked = (nodeId: string) => {
setLockedNodeIds((current) => toggleSetValue(current, nodeId));
setDirty(true);
};
const toggleLayersHidden = (nodeIds: string[]) => {
if (nodeIds.length === 0) return;
setHiddenNodeIds((current) => toggleSetValues(current, nodeIds));
setDirty(true);
};
const toggleLayersLocked = (nodeIds: string[]) => {
if (nodeIds.length === 0) return;
setLockedNodeIds((current) => toggleSetValues(current, nodeIds));
setDirty(true);
};
const updateNodeTransform = (nodeId: string, patch: Partial<NodeTransform> | ((transform: NodeTransform) => Partial<NodeTransform>)) => {
setNodes((current) => updateNodeTransformOp(current, nodeId, patch));
setDirty(true);
};
const toggleNodeTransform = (nodeId: string, key: "flipX" | "flipY") => {
updateNodeTransform(nodeId, (transform) => ({ [key]: !transform[key] }));
};
const rotateNodeClockwise = (nodeId: string) => {
updateNodeTransform(nodeId, (transform) => ({ rotation: transform.rotation + 90 }));
};
const autoLayoutAroundNode = (node: CanvasNode) => {
setNodes((current) => autoLayoutAroundNodeOp(current, node));
selectOnlyNode(node.id);
setDirty(true);
};
const autoLayoutSelectedNodes = () => {
if (selectedIds.size < 2) return;
setNodes((current) => autoLayoutNodes(current, selectedIds));
setDirty(true);
};
const alignSelectedNodes = (alignment: NodeAlignment) => {
if (selectedIds.size < 2) return;
setNodes((current) => alignNodes(current, selectedIds, alignment));
setDirty(true);
};
const distributeSelectedNodes = (direction: NodeDistribution) => {
if (selectedIds.size < 3) return;
setNodes((current) => distributeNodes(current, selectedIds, direction));
setDirty(true);
};
const createGroupFromSelectedNodes = () => {
const selected = nodesRef.current.filter((node) => selectedIds.has(node.id));
if (selected.length < 2) return;
const groupId = `group-${cryptoFallbackId()}`;
setNodes((current) => groupNodes(current, selectedIds, groupId));
setDirty(true);
};
const applyBooleanShapeOperation = (operation: BooleanShapeOperation) => {
const selectedBooleanGroup = nodesRef.current.find((node) => selectedIds.has(node.id) && isBooleanShapeGroupNode(node));
if (selectedBooleanGroup) {
const nextNodes = nodesRef.current.map((node) => (node.id === selectedBooleanGroup.id ? updateBooleanShapeOperation(node, operation) : node));
nodesRef.current = nextNodes;
setNodes(nextNodes);
selectOnlyNode(selectedBooleanGroup.id);
setDirty(true);
return;
}
const selected = nodesRef.current.filter((node) => selectedIds.has(node.id));
if (selected.length < 2 || !selected.every(isBooleanCapableShapeNode)) return;
const groupId = `boolean-${cryptoFallbackId()}`;
const groupNode = createBooleanShapeGroupNode({
id: groupId,
title: operation.toUpperCase(),
operation,
nodes: selected
});
const selectedSet = new Set(selected.map((node) => node.id));
const highestSelectedIndex = Math.max(...nodesRef.current.map((node, index) => (selectedSet.has(node.id) ? index : -1)));
const nextNodes = nodesRef.current
.map((node) => (selectedSet.has(node.id) ? { ...node, parentId: groupId } : node))
.flatMap((node, index) => (index === highestSelectedIndex ? [node, groupNode] : [node]));
nodesRef.current = nextNodes;
setNodes(nextNodes);
selectOnlyNode(groupId);
setDirty(true);
};
const ungroupBooleanShapeGroup = (groupId: string | undefined) => {
if (!groupId) return;
const groupNode = nodesRef.current.find((node) => node.id === groupId && isBooleanShapeGroupNode(node));
if (!groupNode) return;
const childIds = nodesRef.current.filter((node) => node.parentId === groupId).map((node) => node.id);
const nextNodes = nodesRef.current.flatMap((node) => {
if (node.id === groupId) return [];
if (node.parentId === groupId) return [{ ...node, parentId: undefined }];
return [node];
});
nodesRef.current = nextNodes;
setNodes(nextNodes);
selectNodes(childIds);
setDirty(true);
};
const ungroupNodesByGroupId = (groupId: string | undefined) => {
if (!groupId) return;
if (nodesRef.current.some((node) => node.id === groupId && isBooleanShapeGroupNode(node))) {
ungroupBooleanShapeGroup(groupId);
return;
}
const groupNodeIds = nodesRef.current.filter((node) => node.parentId === groupId).map((node) => node.id);
setNodes((current) => ungroupNodes(current, groupId));
selectNodes(groupNodeIds);
setDirty(true);
};
const ungroupSelectedNodes = () => {
const selected = nodesRef.current.filter((node) => selectedIds.has(node.id));
const selectedBooleanGroup = selected.find(isBooleanShapeGroupNode);
if (selectedBooleanGroup) {
ungroupBooleanShapeGroup(selectedBooleanGroup.id);
return;
}
ungroupNodesByGroupId(selected.find((node) => node.parentId)?.parentId);
};
const mergeSelectedNodes = async () => {
const currentProject = projectRef.current;
if (!currentProject) return;
const selected = nodesRef.current.filter((node) => selectedIds.has(node.id));
if (selected.length < 2) return;
const selectedSet = new Set(selected.map((node) => node.id));
const existingIds = new Set(nodesRef.current.map((node) => node.id));
setSaving(true);
setError("");
try {
if (dirty) {
const savedProject = await saveCanvasSnapshot({ quiet: true });
if (!savedProject) return;
}
const result = await designGateway.mergeLayers(currentProject.id, {
nodeIds: selected.map((node) => node.id),
title: t("mergedLayerTitle")
});
applyProject(result.project);
projectRef.current = result.project;
nodesRef.current = result.project.nodes;
setHiddenNodeIds((current) => new Set(Array.from(current).filter((nodeId) => !selectedSet.has(nodeId))));
setLockedNodeIds((current) => new Set(Array.from(current).filter((nodeId) => !selectedSet.has(nodeId))));
selectOnlyNode(result.project.nodes.find((node) => !existingIds.has(node.id))?.id ?? null);
setDirty(false);
setCanvasActionGenerating(true);
startProjectEvents(result.project.id, result.project.lastThreadId, "canvas-action");
} catch (err) {
setError(toUserMessageText(err, "generateError"));
setCanvasActionGenerating(false);
} finally {
setSaving(false);
}
};
const deleteSelectedNodes = () => {
const ids = new Set(selectedNodes.filter((node) => !lockedNodeIds.has(node.id) && !hiddenNodeIds.has(node.id)).map((node) => node.id));
if (ids.size === 0) return;
setNodes((current) => removeNodesById(current, ids));
setHiddenNodeIds((current) => new Set(Array.from(current).filter((nodeId) => !ids.has(nodeId))));
setLockedNodeIds((current) => new Set(Array.from(current).filter((nodeId) => !ids.has(nodeId))));
selectOnlyNode(null);
setDirty(true);
};
const sendNodeToAgentComposer = (node: CanvasNode) => {
if (isRealCanvasImageNode(node)) {
insertCanvasImageReference(node, { focus: true });
return;
}
if (node.type === "text") {
const content = node.content.trim();
if (!content) return;
setPrompt((current) => (current.trim() ? `${current.trim()}\n${content}` : content));
setComposerMode("agent");
}
};
const createNodeContextMenuHandlers = (node: CanvasNode): NodeContextMenuHandlers => ({
copy: () => copyNode(node),
cut: () => {
copyNode(node);
removeNode(node.id);
},
paste: pasteNode,
duplicate: () => duplicateNode(node),
bringForward: () => reorderNode(node.id, "forward"),
sendBackward: () => reorderNode(node.id, "backward"),
bringToFront: () => reorderNode(node.id, "front"),
sendToBack: () => reorderNode(node.id, "back"),
sendToChat: () => sendNodeToAgentComposer(node),
createGroup: createGroupFromSelectedNodes,
ungroup: () => (isBooleanShapeGroupNode(node) ? ungroupBooleanShapeGroup(node.id) : ungroupNodesByGroupId(node.parentId)),
autoLayout: () => autoLayoutAroundNode(node),
mergeLayers: () => void mergeSelectedNodes(),
toggleHidden: () => toggleLayerHidden(node.id),
toggleLocked: () => toggleLayerLocked(node.id),
flipHorizontal: () => toggleNodeTransform(node.id, "flipX"),
flipVertical: () => toggleNodeTransform(node.id, "flipY"),
exportFormats: node.type === "text" ? ["svg"] : ["png", "jpg", "svg"],
exportAs: (format) => exportCanvasNode(node, format),
deleteNode: () => removeNode(node.id)
});
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (isEditableShortcutTarget(event.target)) return;
const key = event.key.toLowerCase();
const primary = isPrimaryShortcut(event);
const selected = selectedNode;
const selectedHandlers = selected && !lockedNodeIds.has(selected.id) && canUseNodeContextMenu(selected) ? createNodeContextMenuHandlers(selected) : null;
const run = (action: (() => void) | undefined, prevent = true) => {
if (!action) return false;
if (prevent) event.preventDefault();
action();
return true;
};
if (!primary && !event.shiftKey && !event.altKey && key === "escape" && (imageActionMode || mockupPendingTargetId)) {
return void run(() => {
setImageActionMode(null);
setMockupDropActive(false);
setMockupDragNodeId(null);
setMockupPendingTargetId(null);
setMockupReadyTargetId(null);
setMockupPlacement(null);
setMockupSurface(null);
setMockupModel(null);
mockupPrepareRequestRef.current += 1;
if (mockupPrepareTimerRef.current) {
clearTimeout(mockupPrepareTimerRef.current);
mockupPrepareTimerRef.current = null;
}
});
}
if (!primary && !event.shiftKey && !event.altKey && key === "v") return void run(() => chooseCanvasTool("select"));
if (!primary && !event.shiftKey && !event.altKey && key === "h") return void run(() => chooseCanvasTool("pan"));
if (!primary && !event.shiftKey && !event.altKey && key === "f") return void run(() => chooseCanvasTool("frame"));
if (!primary && !event.shiftKey && !event.altKey && key === "t") return void run(() => chooseCanvasTool("text"));
if (!primary && !event.shiftKey && !event.altKey && key === "r") return void run(() => chooseShapeTool("rectangle"));
if (!primary && !event.shiftKey && !event.altKey && key === "l") return void run(() => chooseShapeTool("line"));
if (!primary && event.shiftKey && !event.altKey && key === "l") return void run(() => chooseShapeTool("arrow"));
if (!primary && !event.shiftKey && !event.altKey && key === "o") return void run(() => chooseShapeTool("ellipse"));
if (primary && (key === "+" || key === "=")) return void run(() => zoomCanvasTo(viewportRef.current.k + 0.1));
if (primary && !event.shiftKey && key === "-") return void run(() => zoomCanvasTo(viewportRef.current.k - 0.1));
if (!primary && event.shiftKey && (key === "!" || key === "1")) return void run(fitCanvasToAllNodes);
if (primary && !event.shiftKey && key === "c") return void run(selectedHandlers?.copy);
if (primary && !event.shiftKey && key === "x") return void run(selectedHandlers?.cut);
if (primary && !event.shiftKey && key === "d") return void run(selectedHandlers?.duplicate);
if (primary && !event.shiftKey && event.key === "]") return void run(selectedHandlers?.bringForward);
if (primary && !event.shiftKey && event.key === "[") return void run(selectedHandlers?.sendBackward);
if (!primary && !event.shiftKey && event.key === "]") return void run(selectedHandlers?.bringToFront);
if (!primary && !event.shiftKey && event.key === "[") return void run(selectedHandlers?.sendToBack);
if (primary && event.shiftKey && key === "g") return void run(ungroupSelectedNodes);
if (primary && !event.shiftKey && key === "g") return void run(createGroupFromSelectedNodes);
if (!primary && event.shiftKey && key === "a") return void run(selectedHandlers?.autoLayout);
if (primary && !event.shiftKey && key === "e") return void run(selectedHandlers?.mergeLayers);
if (primary && event.shiftKey && key === "h") return void run(selectedHandlers?.toggleHidden);
if (primary && event.shiftKey && key === "l") return void run(selectedHandlers?.toggleLocked);
if ((event.key === "Backspace" || event.key === "Delete") && selectedNodes.length > 1) return void run(deleteSelectedNodes);
if ((event.key === "Backspace" || event.key === "Delete") && selected) return void run(selectedHandlers?.deleteNode);
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
});
useEffect(() => {
const handlePaste = (event: ClipboardEvent) => {
if (isEditableShortcutTarget(event.target)) return;
const imageFiles = event.clipboardData ? dataTransferImageFiles(event.clipboardData, "clipboard-image") : [];
const pasteImageFiles = () => {
const pointer = lastCanvasPointerRef.current;
const anchor = pointer ? canvasPointFromClient(pointer.clientX, pointer.clientY) : canvasCenterPoint();
void uploadImagesToCanvas(imageFiles, anchor);
};
if (clipboardNode && imageFiles.length > 0 && isTransparentClipboardNode(clipboardNode)) {
event.preventDefault();
void shouldUseInternalClipboardNode(clipboardNode, imageFiles).then((useInternalNode) => {
if (useInternalNode) {
pasteNode(clipboardNode);
return;
}
pasteImageFiles();
});
return;
}
if (imageFiles.length > 0) {
event.preventDefault();
pasteImageFiles();
return;
}
if (!clipboardNode) return;
event.preventDefault();
pasteNode();
};
window.addEventListener("paste", handlePaste);
return () => window.removeEventListener("paste", handlePaste);
});
const addCanvasNode = (type: CanvasNode["type"]) => {
const rect = containerRef.current?.getBoundingClientRect();
const stageWidth = rect?.width ?? 1100;
const stageHeight = rect?.height ?? 760;
const centerX = (stageWidth / 2 - viewport.x) / viewport.k;
const centerY = (stageHeight / 2 - viewport.y) / viewport.k;
const size =
type === "text"
? { width: 280, height: 180 }
: type === "image"
? { width: 360, height: 460 }
: { width: 520, height: 320 };
const node: CanvasNode = {
id: typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID() : `${Date.now()}-${type}`,
type,
title: type === "text" ? t("newTextNode") : type === "image" ? t("newImageNode") : t("newFrameNode"),
x: centerX - size.width / 2,
y: centerY - size.height / 2,
width: size.width,
height: size.height,
content: type === "text" ? t("newTextNodeContent") : project?.brief ?? t("unnamedCanvasProject"),
tone: "natural",
status: "draft"
};
setNodes((current) => [...current, node]);
selectOnlyNode(node.id);
setDirty(true);
};
const addImageGeneratorNode = (point?: { x: number; y: number }) => {
const rect = containerRef.current?.getBoundingClientRect();
const stageWidth = rect?.width ?? 1100;
const stageHeight = rect?.height ?? 760;
const center = {
x: (stageWidth / 2 - viewport.x) / viewport.k,
y: (stageHeight / 2 - viewport.y) / viewport.k
};
const origin = point ?? center;
const size = imageGeneratorPlaceholderSize(composerImageSettings, viewport.k, { width: stageWidth, height: stageHeight });
const node: CanvasNode = {
id: cryptoFallbackId(),
type: "image",
title: t("imageGeneratorNode"),
x: origin.x - size.width / 2,
y: origin.y - size.height / 2,
width: size.width,
height: size.height,
content: "",
tone: "image-generator",
status: "draft",
layerRole: "image-generator"
};
setComposerMode("image");
setActiveTool("select");
setMode("select");
setNodes((current) => {
const next = [...current, node];
nodesRef.current = next;
return next;
});
selectOnlyNode(node.id);
setDirty(true);
};
const updateComposerImageSettings = (settings: ComposerImageSettings) => {
setComposerImageSettings(settings);
const generator = selectedImageGeneratorNode;
if (!generator) return;
const size = imageGeneratorPlaceholderSize(settings, viewportRef.current.k, canvasStageSize);
setNodes((current) => {
const next = current.map((node) => {
if (node.id !== generator.id || node.type !== "image" || node.layerRole !== "image-generator") return node;
const centerX = node.x + node.width / 2;
const centerY = node.y + node.height / 2;
return {
...node,
x: centerX - size.width / 2,
y: centerY - size.height / 2,
width: size.width,
height: size.height
};
});
nodesRef.current = next;
return next;
});
setDirty(true);
};
const addTextNodeAt = (point?: { x: number; y: number }) => {
const rect = containerRef.current?.getBoundingClientRect();
const stageWidth = rect?.width ?? 1100;
const stageHeight = rect?.height ?? 760;
const currentViewport = viewportRef.current;
const center = {
x: (stageWidth / 2 - currentViewport.x) / currentViewport.k,
y: (stageHeight / 2 - currentViewport.y) / currentViewport.k
};
const origin = point ?? center;
const zoom = Math.max(currentViewport.k, 0.01);
const screenFontSize = defaultTextVisibleFontSize;
const size = { width: defaultTextVisibleBox.width / zoom, height: defaultTextVisibleBox.height / zoom };
const x = point ? origin.x : origin.x - size.width / 2;
const y = point ? origin.y : origin.y - size.height / 2;
const node: CanvasNode = {
id: cryptoFallbackId(),
type: "text",
title: t("newTextNode"),
x,
y,
width: size.width,
height: size.height,
content: "",
tone: "ink",
status: "draft",
layerRole: "editable-text",
fontFamily: "Inter",
fontWeight: "Regular",
fontSize: screenFontSize / zoom,
color: "#000000",
textAlign: "left",
opacity: 1,
strokeColor: "#000000",
strokeWidth: 0,
lineHeight: 0,
letterSpacing: 0,
textDecoration: "none",
textTransform: "none"
};
setNodes((current) => {
const next = [...current, node];
nodesRef.current = next;
return next;
});
selectOnlyNode(node.id);
setEditingTextNodeId(node.id);
setDirty(true);
};
const chooseCanvasTool = (tool: CanvasTool) => {
setActiveTool(tool);
setCanvasToolMenuOpen(false);
if (tool !== "shape") setShapeToolMenuOpen(false);
if (tool === "select" || tool === "pin") {
setMode("select");
return;
}
if (tool === "pan") {
setMode("pan");
return;
}
if (tool === "image") {
addImageGeneratorNode();
return;
}
if (tool === "frame") {
setMode("select");
return;
}
if (tool === "shape") {
setMode("select");
return;
}
if (tool === "pen") {
setMode("select");
return;
}
setMode("select");
};
const chooseShapeTool = (kind: ShapeKind) => {
setSelectedShapeKind(kind);
setShapeToolMenuOpen(false);
chooseCanvasTool("shape");
};
const shareProject = () => {
if (!project) return;
const shareUrl = window.location.href;
const shareTitle = project.title || t("canvasUntitled");
if (navigator.share) {
void navigator.share({ title: shareTitle, url: shareUrl }).catch((err: unknown) => {
if (err instanceof DOMException && err.name === "AbortError") return;
showCanvasToast(t("shareUnavailable"), "error");
});
return;
}
if (!navigator.clipboard?.writeText) {
showCanvasToast(t("shareUnavailable"), "error");
return;
}
void navigator.clipboard.writeText(shareUrl).then(() => showCanvasToast(t("shareLinkCopied"))).catch(() => showCanvasToast(t("shareUnavailable"), "error"));
};
const syncReferenceImages = (references: UploadedReferenceImage[]) => {
referenceImagesRef.current = references;
setReferenceImages(references);
};
const handleReferenceRemoved = (reference: UploadedReferenceImage) => {
revokeReferenceImage(reference);
if (reference.external) return;
if (isCanvasNodeReferenceImage(reference)) return;
if (isAssetReferencedByCanvas(reference.publicUrl, nodesRef.current)) return;
if (reference.publicUrl) {
void designGateway.deleteAsset(reference.publicUrl).catch((err: Error) => console.warn("Failed to queue reference image cleanup", err));
}
};
const beginNewAgentConversation = () => {
selectedAgentThreadIdRef.current = newAgentConversationId;
setSelectedAgentThreadId(newAgentConversationId);
setAgentHistoryOpen(false);
setPrompt("");
setMessages([]);
setError("");
agentComposerRef.current?.reset("");
activeAgentThreadIdRef.current = null;
};
const selectAgentThread = (threadId: string) => {
selectedAgentThreadIdRef.current = threadId;
setSelectedAgentThreadId(threadId);
setAgentHistoryOpen(false);
const currentProject = projectRef.current;
if (currentProject) {
setMessages(visibleAgentMessagesForThread(currentProject, imageGeneratorThreadIdsRef.current, threadId));
}
};
const removeAgentThreadFromMenu = (threadId: string) => {
setAgentThreads((current) => current.filter((thread) => thread.agentThreadId !== threadId));
};
const generate = async (source: "agent" | "image-generator" = "agent", imageGeneratorContents?: AgentContent[], resetImageGenerator?: () => void) => {
const selectedGeneratorNode = source === "image-generator" && selectedImageGeneratorNode ? selectedImageGeneratorNode : null;
const activeMode: ComposerMode = source === "image-generator" ? "image" : composerMode;
const enableSearch = source === "agent" && useAgentWebSearch;
const activeRef = agentComposerRef;
const activePrompt = prompt;
const baseContents =
source === "image-generator"
? imageGeneratorContents ?? []
: activeRef.current?.toAgentContents() ?? [{ type: "text" as const, text: activePrompt, textSource: "input" }];
const hasUserText = baseContents.some((content) => content.type === "text" && content.text.replace(/\u00a0/g, " ").trim());
const contents =
activeMode === "image"
? withImageModeSettings(baseContents, composerImageSettings, t, selectedGeneratorNode?.id)
: baseContents;
if (!project || !hasUserText) return;
const selectedThreadId = selectedAgentThreadIdRef.current;
const reusableAgentThreadId =
source === "agent" && selectedThreadId !== newAgentConversationId
? selectedThreadId || latestAgentThreadId(project, imageGeneratorThreadIdsRef.current)
: "";
const threadId = reusableAgentThreadId || cryptoFallbackId();
if (source === "image-generator") {
setImageGeneratorGenerating(true);
if (selectedGeneratorNode) {
const lockedPrompt = imageGeneratorSubmittedPrompt(baseContents);
setNodes((current) => {
const next = current.map((node) =>
node.id === selectedGeneratorNode.id && node.type === "image" && node.layerRole === "image-generator"
? { ...node, content: lockedPrompt, status: "generating" }
: node
);
nodesRef.current = next;
return next;
});
}
} else {
activeAgentThreadIdRef.current = threadId;
selectedAgentThreadIdRef.current = threadId;
setSelectedAgentThreadId(threadId);
setAgentGenerating(true);
}
setError("");
if (source === "image-generator") {
imageGeneratorThreadIdsRef.current.add(threadId);
} else {
setMessages((current) => [...current, ...createOptimisticAgentMessages(threadId, contents)]);
}
try {
if (dirty) {
await saveCanvasSnapshot({ quiet: true });
}
startAgentSocket(
project.id,
{
messages: [{ role: "user", contents }],
mode: source === "image-generator" ? "image-generator" : activeMode === "image" ? "image" : "design",
threadId,
threadIdType: 9,
imageModel: toImageApiModel(selectedCanvasModel.id),
imageSize: activeMode === "image" ? composerImageSize(composerImageSettings) : undefined,
enableWebSearch: enableSearch,
toolConfig: {
preferToolCategories: {
...(enableSearch ? { SEARCH: ["search_web_info", "search_images"] } : {}),
IMAGE: ["generate_image_gpt_image_2"]
}
}
},
source
);
if (source === "image-generator") {
resetImageGenerator?.();
} else {
activeRef.current?.reset("");
setPrompt("");
}
setDirty(false);
} catch (err) {
setError(toUserMessageText(err, "generateError"));
if (source === "image-generator") {
setImageGeneratorGenerating(false);
} else {
activeAgentThreadIdRef.current = null;
setAgentGenerating(false);
}
}
};
const stopAgentTask = async () => {
if (!project) return;
const threadId = activeAgentThreadIdRef.current || project.lastThreadId;
stopGenerationStream("agent");
activeAgentThreadIdRef.current = null;
setAgentGenerating(false);
setError("");
try {
const { project: nextProject } = await designGateway.stopAgentTask(project.id, threadId);
applyProject(nextProject);
} catch (err) {
setError(toUserMessageText(err, "generateError"));
}
};
const runImageNodeAction = async (action: NodeActionRequest["action"], request: Omit<NodeActionRequest, "action"> = {}) => {
const targetNode = selectedImageNode;
if (!project || !targetNode || isImageActionBusy(targetNode)) return;
setCanvasActionGenerating(true);
setError("");
try {
if (dirty) {
const savedProject = await saveCanvasSnapshot({ quiet: true });
if (!savedProject) {
setCanvasActionGenerating(false);
return;
}
}
const result = await designGateway.runNodeActionAsync(project.id, targetNode.id, {
action,
imageModel: toImageApiModel(selectedCanvasModel.id),
imageSize: defaultImageSize(),
...request
});
applyProject(result.project);
startProjectEvents(result.project.id, result.project.lastThreadId, "canvas-action");
setImageActionMode(null);
setEraserMarks([]);
setTextEditItems([]);
setTextEditLoading(false);
setTextEditError("");
setMoveSelection(null);
setMoveRecognitionLabel("");
setMoveRecognitionLoading(false);
setCropSelection(null);
setQuickEditPrompt("");
setDirty(false);
} catch (err) {
setError(toUserMessageText(err, "generateError"));
setCanvasActionGenerating(false);
setMoveRecognitionLoading(false);
}
};
const syncProjectWithLocalCanvas = (nextProject: Project, nextNodes = nodesRef.current) => {
const syncedProject = {
...nextProject,
viewport: viewportRef.current,
nodes: nextNodes
};
projectRef.current = syncedProject;
setProject(syncedProject);
return syncedProject;
};
const prepareMockupPlacement = () => {
const currentProject = projectRef.current ?? project;
if (!currentProject || !selectedImageNode || selectedImageNodeBusy || !isRealCanvasImageNode(selectedImageNode)) return;
const targetId = selectedImageNode.id;
const requestId = mockupPrepareRequestRef.current + 1;
mockupPrepareRequestRef.current = requestId;
if (mockupPrepareTimerRef.current) clearTimeout(mockupPrepareTimerRef.current);
setMockupDropActive(false);
setMockupDragNodeId(null);
setMockupReadyTargetId(null);
setMockupPlacement(null);
setMockupSurface(null);
setMockupModel(null);
setMockupPendingTargetId(targetId);
setImageActionMode(null);
let closeMockupEvents: (() => void) | null = null;
let settled = false;
const minimumPrepareTime = new Promise<void>((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<typeof setTimeout> | null = null;
let closed = false;
closeMockupEvents = () => {
closed = true;
if (timer) clearTimeout(timer);
};
const poll = async () => {
try {
const task = await designGateway.getGeneratorTask(response.project.id, response.threadId);
if (closed || mockupPrepareRequestRef.current !== requestId) return;
if (task.data.status === "failed") {
const { project: nextProject } = await designGateway.getProject(response.project.id);
if (closed || mockupPrepareRequestRef.current !== requestId) return;
failPreparation(projectFailureMessage(nextProject) || t("generateError"));
return;
}
if (task.data.status === "completed") {
const { project: nextProject } = await designGateway.getProject(response.project.id);
if (closed || mockupPrepareRequestRef.current !== requestId) return;
const ready = await finishFromProject(nextProject);
if (ready) return;
failPreparation(projectFailureMessage(nextProject) || t("generateError"));
return;
}
timer = setTimeout(poll, generatorTaskPollIntervalMs);
} catch (err) {
if (!closed) failPreparation(toUserMessageText(err, "generateError"));
}
};
void poll();
} catch (err) {
if (mockupPrepareRequestRef.current !== requestId) return;
failPreparation(toUserMessageText(err, "generateError"));
}
})();
};
const applyMockupPrint = async (sourceNode: CanvasNode, targetNode: CanvasNode, placement: MockupPlacement, surface: MockupSurface, model: MockupModel, dropPoint?: { x: number; y: number }) => {
if (!project || !isRealCanvasImageNode(sourceNode)) return;
const printPlacement = mockupPrintPlacementForDrop(sourceNode, targetNode, placement, dropPoint);
const fit = mockupPlacementWorldRect(targetNode, printPlacement);
let content = sourceNode.content;
try {
content = await renderMockupPrintTexture(sourceNode.content, targetNode.content, printPlacement, fit, model);
} catch (err) {
console.warn("Failed to render mockup texture preview", err);
}
const printNode: CanvasNode = {
id: cryptoFallbackId(),
type: "image",
title: t("mockupPrintTitle"),
x: fit.x,
y: fit.y,
width: fit.width,
height: fit.height,
content,
tone: "mockup-print",
status: "success",
layerRole: "mockup-print",
opacity: 0.92,
mockupSurface: surface,
mockupModel: model,
mockupSourceContent: sourceNode.mockupSourceContent || sourceNode.content
};
const targetIndex = nodesRef.current.findIndex((node) => node.id === targetNode.id);
const nextNodes = [...nodesRef.current];
nextNodes.splice(targetIndex >= 0 ? targetIndex + 1 : nextNodes.length, 0, printNode);
nodesRef.current = nextNodes;
setNodes(nextNodes);
selectOnlyNode(printNode.id);
setImageActionMode(null);
setMockupDropActive(false);
setMockupReadyTargetId(null);
setMockupPlacement(null);
setMockupSurface(null);
setMockupModel(null);
setDirty(true);
void saveCanvasSnapshot({ quiet: true });
};
const refreshMockupPrintTextures = async (nodeIds: string[]) => {
const nextNodes = [...nodesRef.current];
let changed = false;
for (const nodeId of nodeIds) {
const index = nextNodes.findIndex((node) => node.id === nodeId);
const printNode = nextNodes[index];
const targetId = printNode?.mockupSurface?.targetId;
const model = printNode?.mockupModel;
if (!printNode || printNode.layerRole !== "mockup-print" || !printNode.mockupSourceContent || !targetId || !model) continue;
const targetNode = nextNodes.find((node) => node.id === targetId);
if (!targetNode || !isRealCanvasImageNode(targetNode)) continue;
try {
const placement = mockupPlacementFromPrintNode(printNode, targetNode);
const content = await renderMockupPrintTexture(printNode.mockupSourceContent, targetNode.content, placement, { width: printNode.width, height: printNode.height }, model);
nextNodes[index] = { ...printNode, content };
changed = true;
} catch (err) {
console.warn("Failed to refresh mockup texture preview", err);
}
}
if (!changed) {
void saveCanvasSnapshot({ quiet: true });
return;
}
nodesRef.current = nextNodes;
setNodes(nextNodes);
setDirty(true);
void saveCanvasSnapshot({ quiet: true });
};
const restoreQuickEditViewport = useCallback(() => {
const focus = quickEditFocusRef.current;
if (focus) {
viewportRef.current = focus.viewport;
setViewport(focus.viewport);
setDirty(focus.dirty);
quickEditFocusRef.current = null;
}
setImageActionMode(null);
}, [setViewport, viewportRef]);
useEffect(() => {
if (imageActionMode !== "quick-edit") return;
const handlePointerDown = (event: PointerEvent) => {
const target = event.target as Element | null;
if (target?.closest(".quick-edit-panel")) return;
restoreQuickEditViewport();
};
document.addEventListener("pointerdown", handlePointerDown, true);
return () => document.removeEventListener("pointerdown", handlePointerDown, true);
}, [imageActionMode, restoreQuickEditViewport]);
const applyQuickEdit = () => {
if (!quickEditPrompt.trim()) return;
restoreQuickEditViewport();
void runImageNodeAction("quick-edit", { prompt: quickEditPrompt.trim() });
};
const openQuickEditPanel = () => {
if (!selectedImageNode) return;
const stageWidth = canvasStageSize.width;
const stageHeight = canvasStageSize.height;
if (!quickEditFocusRef.current) {
quickEditFocusRef.current = {
viewport: { ...viewportRef.current },
dirty
};
}
if (stageWidth > 0 && stageHeight > 0) {
const topMargin = 52;
const sideMargin = 56;
const footerReserve = 188;
const availableWidth = Math.max(320, stageWidth - sideMargin * 2);
const availableHeight = Math.max(240, stageHeight - topMargin - footerReserve);
const scale = clamp(Math.min(availableWidth / Math.max(selectedImageNode.width, 1), availableHeight / Math.max(selectedImageNode.height, 1)), minCanvasZoom, maxCanvasZoom);
const nextViewport = {
x: stageWidth / 2 - (selectedImageNode.x + selectedImageNode.width / 2) * scale,
y: topMargin - selectedImageNode.y * scale,
k: scale
};
viewportRef.current = nextViewport;
setViewport(nextViewport);
}
setMode("select");
setImageActionMode("quick-edit");
};
const applyEraserEdit = () => {
if (eraserMarks.length === 0) return;
void runImageNodeAction("eraser", {
prompt: t("eraserDefaultPrompt"),
mask: JSON.stringify({ brushSize: eraserBrushSize, brushColor: eraserBrushColor, brushOpacity: eraserBrushOpacity, points: eraserMarks })
});
};
const openTextEditPanel = async () => {
if (!project || !selectedImageNode || isImageActionBusy(selectedImageNode)) return;
const targetNodeId = selectedImageNode.id;
textExtractionEventsCloseRef.current?.();
textExtractionEventsCloseRef.current = null;
setImageActionMode(null);
setTextEditItems([]);
setTextEditLoading(true);
setTextEditError("");
setCanvasActionGenerating(true);
try {
const response = await designGateway.extractNodeTextAsync(project.id, targetNodeId, "edit-text");
applyProject(response.project, { preserveMessages: true });
const openPanelWithResult = (result: ExtractNodeTextResponse) => {
setTextEditItems(result.items.map(toEditableTextLayer));
setTextEditError("");
setImageActionMode("edit-text");
setTextEditLoading(false);
};
let closed = false;
let timer: ReturnType<typeof setTimeout> | null = null;
const closePolling = () => {
closed = true;
if (timer) clearTimeout(timer);
};
textExtractionEventsCloseRef.current = closePolling;
const poll = async () => {
try {
const task = await designGateway.getGeneratorTask(response.project.id, response.threadId);
if (closed) return;
if (task.data.status === "completed") {
const { project: nextProject } = await designGateway.getProject(response.project.id);
if (closed) return;
applyProject(nextProject, { preserveMessages: true });
const result = textExtractionResultFromProject(nextProject, response.threadId);
if (result) openPanelWithResult(result);
setCanvasActionGenerating(false);
textExtractionEventsCloseRef.current = null;
return;
}
if (task.data.status === "failed") {
const { project: nextProject } = await designGateway.getProject(response.project.id);
if (closed) return;
applyProject(nextProject, { preserveMessages: true });
setTextEditLoading(false);
setCanvasActionGenerating(false);
setError(projectFailureMessage(nextProject) || t("editTextOcrFailed"));
textExtractionEventsCloseRef.current = null;
return;
}
timer = setTimeout(poll, generatorTaskPollIntervalMs);
} catch (nextError) {
if (closed) return;
setTextEditLoading(false);
setCanvasActionGenerating(false);
setError(toUserMessageText(nextError, "editTextOcrFailed"));
textExtractionEventsCloseRef.current = null;
}
};
void poll();
} catch (err) {
setError(toUserMessageText(err, "editTextOcrFailed"));
setTextEditLoading(false);
setCanvasActionGenerating(false);
}
};
const updateTextEditItem = (id: string, text: string) => {
setTextEditItems((current) => current.map((item) => (item.id === id ? { ...item, text, removed: false } : item)));
};
const removeTextEditItem = (id: string) => {
setTextEditItems((current) => current.map((item) => (item.id === id ? { ...item, text: "", removed: true } : item)));
};
const cancelTextEdit = () => {
textExtractionEventsCloseRef.current?.();
textExtractionEventsCloseRef.current = null;
setImageActionMode(null);
setTextEditItems([]);
setTextEditLoading(false);
setTextEditError("");
};
const applyTextEdit = () => {
if (textEditItems.length === 0 || textEditLoading) return;
void runImageNodeAction("edit-text", {
prompt: buildTextEditPrompt(textEditItems, t("editTextPrompt")),
options: {
textLayers: JSON.stringify(textEditItems.map(toTextLayerPayload))
}
});
};
const applyMoveObject = () => {
if (!moveSelection?.dstBox) return;
const objectLabel = moveRecognitionLabel.trim();
void runImageNodeAction("move-object", {
prompt: movePrompt.trim() || t("moveDefaultPrompt"),
selection: JSON.stringify({
src_box: moveSelection.srcBox,
dst_box: moveSelection.dstBox
}),
options: objectLabel ? { object_label: objectLabel } : undefined
});
};
const applyMultiAngle = () => {
void runImageNodeAction("multi-angle", {
prompt: multiAngleOptions.prompt.trim() || t("multiAngleDefaultPrompt"),
options: {
tab: multiAngleOptions.tab,
rotation: String(multiAngleOptions.yaw),
tilt: String(multiAngleOptions.pitch),
zoom: String(multiAngleOptions.zoom)
}
});
};
const updateSelectedImageAdjustments = (nextAdjustments: AdjustOptions) => {
setAdjustOptions(nextAdjustments);
const targetId = selectedImageNode?.id;
if (!targetId) return;
const imageAdjustments = serializeImageAdjustments(nextAdjustments) || undefined;
const applyPatch = (node: CanvasNode) => (node.id === targetId ? { ...node, imageAdjustments } : node);
const nextNodes = nodesRef.current.map(applyPatch);
nodesRef.current = nextNodes;
setNodes((current) => current.map(applyPatch));
setDirty(true);
};
const openAdjustPanel = () => {
setAdjustOptions(parseImageAdjustments(selectedImageNode?.imageAdjustments));
setImageActionMode("adjust");
};
const autoAdjustSelectedImage = () => {
updateSelectedImageAdjustments(autoImageAdjustments(adjustOptions));
};
const openExpandPanel = () => {
setExpandOptions((current) => ({ ...current, imageRect: undefined }));
setImageActionMode("expand");
};
const applyExpandImage = () => {
if (!selectedImageNode) return;
const target = expandTargetGeometryForNode(selectedImageNode, expandOptions);
void runImageNodeAction("expand", {
prompt: expandOptions.prompt.trim() || t("expandPrompt"),
imageSize: expandImageSize(expandOptions.ratio, target.width / Math.max(target.height, 1)),
selection: JSON.stringify({
target: { x: target.x, y: target.y, width: target.width, height: target.height },
source: target.imageRect
}),
options: {
ratio: expandOptions.ratio,
scale: `${expandOptions.scale}x`,
targetX: String(target.x),
targetY: String(target.y),
targetWidth: String(target.width),
targetHeight: String(target.height),
sourceRect: JSON.stringify(target.imageRect)
}
});
};
const applyCropImage = async () => {
const targetNode = selectedImageNode;
const currentProject = projectRef.current ?? project;
if (!targetNode || !currentProject) return;
const selection = cropSelection ?? defaultCropSelection;
if (selection.width < 0.02 || selection.height < 0.02) return;
setSaving(true);
setError("");
try {
const cropped = await cropImageNodeFile(targetNode, selection);
const publicUrl = await designGateway.uploadImage(cropped.file);
const nextNodes = nodesRef.current.map((node) =>
node.id === targetNode.id
? {
...node,
x: targetNode.x + targetNode.width * selection.x,
y: targetNode.y + targetNode.height * selection.y,
width: cropped.displayWidth,
height: cropped.displayHeight,
content: publicUrl,
status: "success"
}
: node
);
nodesRef.current = nextNodes;
setNodes(nextNodes);
setImageActionMode(null);
setCropSelection(null);
setCropPrompt("");
setDirty(true);
const { project: savedProject } = await designGateway.saveCanvas(currentProject.id, viewportRef.current, nextNodes, [], {
version: currentProject.version,
snapshotId: currentProject.snapshotId,
canvas: currentProject.canvas
});
applyProject(savedProject);
setDirty(false);
} catch (err) {
setError(toUserMessageText(err, "generateError"));
} finally {
setSaving(false);
}
};
const attachImage = async (file: File | null) => {
if (!file) return;
const localPreviewUrl = URL.createObjectURL(file);
try {
const publicUrl = await designGateway.uploadImage(file);
const previewUrl = publicUrl || localPreviewUrl;
if (previewUrl !== localPreviewUrl) URL.revokeObjectURL(localPreviewUrl);
const reference = {
id: `${Date.now()}-${file.name}`,
name: file.name,
publicUrl,
previewUrl
};
if (agentComposerRef.current) {
agentComposerRef.current.insertReference(reference);
} else {
syncReferenceImages([...referenceImagesRef.current, reference]);
}
} catch (err) {
URL.revokeObjectURL(localPreviewUrl);
setError(toUserMessageText(err, "generateError"));
}
};
const uploadReferenceToImageGenerator = async (file: File | null) => {
if (!file) return null;
const localPreviewUrl = URL.createObjectURL(file);
try {
const publicUrl = await designGateway.uploadImage(file);
const previewUrl = publicUrl || localPreviewUrl;
if (previewUrl !== localPreviewUrl) URL.revokeObjectURL(localPreviewUrl);
const reference = {
id: `${Date.now()}-${file.name}`,
name: file.name,
publicUrl,
previewUrl
};
return reference;
} catch (err) {
URL.revokeObjectURL(localPreviewUrl);
setError(toUserMessageText(err, "generateError"));
return null;
}
};
const canvasCenterPoint = () => {
const rect = containerRef.current?.getBoundingClientRect();
const currentViewport = viewportRef.current;
return {
x: ((rect?.width ?? 1100) / 2 - currentViewport.x) / currentViewport.k,
y: ((rect?.height ?? 760) / 2 - currentViewport.y) / currentViewport.k
};
};
const uploadImagesToCanvas = async (files: File[], anchor = canvasCenterPoint()) => {
const currentProject = projectRef.current;
const imageFiles = files.filter(isImageFile);
if (!currentProject || imageFiles.length === 0) return;
setSaving(true);
setError("");
const preparedUploads: Array<{ file: File; previewUrl: string; node: CanvasNode }> = [];
try {
for (const [index, file] of imageFiles.entries()) {
const previewUrl = URL.createObjectURL(file);
const dimensions = await readImageDimensions(file).catch(() => ({ width: 512, height: 512 }));
const size = fitCanvasImageSize(dimensions.width, dimensions.height);
preparedUploads.push({
file,
previewUrl,
node: {
id: cryptoFallbackId(),
type: "image",
title: file.name || t("newImageNode"),
x: anchor.x - size.width / 2 + index * 36,
y: anchor.y - size.height / 2 + index * 36,
width: size.width,
height: size.height,
content: previewUrl,
tone: "natural",
status: "uploading"
}
});
}
const optimisticNodes = preparedUploads.map((item) => item.node);
const nextNodesWithUploads = [...nodesRef.current, ...optimisticNodes];
nodesRef.current = nextNodesWithUploads;
setNodes(nextNodesWithUploads);
selectNodes(optimisticNodes.map((node) => node.id));
setDirty(true);
const uploadedResults = await Promise.allSettled(
preparedUploads.map(async ({ file, node }) => ({
id: node.id,
publicUrl: await designGateway.uploadImage(file)
}))
);
const uploadedById = new Map<string, string>();
const failedIds = new Set<string>();
uploadedResults.forEach((result, index) => {
const nodeId = preparedUploads[index]?.node.id;
if (!nodeId) return;
if (result.status === "fulfilled") uploadedById.set(result.value.id, result.value.publicUrl);
else failedIds.add(nodeId);
});
const finalUploadedNodeIds = new Set(uploadedById.keys());
const finalNodes = nodesRef.current
.filter((node) => !failedIds.has(node.id))
.map((node) => {
const publicUrl = uploadedById.get(node.id);
return publicUrl ? { ...node, content: publicUrl, status: "success" } : node;
});
nodesRef.current = finalNodes;
setNodes(finalNodes);
selectNodes(finalNodes.filter((node) => finalUploadedNodeIds.has(node.id)).map((node) => node.id));
preparedUploads.forEach((item) => URL.revokeObjectURL(item.previewUrl));
if (failedIds.size > 0) {
setError(t("generateError"));
}
const { project: savedProject } = await designGateway.saveCanvas(currentProject.id, viewportRef.current, finalNodes, [], {
version: currentProject.version,
snapshotId: currentProject.snapshotId,
canvas: ""
});
applyProject(savedProject);
setDirty(false);
if (failedIds.size === 0 && finalUploadedNodeIds.size > 0) showCanvasToast(t("imageUploadSuccess"));
} catch (err) {
preparedUploads.forEach((item) => URL.revokeObjectURL(item.previewUrl));
const failedUploadIds = new Set(preparedUploads.map((item) => item.node.id));
if (failedUploadIds.size > 0) {
const nextNodes = nodesRef.current.filter((node) => !failedUploadIds.has(node.id));
nodesRef.current = nextNodes;
setNodes(nextNodes);
}
setError(toUserMessageText(err, "generateError"));
} finally {
setSaving(false);
setCanvasFileDragging(false);
}
};
const handleCanvasImageInput = (event: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files ?? []);
event.target.value = "";
void uploadImagesToCanvas(files);
};
const handleCanvasPointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
lastCanvasPointerRef.current = {
clientX: event.clientX,
clientY: event.clientY
};
};
const handleCanvasDragOver = (event: React.DragEvent<HTMLDivElement>) => {
if (!dataTransferHasImage(event.dataTransfer)) return;
event.preventDefault();
event.dataTransfer.dropEffect = "copy";
setCanvasFileDragging(true);
};
const handleCanvasDragLeave = (event: React.DragEvent<HTMLDivElement>) => {
if (event.currentTarget.contains(event.relatedTarget as Node | null)) return;
setCanvasFileDragging(false);
};
const handleCanvasDrop = (event: React.DragEvent<HTMLDivElement>) => {
const files = dataTransferImageFiles(event.dataTransfer);
if (files.length === 0) return;
event.preventDefault();
event.stopPropagation();
setCanvasFileDragging(false);
void uploadImagesToCanvas(files, canvasPointFromClient(event.clientX, event.clientY));
};
if (!project) {
return (
<div className="workspace-loading">
<Loader2 className="spin" size={28} />
<span>{error || t("loadingCanvas")}</span>
</div>
);
}
return (
<div className={`workspace-shell ${assistantPanelOpen ? "" : "assistant-panel-collapsed"}`}>
<header className="workspace-topbar">
<div className="workspace-countdown" aria-label={t("promo")}>
<span>0</span>
<span>1</span>
<i>:</i>
<span>1</span>
<span>7</span>
<i>:</i>
<span>1</span>
<span>4</span>
<i>:</i>
<span>5</span>
<span>3</span>
<div>
<b>{t("promo")}</b>
<span className="workspace-upgrade-copy">{t("upgradeNow")}</span>
</div>
</div>
</header>
<main className="workspace-main">
<div
className={`canvas-stage ${showGrid ? "show-grid" : ""} ${canvasFileDragging ? "dragging-upload" : ""} ${activeTool === "pan" ? "is-pan-tool" : ""} ${canvasPanning ? "is-panning" : ""} ${activeTool === "frame" ? "is-frame-tool" : ""} ${activeTool === "shape" ? "is-shape-tool" : ""} ${activeTool === "pen" ? "is-pen-tool" : ""} ${activeTool === "text" ? "is-text-tool" : ""}`}
style={{ "--canvas-background": canvasBackgroundColor } as CSSProperties}
ref={containerRef}
onWheel={handleWheel}
onPointerDown={handleBackgroundPointerDown}
onPointerMove={handleCanvasPointerMove}
onDragOver={handleCanvasDragOver}
onDragLeave={handleCanvasDragLeave}
onDrop={handleCanvasDrop}
>
<input ref={canvasImageInputRef} type="file" accept="image/*" multiple hidden onChange={handleCanvasImageInput} />
<div className="workspace-stage-title">
<WorkspaceTitle
title={project.title}
untitledLabel={t("canvasUntitled")}
renameLabel={t("renameCanvasTitle")}
placeholder={t("projectTitlePlaceholder")}
backHomeLabel={t("backHome")}
copyLabel={t("copyProject")}
moreLabel={t("more")}
onBackHome={openHome}
onRename={updateProjectTitle}
/>
</div>
{assistantPanelOpen ? (
<CanvasAccountControls credits={credits} />
) : (
<CanvasAccountControls credits={credits} collapsed onOpenConversation={() => setAssistantPanelOpen(true)} />
)}
{layersPanelOpen && (
<CanvasLayerPanel
nodes={nodes}
selectedIds={selectedIds}
hiddenNodeIds={hiddenNodeIds}
lockedNodeIds={lockedNodeIds}
onSelect={selectOnlyNode}
onSelectMany={selectNodes}
onRenameNode={renameNodeTitle}
onToggleHidden={toggleLayerHidden}
onToggleLocked={toggleLayerLocked}
onToggleHiddenMany={toggleLayersHidden}
onToggleLockedMany={toggleLayersLocked}
onClose={() => setLayersPanelOpen(false)}
/>
)}
{generatedFilesPanelOpen && (
<CanvasGeneratedFilesPanel
nodes={nodes}
messages={project?.messages ?? []}
selectedIds={selectedIds}
onSelect={selectOnlyNode}
onDownload={(node) => exportImageNode(node, isVectorizedImageNode(node) ? "svg" : "png")}
onClose={() => setGeneratedFilesPanelOpen(false)}
/>
)}
{showGrid && <CanvasGrid viewport={viewport} />}
<div className="canvas-world" style={{ transform: `translate(${viewport.x}px, ${viewport.y}px) scale(${viewport.k})` }}>
{renderedCanvasNodes.map((node) => {
const hidden = hiddenNodeIds.has(node.id);
const locked = lockedNodeIds.has(node.id);
const canOperateNode = !locked;
const suppressSelectionFrame = (imageActionMode === "expand" || imageActionMode === "crop") && selectedImageNode?.id === node.id;
return (
<CanvasNodeView
key={node.id}
node={node}
selected={selectedIds.has(node.id) && selectedIds.size <= 1}
mockupDragging={mockupDragNodeId === node.id}
editing={editingTextNodeId === node.id}
showResizeHandles={canOperateNode && !suppressSelectionFrame && mode === "select" && selectedIds.size <= 1 && editingTextNodeId !== node.id && canResizeCanvasNode(node) && !isLineShapeNode(node)}
hidden={hidden}
locked={locked}
mockupClipPath={mockupPrintClipPath(node, nodes)}
transform={{
flipX: Boolean(node.flipX),
flipY: Boolean(node.flipY),
rotation: node.rotation ?? 0
}}
selectionScale={viewport.k}
viewportScale={viewport.k}
hasClipboard={Boolean(clipboardNode)}
contextMenuHandlers={canOperateNode && canUseNodeContextMenu(node) ? createNodeContextMenuHandlers(node) : null}
onPointerDown={(event) => handleNodePointerDown(event, node)}
onContextMenu={() => {
if (!canOperateNode) return;
if (!selectedIds.has(node.id)) selectOnlyNode(node.id);
}}
onTitleRename={(title) => renameNodeTitle(node.id, title)}
onResizePointerDown={(event, corner) => handleNodeResizePointerDown(event, node, corner)}
onShapeEndpointPointerDown={(event, endpoint) => handleShapeEndpointPointerDown(event, node, endpoint)}
onContentChange={(content) => updateNodeContent(node.id, content)}
onTextEditFinish={(content) => finishTextEditing(node.id, content)}
/>
);
})}
</div>
<CanvasAnnotations annotations={annotations} viewport={viewport} />
<FrameDraftOverlay draft={frameDraft} label={t("newFrameNode")} />
<ShapeDraftOverlay draft={shapeDraft} kind={selectedShapeKind} label={shapeTitle(selectedShapeKind, t)} />
<PenStrokeDraftOverlay draft={penDraft} />
{selectedImageGeneratorBounds && (
<ImageGeneratorFloatingComposer
bounds={selectedImageGeneratorBounds}
stageWidth={canvasStageSize.width}
stageHeight={canvasStageSize.height}
models={imageModels}
selectedModel={selectedCanvasModel}
settings={composerImageSettings}
generating={selectedImageGeneratorBusy}
lockedPrompt={selectedImageGeneratorNode?.status === "generating" ? cleanImageGeneratorPrompt(selectedImageGeneratorNode.content) : ""}
onSettingsChange={updateComposerImageSettings}
onModelChange={setSelectedCanvasModelId}
onUploadFile={uploadReferenceToImageGenerator}
onReferenceRemoved={handleReferenceRemoved}
onSubmit={(contents, reset) => void generate("image-generator", contents, reset)}
/>
)}
{selectionBox && <div className="canvas-selection-marquee" style={selectionBox} />}
{alignmentGuides.map((guide) => (
<span
key={guide.id}
className={`canvas-alignment-guide ${guide.orientation}`}
style={{
left: guide.left,
top: guide.top,
width: guide.orientation === "horizontal" ? guide.length : undefined,
height: guide.orientation === "vertical" ? guide.length : undefined
}}
/>
))}
{multiSelectionBounds && (
<MultiSelectionOverlay
bounds={multiSelectionBounds}
grouped={Boolean(selectedGroupId)}
leftInset={floatingToolbarLeftInset}
onAutoLayout={autoLayoutSelectedNodes}
onCreateGroup={createGroupFromSelectedNodes}
onUngroup={ungroupSelectedNodes}
onMergeLayers={mergeSelectedNodes}
canBooleanOperate={canBooleanOperateSelection}
onBooleanOperation={applyBooleanShapeOperation}
onAlign={alignSelectedNodes}
onDistribute={distributeSelectedNodes}
onDownload={(format) => selectedNodes.filter((node) => node.type === "image").forEach((node) => exportImageNode(node, format))}
onResizeStart={handleMultiSelectionResizePointerDown}
/>
)}
{selectedBooleanShapeGroupNode && selectedBooleanShapeGroupBounds && (
<MultiSelectionOverlay
bounds={selectedBooleanShapeGroupBounds}
grouped
showSelectionBox={false}
leftInset={floatingToolbarLeftInset}
onAutoLayout={() => autoLayoutAroundNode(selectedBooleanShapeGroupNode)}
onCreateGroup={createGroupFromSelectedNodes}
onUngroup={() => ungroupBooleanShapeGroup(selectedBooleanShapeGroupNode.id)}
onMergeLayers={mergeSelectedNodes}
canBooleanOperate
onBooleanOperation={applyBooleanShapeOperation}
onAlign={alignSelectedNodes}
onDistribute={distributeSelectedNodes}
onDownload={(format) => exportCanvasNode(selectedBooleanShapeGroupNode, format)}
onResizeStart={(event, handle) => handleMultiSelectionResizePointerDown(event, handle)}
/>
)}
{selectedImageNode && selectedNodeBounds && (
<>
{isVectorizedImageNode(selectedImageNode) ? (
<VectorizedImageToolbar bounds={selectedNodeBounds} leftInset={floatingToolbarLeftInset} onDownload={() => exportImageNode(selectedImageNode, "svg")} />
) : (
<>
{imageActionMode !== "quick-edit" && imageActionMode !== "eraser" && imageActionMode !== "edit-text" && imageActionMode !== "expand" && imageActionMode !== "crop" && imageActionMode !== "flip-rotate" && selectedImageNode.status !== "text-extracting" && mockupPendingTargetId !== selectedImageNode.id && (
<ImageNodeToolbar
bounds={selectedNodeBounds}
leftInset={floatingToolbarLeftInset}
disabled={selectedImageNodeBusy}
visibility={toolbarVisibility}
onVisibilityChange={setToolbarVisibility}
onQuickEdit={openQuickEditPanel}
onUpscale={(scale) =>
void runImageNodeAction("upscale", {
prompt: t("upscalePrompt", { scale }),
options: { scale }
})
}
onRemoveBackground={() => selectedImageNode && void runImageNodeAction("remove-background", { prompt: t("removeBackgroundPrompt"), options: backgroundRemovalOptions(selectedImageNode) })}
onEraser={() => {
setEraserMarks([]);
setImageActionMode("eraser");
}}
onEditElements={() => void runImageNodeAction("edit-elements", { prompt: t("editElementsPrompt") })}
onEditText={() => void openTextEditPanel()}
onMultiAngle={() => setImageActionMode("multi-angle")}
onMoveObject={() => {
setMoveSelection(null);
setMoveRecognitionLabel("");
setMoveRecognitionLoading(false);
setImageActionMode("move-object");
}}
onMockup={prepareMockupPlacement}
onExpand={openExpandPanel}
onAdjust={openAdjustPanel}
onCrop={() => {
setCropSelection(defaultCropSelection);
setImageActionMode("crop");
}}
onVectorize={() => void runImageNodeAction("vectorize", { prompt: t("vectorPrompt") })}
onFlipRotate={() => setImageActionMode("flip-rotate")}
onDownload={() => exportImageNode(selectedImageNode, "png")}
/>
)}
{imageActionMode === "quick-edit" && (
<QuickEditPanel
bounds={selectedNodeBounds}
value={quickEditPrompt}
disabled={selectedImageNodeBusy}
placeholder={t("quickEditPlaceholder")}
onChange={setQuickEditPrompt}
onCancel={restoreQuickEditViewport}
onSubmit={applyQuickEdit}
/>
)}
{imageActionMode === "eraser" && (
<EraserOverlay
bounds={selectedNodeBounds}
marks={eraserMarks}
brushSize={eraserBrushSize}
brushColor={eraserBrushColor}
brushOpacity={eraserBrushOpacity}
disabled={selectedImageNodeBusy}
onMarksChange={setEraserMarks}
onBrushSizeChange={setEraserBrushSize}
onBrushColorChange={setEraserBrushColor}
onBrushOpacityChange={setEraserBrushOpacity}
onCancel={() => {
setImageActionMode(null);
setEraserMarks([]);
}}
onApply={applyEraserEdit}
/>
)}
{imageActionMode === "edit-text" && (
<ImageTextEditPanel
bounds={selectedNodeBounds}
items={textEditItems}
loading={textEditLoading}
error={textEditError}
disabled={selectedImageNodeBusy}
onChange={updateTextEditItem}
onRemove={removeTextEditItem}
onCancel={cancelTextEdit}
onApply={applyTextEdit}
/>
)}
{imageActionMode === "move-object" && (
<MoveObjectOverlay
bounds={selectedNodeBounds}
selection={moveSelection}
prompt={movePrompt}
recognitionLabel={moveRecognitionLabel}
recognitionLoading={moveRecognitionLoading}
disabled={selectedImageNodeBusy}
onSelectionChange={setMoveSelection}
onRecognitionLabelChange={setMoveRecognitionLabel}
onPromptChange={setMovePrompt}
onCancel={() => {
setImageActionMode(null);
setMoveSelection(null);
setMoveRecognitionLabel("");
setMoveRecognitionLoading(false);
}}
onApply={applyMoveObject}
/>
)}
{imageActionMode === "multi-angle" && (
<MultiAnglePanel
bounds={selectedNodeBounds}
imageUrl={selectedImageNode.content}
value={multiAngleOptions}
disabled={selectedImageNodeBusy}
onChange={setMultiAngleOptions}
onCancel={() => setImageActionMode(null)}
onApply={applyMultiAngle}
/>
)}
{mockupPendingTargetId === selectedImageNode.id && <MockupDropOverlay bounds={selectedNodeBounds} active label={t("mockupPreparing")} />}
{imageActionMode === "mockup" && mockupReadyTargetId === selectedImageNode.id && mockupPlacement && mockupModel && (
<MockupDropOverlay bounds={mockupPlacementScreenBounds(selectedImageNode, mockupPlacement, viewport)} active={mockupDropActive || Boolean(mockupDragNodeId)} label={t("mockupDropHint")} compact />
)}
{imageActionMode === "adjust" && (
<AdjustPanel
bounds={selectedNodeBounds}
value={adjustOptions}
disabled={selectedImageNodeBusy}
onChange={updateSelectedImageAdjustments}
onCancel={() => setImageActionMode(null)}
onAutoAdjust={autoAdjustSelectedImage}
/>
)}
{imageActionMode === "expand" && (
<ExpandPanel
bounds={selectedNodeBounds}
imageUrl={selectedImageNode.content}
title={selectedImageNode.title}
creditCost={selectedCanvasModel.credits}
value={expandOptions}
disabled={selectedImageNodeBusy}
onChange={setExpandOptions}
onCancel={() => setImageActionMode(null)}
onApply={applyExpandImage}
/>
)}
{imageActionMode === "crop" && (
<CropOverlay
bounds={selectedNodeBounds}
sourceSize={{ width: selectedImageNode.width, height: selectedImageNode.height }}
title={selectedImageNode.title}
selection={cropSelection}
prompt={cropPrompt}
disabled={selectedImageNodeBusy}
onSelectionChange={setCropSelection}
onPromptChange={setCropPrompt}
onCancel={() => {
setImageActionMode(null);
setCropSelection(null);
}}
onApply={applyCropImage}
/>
)}
{imageActionMode === "flip-rotate" && (
<FlipRotatePanel
bounds={selectedNodeBounds}
leftInset={floatingToolbarLeftInset}
disabled={selectedImageNodeBusy}
flipX={Boolean(selectedImageNode.flipX)}
flipY={Boolean(selectedImageNode.flipY)}
rotation={selectedImageNode.rotation ?? 0}
onFlipHorizontal={() => toggleNodeTransform(selectedImageNode.id, "flipX")}
onFlipVertical={() => toggleNodeTransform(selectedImageNode.id, "flipY")}
onRotationChange={(rotation) => updateNodeTransform(selectedImageNode.id, { rotation })}
onRotate={() => rotateNodeClockwise(selectedImageNode.id)}
onDownload={() => exportImageNode(selectedImageNode, "png")}
/>
)}
</>
)}
</>
)}
{selectedFrameNode && selectedFrameBounds && (
<FrameStyleToolbar node={selectedFrameNode} bounds={selectedFrameBounds} leftInset={floatingToolbarLeftInset} onPatch={(patch) => updateFrameStyle(selectedFrameNode.id, patch)} />
)}
{selectedShapeNode && selectedShapeBounds && !isLineShapeNode(selectedShapeNode) && (
<ShapeStyleToolbar
node={selectedShapeNode}
bounds={selectedShapeBounds}
leftInset={floatingToolbarLeftInset}
onPatch={(patch) => updateShapeStyle(selectedShapeNode.id, patch)}
onDownload={() => exportCanvasNode(selectedShapeNode, "svg")}
/>
)}
{selectedPenStrokeNode && selectedPenStrokeBounds && (
<PenStrokeToolbar
node={selectedPenStrokeNode}
bounds={selectedPenStrokeBounds}
viewportScale={viewport.k}
leftInset={floatingToolbarLeftInset}
onPatch={(patch) => updatePenStrokeStyle(selectedPenStrokeNode.id, patch)}
onDownload={() => exportImageNode(selectedPenStrokeNode, "svg")}
/>
)}
{selectedTextNode && selectedTextBounds && editingTextNodeId !== selectedTextNode.id && (
<TextStyleToolbar
node={selectedTextNode}
bounds={selectedTextBounds}
leftInset={floatingToolbarLeftInset}
onPatch={(patch) => updateTextStyle(selectedTextNode.id, patch)}
onDownload={() => exportTextNode(selectedTextNode)}
/>
)}
<div className={`canvas-bottom-left ${generatedFilesPanelOpen ? "is-offset-by-files-panel" : layersPanelOpen ? "is-offset-by-layers-panel" : ""}`}>
{showMinimap && (
<CanvasMinimap
nodes={visibleCanvasNodes}
selectedId={selectedId}
viewport={viewport}
stageSize={canvasStageSize}
onViewportChange={(nextViewport) => {
viewportRef.current = nextViewport;
setViewport(nextViewport);
setDirty(true);
}}
onViewportCommit={() => void saveCanvasSnapshot({ quiet: true })}
/>
)}
<div className="canvas-statusbar">
<CanvasColorPopover
title={t("canvasBackgroundColor")}
value={canvasBackgroundColor}
opacity={100}
showOpacity={false}
allowTransparent={false}
swatches={canvasBackgroundSwatches}
onColorChange={updateCanvasBackgroundColor}
onOpacityChange={() => undefined}
>
<button type="button" className="canvas-background-trigger" aria-label={t("canvasBackgroundColor")} title={t("canvasBackgroundColor")}>
<Circle size={14} />
</button>
</CanvasColorPopover>
<button
className={layersPanelOpen ? "active" : ""}
aria-label={t("layers")}
onClick={() => {
setLayersPanelOpen((value) => !value);
setGeneratedFilesPanelOpen(false);
}}
>
<Layers size={14} />
</button>
<button
className={generatedFilesPanelOpen ? "active" : ""}
aria-label={t("generatedFiles")}
title={t("generatedFiles")}
onClick={() => {
setGeneratedFilesPanelOpen((value) => !value);
setLayersPanelOpen(false);
}}
>
<Folder size={14} />
</button>
<button
className={`minimap-toggle ${showMinimap ? "active" : ""}`}
aria-label={t("minimap")}
title={t("minimap")}
onClick={() => setShowMinimap((value) => !value)}
>
<MapIcon size={14} />
</button>
<span className="canvas-statusbar-separator" />
<CanvasZoomMenu
zoom={viewport.k}
onZoomIn={() => zoomCanvasTo(viewportRef.current.k + 0.1)}
onZoomOut={() => zoomCanvasTo(viewportRef.current.k - 0.1)}
onFitAll={fitCanvasToAllNodes}
onZoomTo={zoomCanvasTo}
/>
</div>
</div>
<WorkspaceToolbar
activeTool={activeTool}
canvasToolMenuOpen={canvasToolMenuOpen}
shapeToolMenuOpen={shapeToolMenuOpen}
selectedShapeKind={selectedShapeKind}
showGrid={showGrid}
offsetBy={generatedFilesPanelOpen ? "files" : layersPanelOpen ? "layers" : null}
onCanvasToolMenuOpenChange={setCanvasToolMenuOpen}
onShapeToolMenuOpenChange={setShapeToolMenuOpen}
onChooseTool={chooseCanvasTool}
onChooseShapeKind={setSelectedShapeKind}
onToggleGrid={() => setShowGrid((value) => !value)}
onUploadImage={() => canvasImageInputRef.current?.click()}
/>
{activeTool === "pen" && (
<PenToolControls
settings={penSettings}
offsetBy={generatedFilesPanelOpen ? "files" : layersPanelOpen ? "layers" : null}
onChange={setPenSettings}
/>
)}
</div>
{assistantPanelOpen && (
<aside className="assistant-panel">
<div className="assistant-header">
<div className="assistant-header-title">
<strong>{project.brief || project.title || t("canvasUntitled")}</strong>
</div>
<div className="assistant-header-actions">
<button className="assistant-header-icon" type="button" aria-label={t("newChat")} title={t("newChat")} onClick={beginNewAgentConversation} disabled={!hasCurrentConversation}>
<MessageSquarePlus size={16} />
</button>
<Popover open={agentHistoryOpen} onOpenChange={setAgentHistoryOpen}>
<PopoverTrigger asChild>
<button className={`assistant-header-icon assistant-history-trigger ${agentHistoryOpen ? "active" : ""}`} type="button" aria-label={t("conversationHistory")} title={t("conversationHistory")}>
<ChevronDown size={16} />
</button>
</PopoverTrigger>
<PopoverContent className="agent-history-popover" align="end" sideOffset={10}>
<div className="agent-history-title">{t("conversationHistory")}</div>
<label className="agent-history-search">
<Search size={14} aria-hidden="true" />
<input
data-testid="agent-history-search-input"
value={agentHistorySearch}
placeholder={t("searchConversationPlaceholder")}
onChange={(event) => setAgentHistorySearch(event.target.value)}
/>
</label>
<div className="agent-history-list" data-testid="agent-history-menu">
{!agentHistorySearch.trim() && (
<div
className={`agent-history-item ${activeHistoryThreadId === newAgentConversationId ? "active" : ""}`}
role="button"
tabIndex={0}
data-testid="agent-history-menu-item"
onClick={beginNewAgentConversation}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
beginNewAgentConversation();
}
}}
>
<span>{t("newChat")}</span>
</div>
)}
{filteredAgentHistoryThreads.map((thread) => (
<div
className={`agent-history-item ${activeHistoryThreadId === thread.agentThreadId ? "active" : ""}`}
role="button"
tabIndex={0}
key={thread.agentThreadId}
data-testid="agent-history-menu-item"
onClick={() => selectAgentThread(thread.agentThreadId)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
selectAgentThread(thread.agentThreadId);
}
}}
>
<span>{agentThreadTitle(thread)}</span>
<button
className="agent-history-delete"
type="button"
aria-label={t("deleteConversation")}
title={t("deleteConversation")}
onClick={(event) => {
event.stopPropagation();
removeAgentThreadFromMenu(thread.agentThreadId);
}}
>
<Trash2 size={14} />
</button>
</div>
))}
{filteredAgentHistoryThreads.length === 0 && agentHistorySearch.trim() && (
<div className="agent-history-empty">{t("noConversationHistory")}</div>
)}
</div>
</PopoverContent>
</Popover>
<button className="assistant-header-icon" type="button" aria-label={t("share")} title={t("share")} onClick={shareProject} disabled={!hasCurrentConversation}>
<Share2 size={16} />
</button>
<span className="assistant-header-separator" aria-hidden="true" />
<button className="assistant-header-icon" type="button" aria-label={t("collapse")} title={t("collapse")} onClick={() => setAssistantPanelOpen(false)}>
<PanelRightClose size={16} />
</button>
</div>
</div>
<div className="assistant-feed">
<CanvasAgentTimeline messages={messages} nodes={nodes} active={agentTimelineActive} />
</div>
{error && <p className="panel-error">{error}</p>}
<CanvasAgentComposer
ref={agentComposerRef}
text={prompt}
mode={composerMode}
models={imageModels}
selectedModel={selectedCanvasModel}
settings={composerImageSettings}
references={referenceImages}
annotations={annotations}
webSearchEnabled={useAgentWebSearch}
generating={agentGenerating}
previewSlot={<CanvasAnnotationPreview preview={annotationPreview} nodes={nodes} />}
onTextChange={setPrompt}
onModeChange={setComposerMode}
onSettingsChange={updateComposerImageSettings}
onModelChange={setSelectedCanvasModelId}
onWebSearchChange={setUseAgentWebSearch}
onUploadFile={attachImage}
onReferencesChange={syncReferenceImages}
onReferenceRemoved={handleReferenceRemoved}
onAnnotationsChange={syncAnnotations}
onAnnotationPreviewChange={setAnnotationPreview}
onSubmit={() => void generate("agent")}
onStop={() => void stopAgentTask()}
/>
</aside>
)}
</main>
</div>
);
}
function CanvasNodeView({
node,
selected,
mockupDragging,
editing,
showResizeHandles,
hidden,
locked,
mockupClipPath,
transform,
selectionScale,
viewportScale,
hasClipboard,
contextMenuHandlers,
onPointerDown,
onContextMenu,
onTitleRename,
onResizePointerDown,
onShapeEndpointPointerDown,
onContentChange,
onTextEditFinish
}: {
node: CanvasNode;
selected: boolean;
mockupDragging: boolean;
editing: boolean;
showResizeHandles: boolean;
hidden: boolean;
locked: boolean;
mockupClipPath?: string;
transform: NodeTransform;
selectionScale: number;
viewportScale: number;
hasClipboard: boolean;
contextMenuHandlers: NodeContextMenuHandlers | null;
onPointerDown: (event: React.PointerEvent<HTMLDivElement>) => void;
onContextMenu: (event: React.MouseEvent<HTMLDivElement>) => void;
onTitleRename: (title: string) => void;
onResizePointerDown: (event: React.PointerEvent<HTMLButtonElement>, handle: ResizeHandle) => void;
onShapeEndpointPointerDown: (event: React.PointerEvent<HTMLButtonElement>, endpoint: ShapeEndpointKey) => void;
onContentChange: (content: string) => void;
onTextEditFinish: (content: string) => void;
}) {
const { t } = useI18n();
const visual = node.type === "frame" || node.type === "image";
const manualFrame = node.type === "frame" && node.layerRole === "manual-frame";
const shapeNode = isShapeNode(node);
const booleanShapeGroup = isBooleanShapeGroupNode(node);
const lineShapeNode = isLineShapeNode(node);
const penStroke = node.type === "image" && node.layerRole === "pen-stroke";
const textOverlay = node.type === "text" && node.layerRole === "editable-text";
const textOverlayStyle = textOverlay ? editableTextLayerStyle(node) : undefined;
const textEditorRef = useRef<HTMLTextAreaElement | null>(null);
const selectionTitle = manualFrame ? "Frame" : shapeNode || booleanShapeGroup ? node.title.trim() || "Shape" : penStroke ? node.title.trim() || "Pen stroke" : node.title.trim() || (node.type === "image" ? "Image" : node.type);
const selectionSize = `${Math.round(node.width)} × ${Math.round(node.height)}`;
const SelectionIcon = node.type === "frame" ? Frame : node.type === "text" ? Type : penStroke ? PenLine : ImageIcon;
useEffect(() => {
if (!editing || !textOverlay) return;
const editor = textEditorRef.current;
if (!editor) return;
editor.focus();
const cursor = editor.value.length;
editor.setSelectionRange(cursor, cursor);
}, [editing, node.id, textOverlay]);
const handleContextMenu = (event: React.MouseEvent<HTMLDivElement>) => {
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 = (
<div
className={`canvas-node node-${node.type} tone-${node.tone} ${manualFrame ? "manual-frame-node" : ""} ${shapeNode || booleanShapeGroup ? "shape-node" : ""} ${penStroke ? "pen-stroke-node" : ""} ${visual ? "visual-node" : textOverlay ? "text-overlay-node" : "note-node"} ${selected ? "selected" : ""} ${mockupDragging ? "is-mockup-dragging" : ""} ${hidden ? "is-hidden" : ""} ${locked ? "is-locked" : ""}`}
style={{ left: node.x, top: node.y, width: node.width, height: node.height }}
onPointerDown={onPointerDown}
onContextMenu={handleContextMenu}
>
<div className="node-content-layer" style={contentLayerStyle}>
{shapeNode || booleanShapeGroup ? (
<ShapeNodePreview node={node} />
) : visual ? (
<ArtboardPreview node={node} viewportScale={viewportScale} />
) : textOverlay ? (
editing ? (
<textarea
ref={textEditorRef}
className="node-text-overlay-editor"
value={node.content}
style={textOverlayStyle}
onPointerDown={(event) => event.stopPropagation()}
onChange={(event) => onContentChange(event.target.value)}
onBlur={(event) => onTextEditFinish(event.currentTarget.value)}
onKeyDown={(event) => {
if (event.key === "Escape" || (event.key === "Enter" && (event.metaKey || event.ctrlKey))) {
event.preventDefault();
event.currentTarget.blur();
}
}}
/>
) : (
<div className="node-text-overlay" style={textOverlayStyle}>
{node.content}
</div>
)
) : (
<>
<div className="node-title">
<span>{node.title}</span>
<small>{locked ? "locked" : node.status}</small>
</div>
{selected && node.type === "text" ? (
<textarea className="node-text-editor" value={node.content} onPointerDown={(event) => event.stopPropagation()} onChange={(event) => onContentChange(event.target.value)} />
) : (
<p>{node.content}</p>
)}
</>
)}
</div>
{locked && (
<div className="node-lock-badge">
<Lock size={12} />
</div>
)}
{selected && showResizeHandles && !locked && (
<CanvasSelectionFrame
node={node}
title={selectionTitle}
size={selectionSize}
Icon={SelectionIcon}
variant={manualFrame ? "frame" : penStroke ? "pen" : shapeNode || booleanShapeGroup ? "shape" : textOverlay ? "text" : undefined}
selectionScale={selectionScale}
transform={transform}
onResizePointerDown={onResizePointerDown}
onTitleRename={node.type === "image" ? onTitleRename : undefined}
renameLabel={t("renameImageNode")}
titlePlaceholder={t("imageTitlePlaceholder")}
/>
)}
{selected && lineShapeNode && !locked && (
<CanvasSelectionFrame
node={node}
title={selectionTitle}
size={selectionSize}
Icon={SelectionIcon}
variant="line-shape"
selectionScale={selectionScale}
transform={transform}
onResizePointerDown={onResizePointerDown}
onLineEndpointPointerDown={onShapeEndpointPointerDown}
/>
)}
</div>
);
if (!contextMenuHandlers) return nodeElement;
return (
<ContextMenu>
<ContextMenuTrigger asChild>{nodeElement}</ContextMenuTrigger>
<CanvasNodeContextMenu hidden={hidden} locked={locked} hasClipboard={hasClipboard} handlers={contextMenuHandlers} />
</ContextMenu>
);
}
function fitTextNodeToContent(node: CanvasNode, options: { keepX?: boolean; keepY?: boolean } = {}): CanvasNode {
if (node.type !== "text" || node.layerRole !== "editable-text") return node;
const size = measureTextNodeContent(node);
return {
...node,
x: options.keepX ? node.x : node.x + (node.width - size.width) / 2,
y: options.keepY ? node.y : node.y + (node.height - size.height) / 2,
width: size.width,
height: size.height
};
}
function textStylePatchChangesMetrics(patch: TextStylePatch) {
return (
patch.fontFamily !== undefined ||
patch.fontWeight !== undefined ||
patch.fontSize !== undefined ||
patch.lineHeight !== undefined ||
patch.letterSpacing !== undefined ||
patch.textTransform !== undefined ||
patch.strokeWidth !== undefined
);
}
function measureTextNodeContent(node: CanvasNode, maxWidth?: number) {
const fontSize = node.fontSize || 48;
const strokePadding = Math.max(0, node.strokeWidth || 0) * 2;
const letterSpacing = node.letterSpacing || 0;
const lineHeight = textLineHeightPixels(node, fontSize);
const lines = transformTextContent(node.content || " ", node.textTransform).split(/\r?\n/);
const context = getTextMeasureContext();
if (context) {
context.font = `${textFontStyle(node.fontWeight)} ${textFontWeight(node.fontWeight)} ${fontSize}px ${canvasFontFamily(node.fontFamily)}`;
}
const measuredLines = lines.flatMap((line) => wrapMeasuredTextLine(line || " ", maxWidth ? Math.max(8, maxWidth - strokePadding) : 0, (value) => measureTextWidth(context, value, letterSpacing)));
const width = Math.max(8, ...measuredLines.map((line) => measureTextWidth(context, line, letterSpacing))) + strokePadding;
const height = Math.max(lineHeight, measuredLines.length * lineHeight) + strokePadding;
return {
width: Math.ceil(width),
height: Math.ceil(height)
};
}
function wrapMeasuredTextLine(line: string, maxWidth: number, measure: (value: string) => number) {
if (!maxWidth || measure(line) <= maxWidth) return [line];
const tokens = line.includes(" ") ? line.split(/(\s+)/) : Array.from(line);
const rows: string[] = [];
let current = "";
tokens.forEach((token) => {
const next = `${current}${token}`;
if (current && measure(next) > maxWidth) {
rows.push(current.trimEnd());
current = token.trimStart();
return;
}
current = next;
});
if (current) rows.push(current.trimEnd());
return rows.length > 0 ? rows : [" "];
}
function measureTextWidth(context: CanvasRenderingContext2D | null, value: string, letterSpacing: number) {
const baseWidth = context ? context.measureText(value).width : value.length * 24;
return baseWidth + Math.max(0, value.length - 1) * letterSpacing;
}
let textMeasureCanvasContext: CanvasRenderingContext2D | null | undefined;
function getTextMeasureContext() {
if (textMeasureCanvasContext !== undefined) return textMeasureCanvasContext;
if (typeof document === "undefined") {
textMeasureCanvasContext = null;
return textMeasureCanvasContext;
}
textMeasureCanvasContext = document.createElement("canvas").getContext("2d");
return textMeasureCanvasContext;
}
function canvasFontFamily(value?: string) {
const family = value || "Inter";
if (family.includes(",") || family.startsWith("\"") || family.startsWith("'")) return family;
return `"${family.replace(/"/g, "\\\"")}", Arial, sans-serif`;
}
function textLineHeightPixels(node: CanvasNode, fontSize: number) {
if (node.lineHeight && node.lineHeight > 0) {
return node.lineHeight <= 4 ? fontSize * node.lineHeight : node.lineHeight;
}
return fontSize * 1.08;
}
function transformTextContent(content: string, transform?: string) {
if (transform === "uppercase") return content.toUpperCase();
if (transform === "lowercase") return content.toLowerCase();
if (transform === "capitalize") return content.replace(/\b\p{L}/gu, (letter) => letter.toUpperCase());
return content;
}
function editableTextLayerStyle(node: CanvasNode): CSSProperties {
const strokeWidth = Math.max(0, node.strokeWidth || 0);
return {
color: node.color || "#111827",
fontFamily: node.fontFamily || "Inter, Arial, sans-serif",
fontSize: node.fontSize ? `${node.fontSize}px` : undefined,
fontWeight: textFontWeight(node.fontWeight),
fontStyle: textFontStyle(node.fontWeight),
lineHeight: node.lineHeight && node.lineHeight > 0 ? node.lineHeight : undefined,
letterSpacing: node.letterSpacing ? `${node.letterSpacing}px` : undefined,
textDecoration: node.textDecoration && node.textDecoration !== "none" ? node.textDecoration : undefined,
textTransform: node.textTransform && node.textTransform !== "none" ? (node.textTransform as CSSProperties["textTransform"]) : undefined,
WebkitTextStroke: strokeWidth > 0 ? `${strokeWidth}px ${node.strokeColor || "#000000"}` : undefined,
opacity: node.opacity && node.opacity > 0 ? node.opacity : 1,
textAlign: node.textAlign || "center"
};
}
function textFontWeight(value?: string | number) {
const text = String(value || "Regular").toLowerCase();
if (/^[1-9]00$/.test(text)) return text;
if (text.includes("thin")) return 100;
if (text.includes("extra light") || text.includes("extralight")) return 200;
if (text.includes("light")) return 300;
if (text.includes("medium")) return 500;
if (text.includes("semi bold") || text.includes("semibold")) return 600;
if (text.includes("extra bold") || text.includes("extrabold")) return 800;
if (text.includes("black") || text.includes("heavy")) return 900;
if (text.includes("bold")) return 700;
return 400;
}
function textFontStyle(value?: string | number): CSSProperties["fontStyle"] {
return String(value || "").toLowerCase().includes("italic") ? "italic" : "normal";
}
function toEditableTextLayer(layer: ExtractedTextLayer, index: number): EditableTextLayer {
return {
...layer,
id: `text-layer-${index}-${cryptoFallbackId()}`,
originalText: layer.originalText || layer.text
};
}
function toTextLayerPayload(layer: EditableTextLayer): ExtractedTextLayer & { removed?: boolean } {
return {
text: layer.removed ? "" : layer.text,
originalText: layer.originalText || layer.text,
x: layer.x,
y: layer.y,
width: layer.width,
height: layer.height,
fontSize: layer.fontSize,
fontFamily: layer.fontFamily,
fontWeight: layer.fontWeight,
color: layer.color,
textAlign: layer.textAlign,
lineHeight: layer.lineHeight,
letterSpacing: layer.letterSpacing,
strokeColor: layer.strokeColor,
strokeWidth: layer.strokeWidth,
opacity: layer.opacity,
rotation: layer.rotation,
confidence: layer.confidence,
removed: layer.removed
};
}
function defaultMockupPlacementForNode(node: CanvasNode): MockupPlacement {
if (node.height > node.width * 1.08) {
return { x: 0.3, y: 0.34, width: 0.4, height: 0.3 };
}
return { x: 0.29, y: 0.31, width: 0.42, height: 0.3 };
}
function defaultMockupSurfaceForNode(node: CanvasNode): MockupSurface {
const placement = defaultMockupPlacementForNode(node);
const topInset = placement.width * 0.045;
const bottomInset = placement.width * 0.015;
return {
targetId: node.id,
polygon: [
{ x: placement.x + topInset, y: placement.y },
{ x: placement.x + placement.width - topInset, y: placement.y },
{ x: placement.x + placement.width - bottomInset, y: placement.y + placement.height },
{ x: placement.x + bottomInset, y: placement.y + placement.height }
]
};
}
async function detectMockupForNode(node: CanvasNode): Promise<{ placement: MockupPlacement; surface: MockupSurface }> {
const image = await loadMockupImage(canvasImageUrl(node.content));
const placement = detectMockupPlacementFromImage(image);
return {
placement,
surface: detectMockupSurfaceFromImage(image, node.id)
};
}
function detectMockupPlacementFromImage(image: HTMLImageElement): MockupPlacement {
const naturalWidth = image.naturalWidth || image.width;
const naturalHeight = image.naturalHeight || image.height;
const aspect = Math.max(0.05, naturalWidth / Math.max(naturalHeight, 1));
const longEdge = 260;
const width = aspect >= 1 ? longEdge : Math.max(1, Math.round(longEdge * aspect));
const height = aspect >= 1 ? Math.max(1, Math.round(longEdge / aspect)) : longEdge;
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d", { willReadFrequently: true });
if (!ctx) throw new Error("Canvas is not available");
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = "high";
ctx.drawImage(image, 0, 0, width, height);
const data = ctx.getImageData(0, 0, width, height);
return scoreMockupPlacementCandidates(data, aspect);
}
function detectMockupSurfaceFromImage(image: HTMLImageElement, targetId: string): MockupSurface {
const naturalWidth = image.naturalWidth || image.width;
const naturalHeight = image.naturalHeight || image.height;
const aspect = Math.max(0.05, naturalWidth / Math.max(naturalHeight, 1));
const portrait = aspect < 0.86;
return {
targetId,
polygon: portrait
? [
{ x: 0.13, y: 0.28 },
{ x: 0.79, y: 0.28 },
{ x: 0.85, y: 0.84 },
{ x: 0.2, y: 0.84 }
]
: [
{ x: 0.17, y: 0.18 },
{ x: 0.83, y: 0.18 },
{ x: 0.85, y: 0.82 },
{ x: 0.15, y: 0.82 }
]
};
}
function scoreMockupPlacementCandidates(imageData: ImageData, aspect: number): MockupPlacement {
const portrait = aspect < 0.86;
const baseWidth = portrait ? 0.13 : 0.15;
const baseHeight = portrait ? 0.19 : 0.2;
const preferredY = portrait ? 0.45 : 0.42;
const candidates: MockupPlacement[] = [];
for (const scale of [0.86, 1, 1.14]) {
const slotWidth = clamp(baseWidth * scale, 0.1, 0.22);
const slotHeight = clamp(baseHeight * scale, 0.13, 0.26);
for (let centerY = portrait ? 0.34 : 0.3; centerY <= (portrait ? 0.62 : 0.58); centerY += 0.035) {
for (let centerX = 0.42; centerX <= 0.58; centerX += 0.035) {
candidates.push({
x: clamp(centerX - slotWidth / 2, 0.04, 0.96 - slotWidth),
y: clamp(centerY - slotHeight / 2, 0.08, 0.9 - slotHeight),
width: slotWidth,
height: slotHeight
});
}
}
}
let best = candidates[0] ?? { x: 0.41, y: 0.31, width: 0.15, height: 0.2 };
let bestScore = Number.NEGATIVE_INFINITY;
for (const candidate of candidates) {
const score = scoreMockupPlacementCandidate(imageData, candidate, preferredY);
if (score > bestScore) {
best = candidate;
bestScore = score;
}
}
return {
x: Number(best.x.toFixed(4)),
y: Number(best.y.toFixed(4)),
width: Number(best.width.toFixed(4)),
height: Number(best.height.toFixed(4))
};
}
function scoreMockupPlacementCandidate(imageData: ImageData, placement: MockupPlacement, preferredY: number) {
const { width, height, data } = imageData;
const startX = Math.floor(placement.x * width);
const endX = Math.ceil((placement.x + placement.width) * width);
const startY = Math.floor(placement.y * height);
const endY = Math.ceil((placement.y + placement.height) * height);
const step = Math.max(1, Math.floor(Math.min(endX - startX, endY - startY) / 28));
let count = 0;
let skinCount = 0;
let transparentCount = 0;
let lumaTotal = 0;
let textureTotal = 0;
for (let y = startY; y < endY; y += step) {
for (let x = startX; x < endX; x += step) {
const px = clamp(x, 0, width - 1);
const py = clamp(y, 0, height - 1);
const index = (py * width + px) * 4;
const r = data[index];
const g = data[index + 1];
const b = data[index + 2];
const alpha = data[index + 3];
const luma = mockupRgbLuma(r, g, b);
const nextX = clamp(px + step, 0, width - 1);
const nextY = clamp(py + step, 0, height - 1);
lumaTotal += luma;
textureTotal += Math.abs(luma - mockupLuma(data, width, height, nextX, py)) + Math.abs(luma - mockupLuma(data, width, height, px, nextY));
skinCount += isMockupSkinColor(r, g, b) ? 1 : 0;
transparentCount += alpha < 220 ? 1 : 0;
count += 1;
}
}
if (!count) return Number.NEGATIVE_INFINITY;
const centerX = placement.x + placement.width / 2;
const centerY = placement.y + placement.height / 2;
const centerScore = 1 - Math.abs(centerX - 0.5) / 0.5;
const yScore = 1 - Math.abs(centerY - preferredY) / 0.38;
const skinRatio = skinCount / count;
const transparentRatio = transparentCount / count;
const lumaScore = 1 - Math.abs(lumaTotal / count / 255 - 0.58) / 0.58;
const textureScore = clamp(textureTotal / count / 42, 0, 1);
return centerScore * 1.35 + yScore * 1.15 + lumaScore * 0.25 + textureScore * 0.4 - skinRatio * 3.1 - transparentRatio * 2.2;
}
function mockupPlacementWorldRect(target: CanvasNode, placement: MockupPlacement) {
return {
x: target.x + target.width * placement.x,
y: target.y + target.height * placement.y,
width: target.width * placement.width,
height: target.height * placement.height
};
}
function mockupPlacementScreenBounds(target: CanvasNode, placement: MockupPlacement, viewport: CanvasViewport): NodeScreenBounds {
const rect = mockupPlacementWorldRect(target, placement);
return {
left: viewport.x + rect.x * viewport.k,
top: viewport.y + rect.y * viewport.k,
width: rect.width * viewport.k,
height: rect.height * viewport.k
};
}
function pointInsideMockupPlacement(x: number, y: number, target: CanvasNode, placement: MockupPlacement) {
const rect = mockupPlacementWorldRect(target, placement);
return x >= rect.x && x <= rect.x + rect.width && y >= rect.y && y <= rect.y + rect.height;
}
async function resolveMockupPlacement(target: CanvasNode, model: MockupModel, surface: MockupSurface): Promise<MockupPlacement> {
const surfacePlacement = mockupPlacementFromSurface(surface);
if (isUsableMockupPrintAreaForTarget(surfacePlacement, target)) return surfacePlacement;
const modelPlacement = await mockupPlacementFromModel(model);
if (isUsableMockupPrintAreaForTarget(modelPlacement, target)) return modelPlacement;
const detected = await detectMockupForNode(target).catch(() => null);
if (detected && isUsableMockupPrintAreaForTarget(detected.placement, target)) return detected.placement;
return defaultMockupPlacementForNode(target);
}
function usableMockupSurface(surface: MockupSurface | undefined, target: CanvasNode): MockupSurface {
if (!surface?.polygon || surface.polygon.length < 3) return defaultMockupSurfaceForNode(target);
const placement = mockupPlacementFromSurface(surface);
if (!isUsableMockupPrintAreaForTarget(placement, target)) return defaultMockupSurfaceForNode(target);
return surface;
}
function mockupPlacementFromSurface(surface: MockupSurface): MockupPlacement {
if (!surface?.polygon?.length) return { x: 0.38, y: 0.3, width: 0.24, height: 0.28 };
let minX = 1;
let minY = 1;
let maxX = 0;
let maxY = 0;
surface.polygon.forEach((point) => {
minX = Math.min(minX, point.x);
minY = Math.min(minY, point.y);
maxX = Math.max(maxX, point.x);
maxY = Math.max(maxY, point.y);
});
return {
x: clamp(minX, 0, 0.98),
y: clamp(minY, 0, 0.98),
width: clamp(maxX - minX, 0.04, 1),
height: clamp(maxY - minY, 0.04, 1)
};
}
function isReasonableMockupPrintArea(placement: MockupPlacement) {
const area = placement.width * placement.height;
return placement.width >= 0.06 && placement.height >= 0.05 && placement.width <= 0.48 && placement.height <= 0.36 && area <= 0.16 && placement.x > 0.02 && placement.y > 0.06;
}
function isUsableMockupPrintAreaForTarget(placement: MockupPlacement, target: CanvasNode) {
if (!isReasonableMockupPrintArea(placement)) return false;
const aspect = target.width / Math.max(target.height, 1);
const area = placement.width * placement.height;
const centered = placement.x > 0.18 && placement.x + placement.width < 0.82 && placement.y > 0.16 && placement.y < 0.56;
const garmentLikeFrame = aspect > 0.72 && aspect < 1.38;
return !(garmentLikeFrame && centered && placement.width >= 0.28 && (placement.height < 0.22 || area < 0.082));
}
async function mockupPlacementFromModel(model: MockupModel): Promise<MockupPlacement> {
const segmentation = await loadMockupByteMap(model.segmentationMap);
let minX = segmentation.width;
let minY = segmentation.height;
let maxX = -1;
let maxY = -1;
for (let y = 0; y < segmentation.height; y += 1) {
for (let x = 0; x < segmentation.width; x += 1) {
if (segmentation.data[y * segmentation.width + x] < 16) continue;
minX = Math.min(minX, x);
minY = Math.min(minY, y);
maxX = Math.max(maxX, x);
maxY = Math.max(maxY, y);
}
}
if (maxX < minX || maxY < minY) {
return { x: 0.38, y: 0.3, width: 0.24, height: 0.28 };
}
const padX = Math.round((maxX - minX + 1) * 0.03);
const padY = Math.round((maxY - minY + 1) * 0.03);
return {
x: clamp((minX - padX) / segmentation.width, 0, 0.98),
y: clamp((minY - padY) / segmentation.height, 0, 0.98),
width: clamp((maxX - minX + 1 + padX * 2) / segmentation.width, 0.04, 1),
height: clamp((maxY - minY + 1 + padY * 2) / segmentation.height, 0.04, 1)
};
}
function mockupPrintClipPath(node: CanvasNode, nodes: CanvasNode[]) {
if (node.layerRole !== "mockup-print" || !node.mockupSurface?.polygon?.length) return undefined;
const target = nodes.find((item) => item.id === node.mockupSurface?.targetId);
if (!target) return undefined;
const polygon = node.mockupSurface.polygon
.map((point) => {
const worldX = target.x + target.width * point.x;
const worldY = target.y + target.height * point.y;
return `${formatMockupCssNumber(worldX - node.x)}px ${formatMockupCssNumber(worldY - node.y)}px`;
})
.join(", ");
return `polygon(${polygon})`;
}
function formatMockupCssNumber(value: number) {
return Number(value.toFixed(3));
}
function mockupPrintPlacementForDrop(source: CanvasNode, target: CanvasNode, placement: MockupPlacement, dropPoint?: { x: number; y: number }): MockupPlacement {
const surfaceRect = mockupPlacementWorldRect(target, placement);
const maxWidth = surfaceRect.width * 0.92;
const maxHeight = surfaceRect.height * 0.92;
const sourceWidth = Math.max(source.width, 1);
const sourceHeight = Math.max(source.height, 1);
const sourceAspect = sourceWidth / sourceHeight;
let width = maxWidth;
let height = width / Math.max(sourceAspect, 0.01);
if (height > maxHeight) {
height = maxHeight;
width = height * sourceAspect;
}
width = clamp(width, Math.min(surfaceRect.width * 0.32, maxWidth), maxWidth);
height = clamp(height, Math.min(surfaceRect.height * 0.32, maxHeight), maxHeight);
const centerX = dropPoint ? clamp(dropPoint.x, surfaceRect.x, surfaceRect.x + surfaceRect.width) : surfaceRect.x + surfaceRect.width / 2;
const centerY = dropPoint ? clamp(dropPoint.y, surfaceRect.y, surfaceRect.y + surfaceRect.height) : surfaceRect.y + surfaceRect.height / 2;
const x = clamp(centerX - width / 2, surfaceRect.x, surfaceRect.x + Math.max(surfaceRect.width - width, 0));
const y = clamp(centerY - height / 2, surfaceRect.y, surfaceRect.y + Math.max(surfaceRect.height - height, 0));
return {
x: (x - target.x) / Math.max(target.width, 1),
y: (y - target.y) / Math.max(target.height, 1),
width: width / Math.max(target.width, 1),
height: height / Math.max(target.height, 1)
};
}
function fitMockupPrintIntoPlacement(source: CanvasNode, target: CanvasNode, placement: MockupPlacement) {
const rect = mockupPlacementWorldRect(target, mockupPrintPlacementForDrop(source, target, placement));
const sourceAspect = source.width / Math.max(source.height, 1);
const rectAspect = rect.width / Math.max(rect.height, 1);
let width = rect.width;
let height = rect.height;
if (sourceAspect > rectAspect) {
height = width / Math.max(sourceAspect, 0.01);
} else {
width = height * sourceAspect;
}
return {
x: rect.x + (rect.width - width) / 2,
y: rect.y + (rect.height - height) / 2,
width,
height
};
}
function mockupPlacementFromPrintNode(printNode: CanvasNode, targetNode: CanvasNode): MockupPlacement {
return {
x: (printNode.x - targetNode.x) / Math.max(targetNode.width, 1),
y: (printNode.y - targetNode.y) / Math.max(targetNode.height, 1),
width: printNode.width / Math.max(targetNode.width, 1),
height: printNode.height / Math.max(targetNode.height, 1)
};
}
async function renderMockupPrintTexture(sourceUrl: string, targetUrl: string, placement: MockupPlacement, fit: { width: number; height: number }, model: MockupModel) {
const [sourceImage, targetImage, modelData] = await Promise.all([loadMockupImage(canvasImageUrl(sourceUrl)), loadMockupImage(canvasImageUrl(targetUrl)), loadMockupModelData(model)]);
const outputSize = mockupTextureOutputSize(fit);
const sourceData = drawMockupSourceImage(sourceImage, outputSize.width, outputSize.height);
const textureData = drawMockupTextureRegion(targetImage, placement, outputSize.width, outputSize.height);
const displacedCanvas = renderDisplacedMockupCanvas(sourceData, textureData, modelData, placement, outputSize.width, outputSize.height);
return renderMockupCanvasWithShadow(displacedCanvas, textureData).toDataURL("image/png");
}
function loadMockupImage(src: string) {
return new Promise<HTMLImageElement>((resolve, reject) => {
const image = new window.Image();
if (!src.startsWith("data:") && !src.startsWith("blob:")) {
image.crossOrigin = "anonymous";
}
image.onload = () => resolve(image);
image.onerror = () => reject(new Error("Mockup image could not be loaded"));
image.src = src;
});
}
type MockupFloatMapData = {
width: number;
height: number;
data: Float32Array;
};
type MockupByteMapData = {
width: number;
height: number;
data: Uint8Array;
};
type MockupModelData = {
depth: MockupFloatMapData;
shade: MockupFloatMapData;
segmentation: MockupByteMapData;
xOffset: MockupFloatMapData;
yOffset: MockupFloatMapData;
depthScaleMin: number;
depthScaleMax: number;
};
async function loadMockupModelData(model: MockupModel): Promise<MockupModelData> {
const [depth, shade, segmentation, xOffset, yOffset] = await Promise.all([
loadMockupFloatMap(model.depthMap),
loadMockupFloatMap(model.shadeMap),
loadMockupByteMap(model.segmentationMap),
loadMockupFloatMap(model.xOffsetMap),
loadMockupFloatMap(model.yOffsetMap)
]);
return {
depth,
shade,
segmentation,
xOffset,
yOffset,
depthScaleMin: model.depthScaleMin,
depthScaleMax: model.depthScaleMax
};
}
async function loadMockupFloatMap(modelMap: MockupModelMap): Promise<MockupFloatMapData> {
if (modelMap.data?.length) {
return {
width: modelMap.width,
height: modelMap.height,
data: Float32Array.from(modelMap.data)
};
}
const buffer = await fetchMockupModelBuffer(modelMap);
return {
width: modelMap.width,
height: modelMap.height,
data: new Float32Array(buffer)
};
}
async function loadMockupByteMap(modelMap: MockupModelMap): Promise<MockupByteMapData> {
if (modelMap.data?.length) {
return {
width: modelMap.width,
height: modelMap.height,
data: Uint8Array.from(modelMap.data.map((value) => clamp(Math.round(value), 0, 255)))
};
}
const buffer = await fetchMockupModelBuffer(modelMap);
return {
width: modelMap.width,
height: modelMap.height,
data: new Uint8Array(buffer)
};
}
async function fetchMockupModelBuffer(modelMap: MockupModelMap) {
const response = await fetch(modelMap.url || `/api/mockup/blob/${encodeURIComponent(modelMap.dataId)}`);
if (!response.ok) throw new Error(`Mockup model blob failed: ${response.status}`);
return response.arrayBuffer();
}
function hasUsableMockupModel(model?: MockupModel | null): model is MockupModel {
return Boolean(
model &&
hasUsableMockupModelMap(model.depthMap) &&
hasUsableMockupModelMap(model.shadeMap) &&
hasUsableMockupModelMap(model.segmentationMap) &&
hasUsableMockupModelMap(model.xOffsetMap) &&
hasUsableMockupModelMap(model.yOffsetMap) &&
model.cameraIntrinsics?.matrix?.length === 9
);
}
function hasUsableMockupModelMap(modelMap?: MockupModelMap | null) {
if (!modelMap || modelMap.width <= 0 || modelMap.height <= 0) return false;
const expected = modelMap.width * modelMap.height;
return Boolean((modelMap.data?.length ?? 0) === expected || modelMap.dataId || modelMap.url);
}
function mockupTextureOutputSize(fit: { width: number; height: number }) {
const aspect = Math.max(0.05, fit.width / Math.max(fit.height, 1));
const longEdge = clamp(Math.round(Math.max(fit.width, fit.height) * 3), 220, 900);
if (aspect >= 1) {
return {
width: longEdge,
height: Math.max(1, Math.round(longEdge / aspect))
};
}
return {
width: Math.max(1, Math.round(longEdge * aspect)),
height: longEdge
};
}
function drawMockupSourceImage(image: HTMLImageElement, width: number, height: number) {
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d", { willReadFrequently: true });
if (!ctx) throw new Error("Canvas is not available");
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = "high";
ctx.drawImage(image, 0, 0, width, height);
const imageData = ctx.getImageData(0, 0, width, height);
softenMockupSourceMatte(imageData);
return imageData;
}
function drawMockupTextureRegion(image: HTMLImageElement, placement: MockupPlacement, width: number, height: number) {
const naturalWidth = image.naturalWidth || image.width;
const naturalHeight = image.naturalHeight || image.height;
const sx = clamp(Math.round(placement.x * naturalWidth), 0, Math.max(naturalWidth - 1, 0));
const sy = clamp(Math.round(placement.y * naturalHeight), 0, Math.max(naturalHeight - 1, 0));
const sw = clamp(Math.round(placement.width * naturalWidth), 1, Math.max(naturalWidth - sx, 1));
const sh = clamp(Math.round(placement.height * naturalHeight), 1, Math.max(naturalHeight - sy, 1));
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d", { willReadFrequently: true });
if (!ctx) throw new Error("Canvas is not available");
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = "high";
ctx.drawImage(image, sx, sy, sw, sh, 0, 0, width, height);
return ctx.getImageData(0, 0, width, height);
}
function renderDisplacedMockupCanvas(sourceData: ImageData, textureData: ImageData, modelData: MockupModelData, placement: MockupPlacement, width: number, height: number) {
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d", { willReadFrequently: true });
if (!ctx) throw new Error("Canvas is not available");
const output = ctx.createImageData(width, height);
const strength = clamp(Math.max(width, height) * 0.035, 2.4, 16);
const useSegmentationMask = mockupSegmentationCoversPlacement(modelData.segmentation, placement);
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
const index = (y * width + x) * 4;
const u = placement.x + (x / Math.max(width - 1, 1)) * placement.width;
const v = placement.y + (y / Math.max(height - 1, 1)) * placement.height;
const mask = useSegmentationMask ? sampleMockupByteMap(modelData.segmentation, u, v) / 255 : 1;
if (mask <= 0.015) {
output.data[index + 3] = 0;
continue;
}
const offsetX = sampleMockupFloatMap(modelData.xOffset, u, v) * strength;
const offsetY = sampleMockupFloatMap(modelData.yOffset, u, v) * strength;
const sample = sampleMockupPixel(sourceData.data, width, height, x + offsetX, y + offsetY);
const center = mockupLuma(textureData.data, width, height, x, y);
const shadeValue = sampleMockupFloatMap(modelData.shade, u, v);
const depthValue = sampleMockupFloatMap(modelData.depth, u, v);
const depthRange = Math.max(modelData.depthScaleMax - modelData.depthScaleMin, 1);
const depth = (depthValue - modelData.depthScaleMin) / depthRange;
const shade = clamp(0.62 + shadeValue * 0.58 + (center / 255 - 0.5) * 0.18 + depth * 0.035, 0.42, 1.2);
const alpha = sample.a * smoothMockupStep(0.02, 0.18, mask);
output.data[index] = clamp(Math.round(sample.r * shade), 0, 255);
output.data[index + 1] = clamp(Math.round(sample.g * shade), 0, 255);
output.data[index + 2] = clamp(Math.round(sample.b * shade), 0, 255);
output.data[index + 3] = clamp(Math.round(alpha), 0, 255);
}
}
ctx.putImageData(output, 0, 0);
return canvas;
}
function mockupSegmentationCoversPlacement(segmentation: MockupByteMapData, placement: MockupPlacement) {
const columns = 9;
const rows = 9;
let covered = 0;
for (let y = 0; y < rows; y += 1) {
for (let x = 0; x < columns; x += 1) {
const u = placement.x + ((x + 0.5) / columns) * placement.width;
const v = placement.y + ((y + 0.5) / rows) * placement.height;
if (sampleMockupByteMap(segmentation, u, v) >= 16) covered += 1;
}
}
return covered / (columns * rows) >= 0.18;
}
function renderMockupCanvasWithShadow(displacedCanvas: HTMLCanvasElement, textureData: ImageData) {
const canvas = document.createElement("canvas");
canvas.width = displacedCanvas.width;
canvas.height = displacedCanvas.height;
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("Canvas is not available");
const shadowOffset = clamp(Math.round(Math.max(canvas.width, canvas.height) * 0.009), 1, 5);
ctx.save();
ctx.shadowColor = "rgba(0, 0, 0, 0.24)";
ctx.shadowBlur = clamp(Math.round(Math.max(canvas.width, canvas.height) * 0.025), 4, 18);
ctx.shadowOffsetX = shadowOffset;
ctx.shadowOffsetY = shadowOffset;
ctx.drawImage(displacedCanvas, 0, 0);
ctx.restore();
ctx.drawImage(displacedCanvas, 0, 0);
const textureCanvas = mockupTextureMaskCanvas(textureData, displacedCanvas);
ctx.save();
ctx.globalCompositeOperation = "source-atop";
ctx.globalAlpha = 0.08;
ctx.drawImage(textureCanvas, 0, 0);
ctx.restore();
ctx.save();
ctx.globalCompositeOperation = "multiply";
ctx.globalAlpha = 0.16;
ctx.drawImage(textureCanvas, 0, 0);
ctx.restore();
return canvas;
}
function softenMockupSourceMatte(imageData: ImageData) {
const { width, height, data } = imageData;
const matte = estimateMockupEdgeMatte(data, width, height);
if (!matte || matte.transparentRatio > 0.08 || matte.luma < 224 || matte.saturation > 28) return;
for (let index = 0; index < data.length; index += 4) {
const distance = Math.hypot(data[index] - matte.r, data[index + 1] - matte.g, data[index + 2] - matte.b);
const neutralLight = mockupRgbLuma(data[index], data[index + 1], data[index + 2]) > 218 && mockupRgbSaturation(data[index], data[index + 1], data[index + 2]) < 42;
if (!neutralLight || distance > 82) continue;
data[index + 3] = Math.round(data[index + 3] * smoothMockupStep(18, 74, distance));
}
}
function estimateMockupEdgeMatte(data: Uint8ClampedArray, width: number, height: number) {
const step = Math.max(1, Math.floor(Math.min(width, height) / 90));
let r = 0;
let g = 0;
let b = 0;
let alpha = 0;
let count = 0;
let transparent = 0;
const sample = (x: number, y: number) => {
const index = (y * width + x) * 4;
if (data[index + 3] < 220) {
transparent += 1;
return;
}
r += data[index];
g += data[index + 1];
b += data[index + 2];
alpha += data[index + 3];
count += 1;
};
for (let x = 0; x < width; x += step) {
sample(x, 0);
sample(x, height - 1);
}
for (let y = 0; y < height; y += step) {
sample(0, y);
sample(width - 1, y);
}
const total = count + transparent;
if (!count || !total) return null;
const matte = {
r: r / count,
g: g / count,
b: b / count,
alpha: alpha / count,
transparentRatio: transparent / total
};
return {
...matte,
luma: mockupRgbLuma(matte.r, matte.g, matte.b),
saturation: mockupRgbSaturation(matte.r, matte.g, matte.b)
};
}
function mockupTextureMaskCanvas(textureData: ImageData, alphaCanvas: HTMLCanvasElement) {
const textureCanvas = document.createElement("canvas");
textureCanvas.width = textureData.width;
textureCanvas.height = textureData.height;
const textureCtx = textureCanvas.getContext("2d");
if (!textureCtx) return textureCanvas;
textureCtx.putImageData(textureData, 0, 0);
textureCtx.globalCompositeOperation = "destination-in";
textureCtx.drawImage(alphaCanvas, 0, 0);
return textureCanvas;
}
function mockupLuma(data: Uint8ClampedArray, width: number, height: number, x: number, y: number) {
const px = clamp(Math.round(x), 0, width - 1);
const py = clamp(Math.round(y), 0, height - 1);
const index = (py * width + px) * 4;
return mockupRgbLuma(data[index], data[index + 1], data[index + 2]);
}
function mockupRgbLuma(r: number, g: number, b: number) {
return r * 0.299 + g * 0.587 + b * 0.114;
}
function mockupRgbSaturation(r: number, g: number, b: number) {
return Math.max(r, g, b) - Math.min(r, g, b);
}
function isMockupSkinColor(r: number, g: number, b: number) {
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
return r > 95 && g > 40 && b > 20 && max - min > 15 && Math.abs(r - g) > 15 && r > g && r > b;
}
function smoothMockupStep(edge0: number, edge1: number, value: number) {
const t = clamp((value - edge0) / Math.max(edge1 - edge0, 1), 0, 1);
return t * t * (3 - 2 * t);
}
function sampleMockupFloatMap(map: MockupFloatMapData, u: number, v: number) {
return sampleMockupScalarMap(map.data, map.width, map.height, u, v);
}
function sampleMockupByteMap(map: MockupByteMapData, u: number, v: number) {
return sampleMockupScalarMap(map.data, map.width, map.height, u, v);
}
function sampleMockupScalarMap(data: Float32Array | Uint8Array, width: number, height: number, u: number, v: number) {
const px = clamp(u, 0, 1) * Math.max(width - 1, 0);
const py = clamp(v, 0, 1) * Math.max(height - 1, 0);
const x0 = Math.floor(px);
const y0 = Math.floor(py);
const x1 = clamp(x0 + 1, 0, width - 1);
const y1 = clamp(y0 + 1, 0, height - 1);
const tx = px - x0;
const ty = py - y0;
const c00 = data[y0 * width + x0] ?? 0;
const c10 = data[y0 * width + x1] ?? c00;
const c01 = data[y1 * width + x0] ?? c00;
const c11 = data[y1 * width + x1] ?? c00;
return bilinearChannel(c00, c10, c01, c11, tx, ty);
}
function sampleMockupPixel(data: Uint8ClampedArray, width: number, height: number, x: number, y: number) {
const px = clamp(x, 0, width - 1);
const py = clamp(y, 0, height - 1);
const x0 = Math.floor(px);
const y0 = Math.floor(py);
const x1 = clamp(x0 + 1, 0, width - 1);
const y1 = clamp(y0 + 1, 0, height - 1);
const tx = px - x0;
const ty = py - y0;
const c00 = readMockupPixel(data, width, x0, y0);
const c10 = readMockupPixel(data, width, x1, y0);
const c01 = readMockupPixel(data, width, x0, y1);
const c11 = readMockupPixel(data, width, x1, y1);
return {
r: bilinearChannel(c00.r, c10.r, c01.r, c11.r, tx, ty),
g: bilinearChannel(c00.g, c10.g, c01.g, c11.g, tx, ty),
b: bilinearChannel(c00.b, c10.b, c01.b, c11.b, tx, ty),
a: bilinearChannel(c00.a, c10.a, c01.a, c11.a, tx, ty)
};
}
function readMockupPixel(data: Uint8ClampedArray, width: number, x: number, y: number) {
const index = (y * width + x) * 4;
return {
r: data[index],
g: data[index + 1],
b: data[index + 2],
a: data[index + 3]
};
}
function bilinearChannel(c00: number, c10: number, c01: number, c11: number, tx: number, ty: number) {
const top = c00 + (c10 - c00) * tx;
const bottom = c01 + (c11 - c01) * tx;
return top + (bottom - top) * ty;
}
function buildTextEditPrompt(items: EditableTextLayer[], fallback: string) {
const edits = items
.filter((item) => item.removed || (item.originalText || item.text) !== item.text)
.map((item) => `- ${item.originalText || item.text} -> ${item.removed ? "[removed]" : item.text}`);
if (edits.length === 0) return fallback;
return `${fallback}\nTargeted text edits:\n${edits.join("\n")}`;
}
async function cropImageNodeFile(node: CanvasNode, selection: NormalizedRect) {
const rect = clampCropSelection(selection);
const blob = await imageBlobForCanvas(node.content);
const imageUrl = URL.createObjectURL(blob);
try {
const image = await loadCanvasImage(imageUrl);
const naturalWidth = image.naturalWidth || image.width;
const naturalHeight = image.naturalHeight || image.height;
const sourceX = Math.round(rect.x * naturalWidth);
const sourceY = Math.round(rect.y * naturalHeight);
const sourceWidth = Math.max(1, Math.round(rect.width * naturalWidth));
const sourceHeight = Math.max(1, Math.round(rect.height * naturalHeight));
const canvas = document.createElement("canvas");
canvas.width = sourceWidth;
canvas.height = sourceHeight;
const context = canvas.getContext("2d");
if (!context) throw new Error("Failed to create crop canvas");
context.clearRect(0, 0, sourceWidth, sourceHeight);
context.drawImage(image, sourceX, sourceY, sourceWidth, sourceHeight, 0, 0, sourceWidth, sourceHeight);
const output = await canvasToImageBlob(canvas, "image/webp", 0.94);
if (!output) throw new Error("Failed to encode cropped image");
return {
file: new File([output], `${imageFileBaseName(node.title || "image")}-cropped.webp`, {
type: "image/webp",
lastModified: Date.now()
}),
displayWidth: Math.max(1, node.width * rect.width),
displayHeight: Math.max(1, node.height * rect.height)
};
} finally {
URL.revokeObjectURL(imageUrl);
}
}
function clampCropSelection(selection: NormalizedRect) {
const width = clamp(selection.width, 0.02, 1);
const height = clamp(selection.height, 0.02, 1);
return {
x: clamp(selection.x, 0, Math.max(0, 1 - width)),
y: clamp(selection.y, 0, Math.max(0, 1 - height)),
width,
height
};
}
async function imageBlobForCanvas(source: string) {
const response = await fetch(source);
if (!response.ok) throw new Error(`Failed to load image: ${response.status}`);
return response.blob();
}
function loadCanvasImage(source: string) {
return new Promise<HTMLImageElement>((resolve, reject) => {
const image = new Image();
image.onload = () => resolve(image);
image.onerror = () => reject(new Error("Failed to decode image"));
image.src = source;
});
}
function canvasToImageBlob(canvas: HTMLCanvasElement, type: string, quality: number) {
return new Promise<Blob | null>((resolve, reject) => {
try {
canvas.toBlob(resolve, type, quality);
} catch (error) {
reject(error);
}
});
}
function imageFileBaseName(value: string) {
const base = value.trim().replace(/\.[a-z0-9]+$/i, "").replace(/[/\\?%*:|"<>]/g, "-");
return base || "image";
}
function textExtractionResultFromProject(project: Project, threadId: string): ExtractNodeTextResponse | null {
const message = [...project.messages]
.reverse()
.find((item) => item.threadId === threadId && item.type === "text_extraction_result");
if (!message) return null;
try {
const parsed = JSON.parse(message.content) as ExtractNodeTextResponse;
return Array.isArray(parsed.items) ? parsed : null;
} catch {
return null;
}
}
function canvasBackgroundStorageKey(projectId: string) {
return `fluxo:canvas-background:${projectId}`;
}
function isTransparentClipboardNode(node: CanvasNode) {
return node.type === "image" && (node.layerRole === "background-removed" || node.tone === "transparent-image");
}
async function shouldUseInternalClipboardNode(node: CanvasNode, imageFiles: File[]) {
const imageFile = imageFiles[0];
if (!imageFile) return false;
try {
const dimensions = await readImageDimensions(imageFile);
return dimensions.width === Math.max(1, Math.round(node.width)) && dimensions.height === Math.max(1, Math.round(node.height));
} catch {
return false;
}
}
async function writeCanvasNodeToSystemClipboard(node: CanvasNode) {
if (node.type === "text") {
await writeClipboardText(node.content || node.title);
return;
}
const blob = await clipboardImageBlobForNode(node);
await writeClipboardBlob(blob);
}
async function clipboardImageBlobForNode(node: CanvasNode) {
if (node.type === "image" && (isRealCanvasImageNode(node) || isCanvasPenStrokeNode(node))) {
return imageNodeRasterBlob(node, "image/png");
}
const width = Math.max(1, Math.round(node.width));
const height = Math.max(1, Math.round(node.height));
return svgToRasterBlob(canvasNodeSvg(node), width, height, "image/png");
}
async function writeClipboardBlob(blob: Blob) {
if (!navigator.clipboard?.write || typeof ClipboardItem === "undefined") {
throw new Error("System clipboard image writes are not available");
}
await navigator.clipboard.write([new ClipboardItem({ [blob.type || "image/png"]: blob })]);
}
async function writeClipboardText(text: string) {
if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text);
return;
} catch {
// Fall back for browsers that expose Clipboard API but block the write.
}
}
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.style.position = "fixed";
textarea.style.opacity = "0";
document.body.append(textarea);
textarea.select();
const copied = document.execCommand("copy");
textarea.remove();
if (!copied) throw new Error("System clipboard text write failed");
}
function cryptoFallbackId() {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) return crypto.randomUUID();
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
function isImageFile(file: File) {
return file.type.startsWith("image/") || /\.(png|jpe?g|webp|gif|avif|svg)$/i.test(file.name);
}
function dataTransferImageFiles(dataTransfer: DataTransfer, fallbackBaseName = "canvas-image") {
const candidates = [
...Array.from(dataTransfer.files),
...Array.from(dataTransfer.items)
.filter((item) => item.kind === "file" && item.type.startsWith("image/"))
.map((item) => item.getAsFile())
.filter((file): file is File => Boolean(file))
];
const seen = new Set<string>();
return candidates.filter(isImageFile).reduce<File[]>((files, file, index) => {
const normalizedFile = ensureImageFileName(file, `${fallbackBaseName}-${Date.now()}-${index + 1}`);
const key = `${normalizedFile.name}:${normalizedFile.type}:${normalizedFile.size}:${normalizedFile.lastModified}`;
if (seen.has(key)) return files;
seen.add(key);
files.push(normalizedFile);
return files;
}, []);
}
function dataTransferHasImage(dataTransfer: DataTransfer) {
const files = dataTransferImageFiles(dataTransfer);
if (files.length > 0) return true;
return Array.from(dataTransfer.items).some((item) => item.kind === "file" && item.type.startsWith("image/"));
}
function ensureImageFileName(file: File, baseName: string) {
if (file.name.trim()) return file;
const type = file.type || "image/png";
return new File([file], `${baseName}.${imageExtensionFromType(type)}`, {
type,
lastModified: file.lastModified || Date.now()
});
}
function imageExtensionFromType(type: string) {
if (type.includes("jpeg") || type.includes("jpg")) return "jpg";
if (type.includes("webp")) return "webp";
if (type.includes("gif")) return "gif";
if (type.includes("avif")) return "avif";
return "png";
}
function readImageDimensions(file: File) {
return new Promise<{ width: number; height: number }>((resolve, reject) => {
const url = URL.createObjectURL(file);
const image = new window.Image();
image.onload = () => {
URL.revokeObjectURL(url);
resolve({ width: image.naturalWidth || image.width, height: image.naturalHeight || image.height });
};
image.onerror = () => {
URL.revokeObjectURL(url);
reject(new Error("Image dimensions could not be read"));
};
image.src = url;
});
}
function fitCanvasImageSize(width: number, height: number) {
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
return { width: 420, height: 420 };
}
const maxSide = 520;
const minSide = 120;
let scale = Math.min(1, maxSide / Math.max(width, height));
if (Math.max(width, height) * scale < minSide) {
scale = minSide / Math.max(width, height);
}
return {
width: Math.round(width * scale),
height: Math.round(height * scale)
};
}
function isApplePlatform() {
if (typeof navigator === "undefined") return false;
const nav = navigator as Navigator & { userAgentData?: { platform?: string } };
const platform = nav.userAgentData?.platform || navigator.platform || "";
return /mac|iphone|ipad|ipod/i.test(platform);
}
function isPrimaryShortcut(event: KeyboardEvent) {
const isMac = isApplePlatform();
if (event.altKey) return false;
return isMac ? event.metaKey && !event.ctrlKey : event.ctrlKey && !event.metaKey;
}
function isEditableShortcutTarget(target: EventTarget | null) {
if (!(target instanceof HTMLElement)) return false;
const tagName = target.tagName.toLowerCase();
return tagName === "input" || tagName === "textarea" || tagName === "select" || target.isContentEditable || Boolean(target.closest("[contenteditable='true'], [role='textbox']"));
}
function visibleAgentMessagesForThread(project: Project, hiddenThreadIds: ReadonlySet<string>, threadId: string | null) {
const visibleMessages = visibleAgentMessages(project.messages, hiddenThreadIds);
const resolvedThreadId = threadId || latestAgentThreadId(project, hiddenThreadIds);
if (!resolvedThreadId) return [];
if (threadId === newAgentConversationId) return [];
return visibleMessages.filter((message) => message.threadId === resolvedThreadId);
}
function agentHistoryListItems(threads: AgentThreadSummary[], project: Project | null, hiddenThreadIds: ReadonlySet<string>) {
const items = threads.length > 0 ? threads : fallbackAgentThreads(project, hiddenThreadIds);
const seen = new Set<string>();
return [...items]
.sort((a, b) => b.updateTimestamp - a.updateTimestamp)
.filter((thread) => {
if (!thread.agentThreadId || seen.has(thread.agentThreadId)) return false;
seen.add(thread.agentThreadId);
return true;
});
}
function fallbackAgentThreads(project: Project | null, hiddenThreadIds: ReadonlySet<string>): AgentThreadSummary[] {
if (!project) return [];
const threads = new Map<string, AgentThreadSummary>();
visibleAgentMessages(project.messages, hiddenThreadIds).forEach((message) => {
if (!message.threadId) return;
const existing = threads.get(message.threadId);
const updateTimestamp = Date.parse(message.createdAt) || 0;
if (!existing) {
threads.set(message.threadId, {
agentThreadId: message.threadId,
coverImageUrl: "",
text: message.role === "user" ? agentThreadTitleText(message.content) : "",
threadIdType: 9,
updateTimestamp
});
return;
}
if (!existing.text && message.role === "user") {
existing.text = agentThreadTitleText(message.content);
}
existing.updateTimestamp = Math.max(existing.updateTimestamp, updateTimestamp);
});
return Array.from(threads.values()).filter((thread) => thread.text.trim());
}
function agentThreadTitle(thread: AgentThreadSummary) {
return thread.text.trim() || "新对话";
}
function latestAgentThreadId(project: Project | null, hiddenThreadIds: ReadonlySet<string>) {
return fallbackAgentThreads(project, hiddenThreadIds)
.sort((a, b) => b.updateTimestamp - a.updateTimestamp)[0]?.agentThreadId || "";
}
function agentThreadTitleText(content: string) {
const lines = content
.split("\n")
.map((line) => line.trim())
.filter((line) => {
const lower = line.toLowerCase();
return line && !lower.startsWith("image generator target node:") && !lower.startsWith("selected model:") && !line.startsWith("模型选择");
});
return lines.join(" ").trim() || content.trim();
}
function isProjectGenerating(project: Project) {
return project.status === "exploring" || project.nodes.some((node) => node.status === "generating");
}
function projectHasImageGeneratorGeneration(project: Project) {
return project.nodes.some((node) => node.status === "generating" && node.layerRole === "image-generator");
}
function projectHasCanvasActionGeneration(project: Project) {
if (!project.nodes.some((node) => (node.status === "generating" && node.layerRole !== "image-generator") || node.status === "mockup-modeling")) return false;
return project.messages.some((message) => message.threadId === project.lastThreadId && (message.name === "canvas_action" || message.toolHint === "canvas_action" || message.name === "mockup_model" || message.toolHint === "mockup_model"));
}
function projectHasWebSearchEnabled(project: Project) {
return project.messages.some((message) => {
if (project.lastThreadId && message.threadId && message.threadId !== project.lastThreadId) return false;
return message.toolHint?.includes("web_search_enabled") || message.title === "Synthesize Skills";
});
}
function projectFailureMessage(project: Project) {
const failure = [...project.messages].reverse().find((message) => message.role === "error" || message.status === "failed");
return failure?.content || failure?.title || "";
}
function runningCanvasActionThreadIds(project: Project) {
const threadStates = new Map<string, "running" | "done">();
project.messages.forEach((message) => {
if (!message.threadId) return;
if (message.name !== "canvas_action" && message.toolHint !== "canvas_action" && message.name !== "mockup_model" && message.toolHint !== "mockup_model") return;
const status = String(message.status || "").toLowerCase();
if (message.role === "error" || status === "success" || status === "failed") {
threadStates.set(message.threadId, "done");
return;
}
if (!threadStates.has(message.threadId)) {
threadStates.set(message.threadId, "running");
}
});
return Array.from(threadStates.entries())
.filter(([, status]) => status === "running")
.map(([threadId]) => threadId);
}
function isAgentVisibleGeneratingNode(node: CanvasNode) {
return node.status === "generating" && node.layerRole !== "image-generator";
}
function isCanvasActionWorkingNode(node: CanvasNode) {
return node.layerRole !== "image-generator" && (node.status === "generating" || node.status === "uploading" || node.status === "text-extracting" || node.status === "mockup-modeling");
}
function isImageActionBusy(node: CanvasNode) {
return node.status === "generating" || node.status === "uploading" || node.status === "text-extracting" || node.status === "mockup-modeling";
}
function isVectorizedImageNode(node: CanvasNode) {
return node.type === "image" && node.layerRole === "vectorized-image";
}
function createOptimisticAgentMessages(threadId: string, contents: AgentContent[]): AgentMessage[] {
const now = Date.now();
const promptText = agentContentsToDisplayText(contents);
const actionId = cryptoFallbackId();
return [
{
id: `${actionId}-user`,
role: "user",
type: "user",
title: "新的设计需求",
content: promptText,
threadId,
actionId,
stepId: `${actionId}-input`,
createdAt: new Date(now).toISOString()
}
];
}
function agentContentsToDisplayText(contents: AgentContent[]) {
return contents
.map((content, index) => {
if (content.type === "text") return content.text;
return `参考图 ${index + 1}image):${content.imageUrl}`;
})
.filter((item) => item.trim())
.join("\n\n");
}
function imageGeneratorSubmittedPrompt(contents: AgentContent[]) {
return cleanImageGeneratorPrompt(
contents
.map((content, index) => {
if (content.type === "text" && !isSettingsTextSource(content.textSource)) return content.text;
if (content.type === "image") return `参考图 ${index + 1}${content.imageUrl}`;
return "";
})
.filter((item) => item.trim())
.join("\n")
);
}
function cleanImageGeneratorPrompt(value: string) {
return value
.replace(/^Image generator target node:.*$/gim, "")
.replace(/^模型选择[:].*$/gm, "")
.replace(/^Selected model:.*$/gim, "")
.replace(/[ \t]+\n/g, "\n")
.replace(/\n{3,}/g, "\n\n")
.trim();
}
function isSettingsTextSource(value?: string) {
return value?.trim().toLowerCase() === "settings";
}
function isBlankTextContent(content: string) {
return content.replace(/[\u200B-\u200D\u2060\uFEFF]/g, "").trim().length === 0;
}