b363dcf888
Introduce a leafer-ui canvas layer that mirrors rendered nodes and drives viewport, node-drag, and alignment-guide updates imperatively, committing React state on idle via startTransition to keep pan/zoom/drag at 60fps. Wire the imperative viewport through wheel zoom, zoom-to, fit-view, and panning. Add a dedicated crop tool: click a node to auto-detect a color-boundary crop region or drag to preselect one, previewed with an on-canvas draft overlay before entering the crop editor. Route uploads through renderContent so the transparent WebP texture feeds the fast display path. Add node --test coverage for the leafer renderer and color-boundary detection. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
6293 lines
260 KiB
TypeScript
6293 lines
260 KiB
TypeScript
"use client";
|
||
|
||
import { ArrowDown, BookOpen, ChevronDown, Circle, Folder, Frame, Globe2, Image as ImageIcon, Layers, Lock, LogOut, Map as MapIcon, Loader2, MessageCircle, MessageSquarePlus, PanelRightClose, PenLine, Search, Trash2, Type, User, Zap } from "lucide-react";
|
||
import { startTransition, useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
|
||
import { openBrandKit, openHome } from "@/application/route";
|
||
import { ContextMenu, ContextMenuTrigger } from "@/components/ui/context-menu";
|
||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||
import type { BrandKit } from "@/domain/brandKit";
|
||
import type { AgentContent, AgentMessage, AgentStarterConfig, AgentThreadSummary, CanvasNode, CanvasViewport, ExtractedTextLayer, ExtractNodeTextResponse, MockupModel, MockupModelMap, MockupPlacement, MockupSurface, NodeActionRequest, Project, ShareAccess } from "@/domain/design";
|
||
import { useI18n, type Locale } from "@/i18n/i18n";
|
||
import { brandKitRepository, brandKitStorageScope } from "@/infrastructure/brandKitRepository";
|
||
import { designGateway } from "@/infrastructure/designGateway";
|
||
import { httpStatusOf } from "@/infrastructure/userMessages";
|
||
import { useAuth } from "@/ui/auth/AuthProvider";
|
||
import { AccountManagementDialog } from "@/ui/components/AccountManagementDialog";
|
||
import { ArtboardPreview } from "@/ui/components/ArtboardPreview";
|
||
import { BrandKitSelector } from "@/ui/components/BrandKitSelector";
|
||
import { CanvasAnnotationPreview } from "@/ui/components/CanvasAnnotationPreview";
|
||
import { CanvasAnnotations } from "@/ui/components/CanvasAnnotations";
|
||
import { CanvasAgentComposer, composerImageSize, defaultComposerImageSettings, type ComposerImageSettings, type ComposerMode } from "@/ui/components/CanvasAgentComposer";
|
||
import { CanvasAgentEmptyState } from "@/ui/components/CanvasAgentEmptyState";
|
||
import { defaultAgentStarterConfig, normalizeAgentStarterConfig } from "@/ui/components/CanvasAgentEmptyState/defaults";
|
||
import { CanvasAgentTimeline } from "@/ui/components/CanvasAgentTimeline";
|
||
import { CanvasGeneratedFilesPanel, CanvasGrid, CanvasLayerPanel } from "@/ui/components/CanvasPanels";
|
||
import { CanvasMinimap } from "@/ui/components/CanvasMinimap";
|
||
import { CanvasNodeContextMenu, type NodeContextMenuHandlers } from "@/ui/components/CanvasNodeContextMenu";
|
||
import { useGlobalToast } from "@/ui/components/CanvasToast";
|
||
import { CanvasZoomMenu } from "@/ui/components/CanvasZoomMenu";
|
||
import { useUserMessage } from "@/ui/hooks/useUserMessage";
|
||
import { ImageGeneratorFloatingComposer } from "@/ui/components/ImageGeneratorFloatingComposer";
|
||
import { AdjustPanel, CropOverlay, defaultToolbarVisibility, EraserOverlay, ExpandPanel, FlipRotatePanel, ImageNodeToolbar, MockupDropOverlay, MoveObjectOverlay, MultiAnglePanel, QuickEditPanel, VectorizedImageToolbar, type AdjustOptions, type ExpandOptions, type ImageToolbarVisibility, type MaskPoint, type MoveObjectSelection, type MultiAngleOptions, type NormalizedRect } from "@/ui/components/ImageNodeActions";
|
||
import { ImageTextEditPanel, type EditableTextLayer } from "@/ui/components/ImageTextEditPanel";
|
||
import { MultiSelectionOverlay, type MultiSelectionResizeHandle } from "@/ui/components/MultiSelectionOverlay";
|
||
import { TextStyleToolbar, type TextStylePatch } from "@/ui/components/TextStyleToolbar";
|
||
import { renderVisualAnnotationGuide, visualAnnotationPayload, VisualAnnotationPanel, type VisualAnnotationMark, type VisualAnnotationTool } from "@/ui/components/VisualAnnotationPanel";
|
||
import { loadMotevaFontCatalog } from "@/ui/components/TextStyleToolbar/fontCatalog";
|
||
import { WorkspaceTitle } from "@/ui/components/WorkspaceTitle";
|
||
import { SharePopover } from "@/ui/components/SharePopover";
|
||
import { autoImageAdjustments, defaultImageAdjustments, parseImageAdjustments, serializeImageAdjustments } from "@/ui/lib/imageAdjustments";
|
||
import { friendlyProjectFailureMessage } from "@/ui/lib/friendlyAgentErrors";
|
||
import { cadImportMessageKey, isCadFile, isCanvasImportFile, prepareCanvasImportAsset, type PreparedCanvasImportAsset } from "@/ui/lib/cadImport";
|
||
import { cadSemanticContextFromSvgText } from "@/ui/lib/cadSemantics";
|
||
import { uploadPreparedCanvasImportAsset } from "@/ui/lib/canvasImportUpload";
|
||
import { isSvgSource, isVectorImageNode } from "@/ui/lib/vectorImage";
|
||
import { canvasImageUrl, canvasNodeRenderContent } from "@/ui/lib/imageDelivery";
|
||
import { createModelAgentContentPreparer } from "@/ui/lib/modelImageReferences";
|
||
import { visiblePromptText } from "@/ui/lib/promptImageReferences";
|
||
import { isImageGeneratorThreadMessage, visibleAgentMessages } from "./canvas/agentMessageFilters";
|
||
import { CanvasColorPopover } from "./canvas/CanvasColorPopover";
|
||
import { buildAlignmentGuides, clamp, minGroupNodeScale, nodeBounds, nodeIntersectsRect, normalizeScreenRect, pointInsideNode, scaleGroupNode, screenBoundsForNodes, screenRectToWorldRect, type AlignmentGuide, type NodeAlignment, type NodeDistribution, type NodeTransform, type ResizeHandle, type SelectionBox } from "@/ui/pages/CanvasWorkspace/canvas/canvasGeometry";
|
||
import { alignNodes, attachNodesToContainingFrames, autoLayoutAroundNode as autoLayoutAroundNodeOp, autoLayoutNodes, canResizeCanvasNode, canUseNodeContextMenu, cloneCanvasNode, distributeNodes, groupNodes, isCanvasPenStrokeNode, isFrameChildNode, isFrameContainerNode, isRealCanvasImageNode, moveNodesByDelta, nodeIdsWithFrameChildren, orderedCanvasNodesForRender, patchFrameStyleNode, removeNodesById, reorderLayer as reorderLayerOp, reorderNode as reorderNodeOp, resizeFrameWithChildren, resizeVisualNodeByHandle, toggleSetValues, ungroupNodes, updateNodeContent as updateNodeContentOp, updateNodeTransform as updateNodeTransformOp, visibleCanvasNodes as getVisibleCanvasNodes, type LayerReorderRequest } from "@/ui/pages/CanvasWorkspace/canvas/canvasNodeOps";
|
||
import { CanvasSelectionFrame } from "@/ui/pages/CanvasWorkspace/canvas/CanvasSelectionFrame";
|
||
import { createCroppedSvgRasterSource, createVectorCropSiblingNode } from "./canvas/cadCrop";
|
||
import { installCanvasWheelListener } from "./canvas/canvasWheel";
|
||
import { detectColorBoundaryCrop } from "./canvas/colorBoundaryCrop";
|
||
import { createLeaferCanvasRenderer, leaferViewSize } from "./canvas/leaferRenderer";
|
||
import { cropSelectionFromScreenDrag, cropSelectionScreenBounds, visibleCropSelectionForBounds } from "./canvas/cropViewport";
|
||
import { beginFrameDrawing, FrameDraftOverlay, type FrameDraft, type FrameWorldRect } from "./canvas/FrameToolOverlay";
|
||
import { FrameStyleToolbar } from "./canvas/FrameStyleToolbar";
|
||
import { defaultFrameStrokeColor, defaultFrameStrokeStyle, defaultFrameStrokeWidth, normalizeHexColor } from "./canvas/frameStyle";
|
||
import { generatorTaskPollIntervalMs } from "@/ui/pages/CanvasWorkspace/canvas/generatorTaskPolling";
|
||
import { useCanvasDocument } from "./canvas/hooks/useCanvasDocument";
|
||
import { useCanvasPersistence } from "./canvas/hooks/useCanvasPersistence";
|
||
import { useCanvasSelection } from "./canvas/hooks/useCanvasSelection";
|
||
import { useGenerationStream, type GenerationChannel } from "./canvas/hooks/useGenerationStream";
|
||
import { imageGeneratorPlaceholderSize, imageGeneratorTargetSizeFromNode, imageGeneratorTargetSizeFromSettings, serializeImageGeneratorTargetSize, type ImageGeneratorTargetSize } from "./canvas/imageGeneratorSizing";
|
||
import { canvasNodeToReferenceImage, isAssetReferencedByCanvas, isCanvasNodeReferenceImage } from "./canvas/nodeReferenceImages";
|
||
import { canvasNodeSvg, exportCanvasNode, exportImageNode, exportTextNode, imageNodeRasterBlob, svgToRasterBlob } from "./canvas/nodeExport";
|
||
import { beginPenStroke, createPenStrokeNode, PenStrokeDraftOverlay, PenToolControls, type PenDraft, type PenSettings } from "@/ui/pages/CanvasWorkspace/canvas/PenTool";
|
||
import { PenStrokeToolbar, updatePenStrokeNode, type PenStrokePatch } from "./canvas/PenStrokeToolbar";
|
||
import { ShapeNodePreview } from "@/ui/pages/CanvasWorkspace/canvas/ShapeNodePreview";
|
||
import { ShapeStyleToolbar } from "@/ui/pages/CanvasWorkspace/canvas/ShapeStyleToolbar";
|
||
import { beginShapeDrawing, ShapeDraftOverlay, type ShapeDraft } from "@/ui/pages/CanvasWorkspace/canvas/ShapeToolOverlay";
|
||
import {
|
||
createBooleanShapeGroupNode,
|
||
createShapeNode,
|
||
isBooleanCapableShapeNode,
|
||
isBooleanShapeGroupNode,
|
||
isBooleanShapeOperandNode,
|
||
isLineShapeNode,
|
||
isShapeNode,
|
||
patchShapeStyleNode,
|
||
resizeLineShapeEndpoint,
|
||
shapeTitle,
|
||
updateBooleanShapeOperation,
|
||
type BooleanShapeOperation,
|
||
type ShapeEndpointKey,
|
||
type ShapeKind,
|
||
type ShapeStylePatch,
|
||
type ShapeWorldDraft
|
||
} from "@/ui/pages/CanvasWorkspace/canvas/shapeNode";
|
||
import { defaultTextVisibleBox, defaultTextVisibleFontSize } from "./canvas/textNodeDefaults";
|
||
import { WorkspaceToolbar } from "./canvas/WorkspaceToolbar";
|
||
import type { CanvasTool, FrameStylePatch, NodeScreenBounds } from "./canvas/types";
|
||
import { revokeReferenceImage, revokeReferenceImages, type AnnotationPreviewRequest, type CanvasAnnotation, type PromptComposerHandle, type UploadedReferenceImage } from "@/ui/components/PromptComposer";
|
||
|
||
const minCanvasZoom = 0.1;
|
||
const maxCanvasZoom = 2.8;
|
||
const defaultCanvasBackgroundColor = "#F7F7F4";
|
||
const canvasBackgroundSwatches = ["#F5F5F5", "#000000", "#FFFFFF", "#00F016", "#A855F7", "#E9D5FF"];
|
||
const canvasSidePanelWidth = 268;
|
||
const newAgentConversationId = "__new_agent_conversation__";
|
||
const prepareModelAgentContents = createModelAgentContentPreparer({ uploadImage: designGateway.uploadImage });
|
||
const canvasWheelGuardSelector = [
|
||
".ui-popover-content",
|
||
".ui-dropdown-menu-content",
|
||
".ui-context-menu-content",
|
||
".canvas-layer-panel",
|
||
".canvas-generated-files-panel",
|
||
".image-action-panel",
|
||
".image-node-toolbar",
|
||
".vector-result-toolbar",
|
||
".text-style-toolbar",
|
||
".workspace-toolbar",
|
||
".canvas-statusbar",
|
||
".canvas-minimap",
|
||
".account-management-backdrop",
|
||
".account-management-dialog"
|
||
].join(",");
|
||
|
||
function revokePreparedUploadUrls(item: { sourcePreviewUrl: string; renderPreviewUrl?: string }) {
|
||
URL.revokeObjectURL(item.sourcePreviewUrl);
|
||
if (item.renderPreviewUrl) URL.revokeObjectURL(item.renderPreviewUrl);
|
||
}
|
||
|
||
function renderAlignmentGuidesImperatively(layer: HTMLElement | null, guides: AlignmentGuide[]) {
|
||
if (!layer) return;
|
||
if (guides.length === 0) {
|
||
layer.replaceChildren();
|
||
return;
|
||
}
|
||
while (layer.childElementCount < guides.length) layer.append(document.createElement("span"));
|
||
Array.from(layer.children).forEach((child, index) => {
|
||
const element = child as HTMLElement;
|
||
const guide = guides[index];
|
||
if (!guide) {
|
||
element.style.display = "none";
|
||
return;
|
||
}
|
||
element.className = `canvas-alignment-guide ${guide.orientation}`;
|
||
element.style.display = "block";
|
||
element.style.left = `${guide.left}px`;
|
||
element.style.top = `${guide.top}px`;
|
||
element.style.width = guide.orientation === "horizontal" ? `${guide.length}px` : "";
|
||
element.style.height = guide.orientation === "vertical" ? `${guide.length}px` : "";
|
||
});
|
||
}
|
||
|
||
type LocalizedText = Record<Locale, string>;
|
||
type HomeImageModel = {
|
||
id: string;
|
||
label: string;
|
||
description: string;
|
||
duration: string;
|
||
credits: number;
|
||
};
|
||
|
||
type ImageActionMode = "quick-edit" | "visual-annotation" | "eraser" | "edit-text" | "multi-angle" | "move-object" | "mockup" | "adjust" | "expand" | "crop" | "flip-rotate" | null;
|
||
const defaultCropSelection: NormalizedRect = { x: 0, y: 0, width: 1, height: 1 };
|
||
const imageModelBlueprints = [
|
||
{
|
||
id: "gpt-image-2-auto",
|
||
label: { "zh-CN": "GPT Image 2", "en-US": "GPT Image 2" },
|
||
description: {
|
||
"zh-CN": "根据提示词自动调整质量,成本可能变化。",
|
||
"en-US": "Automatically adjusts quality per prompt. Costs may vary."
|
||
},
|
||
duration: "30s",
|
||
credits: 15
|
||
},
|
||
{
|
||
id: "gpt-image-2-low",
|
||
label: { "zh-CN": "GPT Image 2 Low", "en-US": "GPT Image 2 Low" },
|
||
description: {
|
||
"zh-CN": "速度快、成本低,适合多数探索任务。",
|
||
"en-US": "Fast, affordable, great for most tasks."
|
||
},
|
||
duration: "30s",
|
||
credits: 8
|
||
},
|
||
{
|
||
id: "gpt-image-2-medium",
|
||
label: { "zh-CN": "GPT Image 2 Medium", "en-US": "GPT Image 2 Medium" },
|
||
description: {
|
||
"zh-CN": "更多细节,中等成本,适合精修视觉。",
|
||
"en-US": "More detail, moderate cost."
|
||
},
|
||
duration: "30s",
|
||
credits: 12
|
||
},
|
||
{
|
||
id: "nano-banana-pro",
|
||
label: { "zh-CN": "Nano Banana Pro", "en-US": "Nano Banana Pro" },
|
||
description: {
|
||
"zh-CN": "适合快速生成商品图、品牌视觉和社媒图。",
|
||
"en-US": "Good for fast product visuals, brand images, and social posts."
|
||
},
|
||
duration: "20s",
|
||
credits: 20
|
||
},
|
||
{
|
||
id: "nano-banana-2",
|
||
label: { "zh-CN": "Nano Banana 2", "en-US": "Nano Banana 2" },
|
||
description: {
|
||
"zh-CN": "平衡速度和质量,适合日常创意探索。",
|
||
"en-US": "Balanced speed and quality for everyday exploration."
|
||
},
|
||
duration: "20s",
|
||
credits: 14
|
||
},
|
||
{
|
||
id: "nano-banana-2-lite",
|
||
label: { "zh-CN": "Nano Banana 2 Lite", "en-US": "Nano Banana 2 Lite" },
|
||
description: {
|
||
"zh-CN": "轻量低成本,适合草稿与快速迭代。",
|
||
"en-US": "Lightweight and lower cost for drafts and quick iteration."
|
||
},
|
||
duration: "15s",
|
||
credits: 8
|
||
},
|
||
{
|
||
id: "gpt-image-1.5",
|
||
label: { "zh-CN": "GPT Image 1.5", "en-US": "GPT Image 1.5" },
|
||
description: {
|
||
"zh-CN": "经典图片模型,适合成本敏感的基础生图。",
|
||
"en-US": "Classic image model for cost-sensitive generation."
|
||
},
|
||
duration: "30s",
|
||
credits: 10
|
||
},
|
||
{
|
||
id: "luma-uni-1",
|
||
label: { "zh-CN": "Luma Uni-1", "en-US": "Luma Uni-1" },
|
||
description: {
|
||
"zh-CN": "适合风格化视觉与创意氛围图。",
|
||
"en-US": "Good for stylized creative visuals."
|
||
},
|
||
duration: "25s",
|
||
credits: 18
|
||
},
|
||
{
|
||
id: "luma-uni-1-max",
|
||
label: { "zh-CN": "Luma Uni-1 Max", "en-US": "Luma Uni-1 Max" },
|
||
description: {
|
||
"zh-CN": "高质量版本,适合关键视觉和精修输出。",
|
||
"en-US": "Higher quality for key visuals and polished output."
|
||
},
|
||
duration: "35s",
|
||
credits: 30
|
||
},
|
||
{
|
||
id: "flux-2-pro",
|
||
label: { "zh-CN": "Flux 2 Pro", "en-US": "Flux 2 Pro" },
|
||
description: {
|
||
"zh-CN": "适合产品、字体感和商业海报探索。",
|
||
"en-US": "Good for products, typography-heavy visuals, and commercial posters."
|
||
},
|
||
duration: "30s",
|
||
credits: 16
|
||
}
|
||
] satisfies Array<{
|
||
id: string;
|
||
label: LocalizedText;
|
||
description: LocalizedText;
|
||
duration: string;
|
||
credits: number;
|
||
}>;
|
||
|
||
function getImageModels(locale: Locale): HomeImageModel[] {
|
||
return imageModelBlueprints.map((model) => ({
|
||
id: model.id,
|
||
label: model.label[locale],
|
||
description: model.description[locale],
|
||
duration: model.duration,
|
||
credits: model.credits
|
||
}));
|
||
}
|
||
|
||
function toImageApiModel(modelId: string) {
|
||
return modelId.startsWith("gpt-image-2") ? "gpt-image-2" : modelId;
|
||
}
|
||
|
||
function defaultImageSize() {
|
||
return "1024x1024";
|
||
}
|
||
|
||
function backgroundRemovalOptions(node: CanvasNode): Record<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 isProjectAccessDenied(err: unknown) {
|
||
const status = httpStatusOf(err);
|
||
return status === 403 || status === 404;
|
||
}
|
||
|
||
function expandTargetGeometryForNode(node: CanvasNode, options: ExpandOptions) {
|
||
const ratio = expandRatioValue(options.ratio, node.width / Math.max(node.height, 1));
|
||
const sourceRatio = node.width / Math.max(node.height, 1);
|
||
const scale = normalizeExpandScale(options.scale);
|
||
let width = node.width;
|
||
let height = node.height;
|
||
if (ratio > sourceRatio) {
|
||
width = node.height * ratio;
|
||
} else {
|
||
height = node.width / Math.max(ratio, 0.01);
|
||
}
|
||
width *= scale;
|
||
height *= scale;
|
||
const imageRect = normalizeExpandSourceRect(options.imageRect, node, width, height);
|
||
return {
|
||
x: node.x - imageRect.x * width,
|
||
y: node.y - imageRect.y * height,
|
||
width,
|
||
height,
|
||
imageRect
|
||
};
|
||
}
|
||
|
||
function normalizeExpandSourceRect(rect: NormalizedRect | undefined, node: CanvasNode, targetWidth: number, targetHeight: number): NormalizedRect {
|
||
const defaultRect = {
|
||
x: (1 - node.width / Math.max(targetWidth, 1)) / 2,
|
||
y: (1 - node.height / Math.max(targetHeight, 1)) / 2,
|
||
width: node.width / Math.max(targetWidth, 1),
|
||
height: node.height / Math.max(targetHeight, 1)
|
||
};
|
||
if (!rect) return clampExpandRect(defaultRect);
|
||
return clampExpandRect({ ...rect, width: defaultRect.width, height: defaultRect.height });
|
||
}
|
||
|
||
function clampExpandRect(rect: NormalizedRect): NormalizedRect {
|
||
const width = clamp(rect.width, 0.02, 1);
|
||
const height = clamp(rect.height, 0.02, 1);
|
||
return {
|
||
x: clamp(rect.x, 0, Math.max(0, 1 - width)),
|
||
y: clamp(rect.y, 0, Math.max(0, 1 - height)),
|
||
width,
|
||
height
|
||
};
|
||
}
|
||
|
||
function expandRatioValue(ratio: ExpandOptions["ratio"], fallbackRatio: number) {
|
||
switch (ratio) {
|
||
case "1:1":
|
||
return 1;
|
||
case "3:4":
|
||
return 3 / 4;
|
||
case "2:3":
|
||
return 2 / 3;
|
||
case "9:16":
|
||
return 9 / 16;
|
||
case "4:3":
|
||
return 4 / 3;
|
||
case "3:2":
|
||
return 3 / 2;
|
||
case "16:9":
|
||
return 16 / 9;
|
||
case "4:5":
|
||
return 4 / 5;
|
||
case "5:4":
|
||
return 5 / 4;
|
||
default:
|
||
return Math.max(0.01, fallbackRatio);
|
||
}
|
||
}
|
||
|
||
function normalizeExpandScale(scale: number) {
|
||
if (!Number.isFinite(scale)) return 1;
|
||
return clamp(scale, 1, 2);
|
||
}
|
||
|
||
function expandImageSize(ratio: ExpandOptions["ratio"], fallbackRatio: number) {
|
||
const fixedSizes: Partial<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 withImageGeneratorTarget(contents: AgentContent[], targetNodeId?: string) {
|
||
return [
|
||
...contents,
|
||
...(targetNodeId
|
||
? [
|
||
{
|
||
type: "text" as const,
|
||
text: `Image generator target node: ${targetNodeId}`,
|
||
textSource: "settings"
|
||
}
|
||
]
|
||
: [])
|
||
];
|
||
}
|
||
|
||
function CanvasAccountControls({ credits, collapsed = false, onOpenConversation }: { credits: number; collapsed?: boolean; onOpenConversation?: () => void }) {
|
||
const { t, locale, toggleLocale } = useI18n();
|
||
const { isAuthenticated, user, openLogin, logout, updateUser } = useAuth();
|
||
const accountRef = useRef<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;
|
||
}
|
||
|
||
const ownerWorkspaceAccess: ShareAccess = {
|
||
permission: "owner",
|
||
source: "owner",
|
||
authenticated: true,
|
||
isOwner: true,
|
||
capabilities: {
|
||
canViewCanvas: true,
|
||
canViewChat: true,
|
||
canEdit: true,
|
||
canManage: true
|
||
}
|
||
};
|
||
|
||
export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess }: { projectId: string; shareAccess?: ShareAccess }) {
|
||
const { t, locale } = useI18n();
|
||
const { isAuthenticated, user } = useAuth();
|
||
const canEdit = shareAccess.capabilities.canEdit;
|
||
const canViewChat = shareAccess.capabilities.canViewChat;
|
||
const canManageSharing = shareAccess.capabilities.canManage;
|
||
const canManageBrandKit = shareAccess.isOwner;
|
||
const toUserMessageText = useUserMessage();
|
||
const { showToast } = useGlobalToast();
|
||
const showGenerationError = useCallback((message: string) => showToast(message, "error"), [showToast]);
|
||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||
const canvasWorldRef = useRef<HTMLDivElement | null>(null);
|
||
const leaferMountRef = useRef<HTMLDivElement | null>(null);
|
||
const leaferRendererRef = useRef<Awaited<ReturnType<typeof createLeaferCanvasRenderer>> | null>(null);
|
||
const imperativeAlignmentGuideRef = useRef<HTMLDivElement | null>(null);
|
||
const viewportCommitTimerRef = useRef<number | null>(null);
|
||
const canvasAssetInputRef = useRef<HTMLInputElement | null>(null);
|
||
const agentComposerRef = useRef<PromptComposerHandle | null>(null);
|
||
const assistantFeedRef = useRef<HTMLDivElement | null>(null);
|
||
const assistantFooterRef = useRef<HTMLDivElement | null>(null);
|
||
const autoScrollAgentFeedRef = useRef(false);
|
||
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 pendingWorkspaceCropRef = useRef<{ nodeId: string; selection: NormalizedRect } | null>(null);
|
||
const workspaceCropDetectionRequestRef = useRef(0);
|
||
const { project, setProject, projectRef, viewport, setViewport, viewportRef, nodes, setNodes, nodesRef, applyProjectDocument } = useCanvasDocument();
|
||
const { selectedId, selectedIds, setSelectedIds, selectedNodes, selectedNode, selectOnlyNode, selectNodes, toggleNodeSelection } = useCanvasSelection(nodes);
|
||
const [messages, setMessages] = useState<AgentMessage[]>([]);
|
||
const [agentHistoryLoading, setAgentHistoryLoading] = useState(canViewChat);
|
||
const [prompt, setPrompt] = useState("");
|
||
const [agentStarterConfig, setAgentStarterConfig] = useState<AgentStarterConfig>(() => defaultAgentStarterConfig(t));
|
||
const [agentThreads, setAgentThreads] = useState<AgentThreadSummary[]>([]);
|
||
const [credits, setCredits] = useState(30);
|
||
const [assistantPanelOpen, setAssistantPanelOpen] = useState(canViewChat);
|
||
const [agentHistoryOpen, setAgentHistoryOpen] = useState(false);
|
||
const [agentHistorySearch, setAgentHistorySearch] = useState("");
|
||
const [selectedAgentThreadId, setSelectedAgentThreadId] = useState<string | null>(null);
|
||
const [assistantFooterHeight, setAssistantFooterHeight] = useState(0);
|
||
const [showAssistantScrollButton, setShowAssistantScrollButton] = useState(false);
|
||
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 [brandKits, setBrandKits] = useState<BrandKit[]>([]);
|
||
const [brandKitsLoading, setBrandKitsLoading] = useState(false);
|
||
const [brandKitUpdating, setBrandKitUpdating] = useState(false);
|
||
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 [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 [visualAnnotationMarks, setVisualAnnotationMarks] = useState<VisualAnnotationMark[]>([]);
|
||
const [visualAnnotationSubmitting, setVisualAnnotationSubmitting] = useState(false);
|
||
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 [workspaceCropTargetId, setWorkspaceCropTargetId] = useState<string | null>(null);
|
||
const [workspaceCropDraftBounds, setWorkspaceCropDraftBounds] = useState<NodeScreenBounds | null>(null);
|
||
const [workspaceCropDetecting, setWorkspaceCropDetecting] = useState(false);
|
||
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 || visualAnnotationSubmitting || nodes.some(isCanvasActionWorkingNode);
|
||
const agentTimelineActive = agentGenerating || Boolean(project && !projectHasCanvasActionGeneration(project) && nodes.some(isAgentVisibleGeneratingNode));
|
||
const mockupPreparing = Boolean(mockupPendingTargetId);
|
||
const anyGenerating = agentGenerating || imageGeneratorGenerating || canvasActionWorkActive || mockupPreparing;
|
||
const updateAssistantScrollButton = useCallback(() => {
|
||
const feed = assistantFeedRef.current;
|
||
if (!feed || feed.scrollHeight <= feed.clientHeight) {
|
||
setShowAssistantScrollButton(false);
|
||
return;
|
||
}
|
||
const distanceToEnd = feed.scrollHeight - feed.scrollTop - feed.clientHeight;
|
||
const shouldShow = distanceToEnd > 24;
|
||
setShowAssistantScrollButton((current) => (current === shouldShow ? current : shouldShow));
|
||
}, []);
|
||
const scrollAssistantFeedToEnd = useCallback((behavior: ScrollBehavior = "auto") => {
|
||
window.requestAnimationFrame(() => {
|
||
const feed = assistantFeedRef.current;
|
||
if (!feed) return;
|
||
if (behavior === "auto") {
|
||
feed.scrollTop = feed.scrollHeight;
|
||
updateAssistantScrollButton();
|
||
return;
|
||
}
|
||
feed.scrollTo({ top: feed.scrollHeight, behavior });
|
||
window.setTimeout(updateAssistantScrollButton, 220);
|
||
});
|
||
}, [updateAssistantScrollButton]);
|
||
|
||
useEffect(() => {
|
||
window.localStorage.setItem("fluxo:show-minimap", showMinimap ? "true" : "false");
|
||
}, [showMinimap]);
|
||
|
||
useEffect(() => {
|
||
if (!isAuthenticated) {
|
||
setCredits(30);
|
||
return;
|
||
}
|
||
designGateway
|
||
.dashboard()
|
||
.then((dashboard) => setCredits(dashboard.credits))
|
||
.catch(() => undefined);
|
||
}, [isAuthenticated]);
|
||
|
||
useEffect(() => {
|
||
let active = true;
|
||
const fallback = defaultAgentStarterConfig(t);
|
||
setAgentStarterConfig(fallback);
|
||
designGateway
|
||
.getAgentStarterConfig(locale)
|
||
.then((config) => {
|
||
if (active) setAgentStarterConfig(normalizeAgentStarterConfig(config, fallback));
|
||
})
|
||
.catch(() => undefined);
|
||
return () => {
|
||
active = false;
|
||
};
|
||
}, [locale, t]);
|
||
|
||
useEffect(() => {
|
||
let active = true;
|
||
if (!isAuthenticated || !canManageBrandKit) {
|
||
setBrandKits([]);
|
||
setBrandKitsLoading(false);
|
||
return;
|
||
}
|
||
setBrandKitsLoading(true);
|
||
brandKitRepository
|
||
.list(brandKitStorageScope(user?.id))
|
||
.then((kits) => {
|
||
if (active) setBrandKits(kits);
|
||
})
|
||
.catch(() => {
|
||
if (!active) return;
|
||
setBrandKits([]);
|
||
showToast(t("brandKitLoadError"), "error");
|
||
})
|
||
.finally(() => {
|
||
if (active) setBrandKitsLoading(false);
|
||
});
|
||
return () => {
|
||
active = false;
|
||
};
|
||
}, [canManageBrandKit, isAuthenticated, showToast, t, user?.id]);
|
||
|
||
useEffect(() => {
|
||
if (!canViewChat) {
|
||
setAssistantPanelOpen(false);
|
||
setAgentHistoryOpen(false);
|
||
}
|
||
}, [canViewChat]);
|
||
|
||
useEffect(() => {
|
||
webSearchHydratedRef.current = false;
|
||
const stored = window.localStorage.getItem(canvasBackgroundStorageKey(projectId));
|
||
setCanvasBackgroundColor(normalizeHexColor(stored ?? "", defaultCanvasBackgroundColor));
|
||
}, [projectId]);
|
||
|
||
useEffect(() => {
|
||
if (editingTextNodeId && !nodes.some((node) => node.id === editingTextNodeId)) {
|
||
setEditingTextNodeId(null);
|
||
}
|
||
}, [editingTextNodeId, nodes]);
|
||
|
||
const applyProject = useCallback((nextProject: Project, options: { preserveMessages?: boolean } = {}) => {
|
||
const mergedProject = preserveLocalDraftImageGenerators(nextProject, nodesRef.current);
|
||
applyProjectDocument(mergedProject);
|
||
if (!options.preserveMessages) {
|
||
setMessages(visibleAgentMessagesForThread(mergedProject, imageGeneratorThreadIdsRef.current, selectedAgentThreadIdRef.current));
|
||
}
|
||
setEditingTextNodeId(null);
|
||
}, [applyProjectDocument, nodesRef]);
|
||
|
||
const selectProjectBrandKit = useCallback(
|
||
async (brandKitId: string | null) => {
|
||
const currentProject = projectRef.current;
|
||
if (!currentProject || !canManageBrandKit) return;
|
||
setBrandKitUpdating(true);
|
||
try {
|
||
const { project: updatedProject } = await designGateway.updateProjectBrandKit(currentProject.id, brandKitId ?? "");
|
||
projectRef.current = updatedProject;
|
||
setProject(updatedProject);
|
||
showToast(t(brandKitId ? "brandKitApplied" : "brandKitCleared"), "success");
|
||
} catch {
|
||
showToast(t("brandKitBindingError"), "error");
|
||
} finally {
|
||
setBrandKitUpdating(false);
|
||
}
|
||
},
|
||
[canManageBrandKit, projectRef, setProject, showToast, t]
|
||
);
|
||
|
||
const appendThinkingMessage = useCallback((message: AgentMessage) => {
|
||
if (isImageGeneratorThreadMessage(message, imageGeneratorThreadIdsRef.current)) return;
|
||
if (message.type === "text_extraction_result" || message.name === "text_extraction") return;
|
||
const selectedThreadId = selectedAgentThreadIdRef.current;
|
||
if (selectedThreadId === newAgentConversationId) return;
|
||
if (selectedThreadId) {
|
||
const messageThreadId = message.threadId || projectRef.current?.lastThreadId || "";
|
||
if (messageThreadId !== selectedThreadId) return;
|
||
}
|
||
setMessages((current) => {
|
||
const existingIndex = current.findIndex((item) => item.id === message.id);
|
||
if (existingIndex < 0) return [...current, message];
|
||
const next = [...current];
|
||
next[existingIndex] = message;
|
||
return next;
|
||
});
|
||
}, []);
|
||
|
||
const { startProjectEvents, startAgentSocket, stopGenerationStream, agentStreamingProjectIdRef } = useGenerationStream({
|
||
applyProject,
|
||
appendThinkingMessage,
|
||
imageGeneratorThreadIdsRef,
|
||
setAgentGenerating,
|
||
setImageGeneratorGenerating,
|
||
setCanvasActionGenerating,
|
||
setError,
|
||
showGenerationError,
|
||
messages: {
|
||
generateError: t("generateError"),
|
||
generateTimeout: t("generateTimeout")
|
||
}
|
||
});
|
||
|
||
const loadAgentThreads = useCallback(async () => {
|
||
if (!canViewChat) return;
|
||
const currentProject = projectRef.current;
|
||
if (!currentProject) return;
|
||
try {
|
||
const response = await designGateway.listAgentThreads(currentProject.id);
|
||
setAgentThreads(response.threads);
|
||
} catch (err) {
|
||
console.warn("Failed to load agent thread history", err);
|
||
}
|
||
}, [canViewChat, projectRef]);
|
||
|
||
const { saveCanvasSnapshot, scheduleCanvasSave, updateProjectTitle } = useCanvasPersistence({
|
||
project,
|
||
projectRef,
|
||
viewport,
|
||
viewportRef,
|
||
nodes,
|
||
nodesRef,
|
||
dirty,
|
||
anyGenerating,
|
||
disabled: !canEdit,
|
||
setProject,
|
||
setDirty,
|
||
setSaving,
|
||
setError,
|
||
messages: {
|
||
saveError: t("saveError"),
|
||
renameProjectError: t("renameProjectError")
|
||
}
|
||
});
|
||
|
||
useEffect(() => {
|
||
if (!agentGenerating) {
|
||
activeAgentThreadIdRef.current = null;
|
||
}
|
||
}, [agentGenerating]);
|
||
|
||
useEffect(() => {
|
||
if (!assistantPanelOpen || !autoScrollAgentFeedRef.current) return;
|
||
scrollAssistantFeedToEnd("auto");
|
||
if (agentTimelineActive) return;
|
||
const timer = window.setTimeout(() => {
|
||
autoScrollAgentFeedRef.current = false;
|
||
}, 250);
|
||
return () => window.clearTimeout(timer);
|
||
}, [agentTimelineActive, assistantPanelOpen, messages, scrollAssistantFeedToEnd]);
|
||
|
||
useEffect(() => {
|
||
if (!assistantPanelOpen) {
|
||
setShowAssistantScrollButton(false);
|
||
return;
|
||
}
|
||
const frame = window.requestAnimationFrame(updateAssistantScrollButton);
|
||
return () => window.cancelAnimationFrame(frame);
|
||
}, [agentTimelineActive, assistantPanelOpen, messages, updateAssistantScrollButton]);
|
||
|
||
useEffect(() => {
|
||
if (!assistantPanelOpen) return;
|
||
const footer = assistantFooterRef.current;
|
||
if (!footer) return;
|
||
const updateFooterHeight = () => {
|
||
setAssistantFooterHeight(Math.ceil(footer.getBoundingClientRect().height));
|
||
updateAssistantScrollButton();
|
||
};
|
||
updateFooterHeight();
|
||
if (typeof ResizeObserver === "undefined") return;
|
||
const observer = new ResizeObserver(updateFooterHeight);
|
||
observer.observe(footer);
|
||
return () => observer.disconnect();
|
||
}, [assistantPanelOpen, error, updateAssistantScrollButton]);
|
||
|
||
useEffect(() => {
|
||
selectedAgentThreadIdRef.current = null;
|
||
setSelectedAgentThreadId(null);
|
||
setAgentHistoryLoading(canViewChat);
|
||
setAgentThreads([]);
|
||
setAgentHistorySearch("");
|
||
setAgentHistoryOpen(false);
|
||
autoScrollAgentFeedRef.current = false;
|
||
}, [canViewChat, projectId]);
|
||
|
||
useEffect(() => {
|
||
if (agentHistoryOpen) void loadAgentThreads();
|
||
}, [agentHistoryOpen, loadAgentThreads]);
|
||
|
||
useEffect(() => {
|
||
let active = true;
|
||
setAgentHistoryLoading(canViewChat);
|
||
designGateway
|
||
.getProject(projectId)
|
||
.then(({ project: nextProject }) => {
|
||
applyProject(nextProject);
|
||
if (!webSearchHydratedRef.current) {
|
||
setUseAgentWebSearch(projectHasWebSearchEnabled(nextProject));
|
||
webSearchHydratedRef.current = true;
|
||
}
|
||
if (isProjectGenerating(nextProject) && canViewChat) {
|
||
const channel: GenerationChannel = projectHasImageGeneratorGeneration(nextProject) ? "image-generator" : projectHasCanvasActionGeneration(nextProject) ? "canvas-action" : "agent";
|
||
if (channel === "image-generator") {
|
||
setImageGeneratorGenerating(true);
|
||
} else if (channel === "canvas-action") {
|
||
setCanvasActionGenerating(true);
|
||
runningCanvasActionThreadIds(nextProject).forEach((threadId) => startProjectEvents(nextProject.id, threadId, "canvas-action"));
|
||
} else {
|
||
setAgentGenerating(true);
|
||
activeAgentThreadIdRef.current = nextProject.lastThreadId || null;
|
||
}
|
||
if (channel !== "canvas-action") {
|
||
startProjectEvents(nextProject.id, nextProject.lastThreadId, channel);
|
||
}
|
||
}
|
||
return canViewChat ? designGateway.getProjectHistory(nextProject.id) : null;
|
||
})
|
||
.then((history) => {
|
||
if (history && !agentStreamingProjectIdRef.current) {
|
||
const currentProject = projectRef.current;
|
||
if (currentProject) {
|
||
const projectWithHistory = {
|
||
...currentProject,
|
||
lastThreadId: history.threadId || currentProject.lastThreadId,
|
||
messages: history.messages
|
||
};
|
||
projectRef.current = projectWithHistory;
|
||
setProject(projectWithHistory);
|
||
setMessages(visibleAgentMessagesForThread(projectWithHistory, imageGeneratorThreadIdsRef.current, selectedAgentThreadIdRef.current));
|
||
} else {
|
||
setMessages(visibleAgentMessages(history.messages, imageGeneratorThreadIdsRef.current));
|
||
}
|
||
}
|
||
})
|
||
.catch((err: unknown) => {
|
||
if (isProjectAccessDenied(err)) {
|
||
showToast("你没有访问该项目权限", "error");
|
||
openHome();
|
||
return;
|
||
}
|
||
setError(toUserMessageText(err, "generateError"));
|
||
})
|
||
.finally(() => {
|
||
if (active) setAgentHistoryLoading(false);
|
||
});
|
||
return () => {
|
||
active = false;
|
||
};
|
||
}, [applyProject, canViewChat, projectId, startProjectEvents]);
|
||
|
||
useEffect(() => {
|
||
referenceImagesRef.current = referenceImages;
|
||
}, [referenceImages]);
|
||
|
||
useEffect(() => {
|
||
annotationsRef.current = annotations;
|
||
}, [annotations]);
|
||
|
||
useEffect(() => () => revokeReferenceImages(referenceImagesRef.current), []);
|
||
|
||
useEffect(() => {
|
||
void loadMotevaFontCatalog();
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
return () => {
|
||
textExtractionEventsCloseRef.current?.();
|
||
mockupPrepareRequestRef.current += 1;
|
||
if (mockupPrepareTimerRef.current) clearTimeout(mockupPrepareTimerRef.current);
|
||
};
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
const stage = containerRef.current;
|
||
if (!stage) return;
|
||
const updateSize = () => {
|
||
const nextSize = { width: stage.clientWidth, height: stage.clientHeight };
|
||
setCanvasStageSize((current) => (current.width === nextSize.width && current.height === nextSize.height ? current : nextSize));
|
||
};
|
||
updateSize();
|
||
const observer = new ResizeObserver(updateSize);
|
||
observer.observe(stage);
|
||
window.addEventListener("resize", updateSize);
|
||
return () => {
|
||
observer.disconnect();
|
||
window.removeEventListener("resize", updateSize);
|
||
};
|
||
}, [project?.id, projectId]);
|
||
|
||
const visibleCanvasNodes = useMemo(() => getVisibleCanvasNodes(nodes, hiddenNodeIds), [hiddenNodeIds, nodes]);
|
||
const renderOrderedCanvasNodes = useMemo(() => orderedCanvasNodesForRender(nodes), [nodes]);
|
||
const renderedCanvasNodes = useMemo(() => renderOrderedCanvasNodes.filter((node) => !isBooleanShapeOperandNode(node, nodes) && (!hiddenNodeIds.has(node.id) || selectedIds.has(node.id))), [hiddenNodeIds, nodes, renderOrderedCanvasNodes, selectedIds]);
|
||
|
||
useEffect(() => {
|
||
const mount = leaferMountRef.current;
|
||
if (!mount || !project?.id) return;
|
||
let disposed = false;
|
||
void createLeaferCanvasRenderer(mount).then((renderer) => {
|
||
if (disposed) {
|
||
renderer.destroy();
|
||
return;
|
||
}
|
||
leaferRendererRef.current = renderer;
|
||
const viewSize = leaferViewSize(mount, canvasStageSize);
|
||
renderer.resize(viewSize.width, viewSize.height);
|
||
renderer.setViewport(viewportRef.current);
|
||
renderer.sync(renderedCanvasNodes, hiddenNodeIds, canvasWorldRef.current);
|
||
});
|
||
return () => {
|
||
disposed = true;
|
||
leaferRendererRef.current?.destroy();
|
||
leaferRendererRef.current = null;
|
||
};
|
||
}, [project?.id]);
|
||
|
||
useEffect(() => {
|
||
leaferRendererRef.current?.sync(renderedCanvasNodes, hiddenNodeIds, canvasWorldRef.current);
|
||
}, [hiddenNodeIds, renderedCanvasNodes]);
|
||
|
||
useEffect(() => {
|
||
leaferRendererRef.current?.setViewport(viewport);
|
||
}, [viewport]);
|
||
|
||
useEffect(() => {
|
||
leaferRendererRef.current?.resize(canvasStageSize.width, canvasStageSize.height);
|
||
}, [canvasStageSize.height, canvasStageSize.width]);
|
||
const imageModels = useMemo(() => getImageModels(locale), [locale]);
|
||
const agentHistoryThreads = useMemo(() => agentHistoryListItems(agentThreads, project, imageGeneratorThreadIdsRef.current), [agentThreads, project]);
|
||
const filteredAgentHistoryThreads = useMemo(() => {
|
||
const keyword = agentHistorySearch.trim().toLowerCase();
|
||
if (!keyword) return agentHistoryThreads;
|
||
return agentHistoryThreads.filter((thread) => agentThreadTitle(thread).toLowerCase().includes(keyword));
|
||
}, [agentHistorySearch, agentHistoryThreads]);
|
||
const selectedCanvasModel = imageModels.find((model) => model.id === selectedCanvasModelId) ?? imageModels[0];
|
||
const activeHistoryThreadId = selectedAgentThreadId === newAgentConversationId ? newAgentConversationId : selectedAgentThreadId || agentHistoryThreads[0]?.agentThreadId || "";
|
||
const selectedNodeControlsEnabled = canEdit && selectedNodes.length <= 1 && selectedNode ? !lockedNodeIds.has(selectedNode.id) : false;
|
||
const selectedControlNode = selectedNodeControlsEnabled ? selectedNode : null;
|
||
const selectedImageNode = selectedControlNode && isRealCanvasImageNode(selectedControlNode) ? selectedControlNode : null;
|
||
const selectedImageNodeBusy = selectedImageNode ? isImageActionBusy(selectedImageNode) : false;
|
||
const workspaceCropTarget = useMemo(() => {
|
||
if (selectedImageNode) return selectedImageNode;
|
||
const candidates = nodes.filter((node) => {
|
||
if (!isRealCanvasImageNode(node) || hiddenNodeIds.has(node.id) || lockedNodeIds.has(node.id)) return false;
|
||
const bounds = canvasNodeScreenBounds(node, viewport);
|
||
return bounds.left < canvasStageSize.width && bounds.top < canvasStageSize.height && bounds.left + bounds.width > 0 && bounds.top + bounds.height > 0;
|
||
});
|
||
return candidates.length === 1 ? candidates[0] : null;
|
||
}, [canvasStageSize.height, canvasStageSize.width, hiddenNodeIds, lockedNodeIds, nodes, selectedImageNode, viewport]);
|
||
const workspaceCropTargetBusy = workspaceCropTarget ? isImageActionBusy(workspaceCropTarget) : false;
|
||
const selectedImageGeneratorNode = selectedControlNode?.type === "image" && selectedControlNode.layerRole === "image-generator" ? selectedControlNode : null;
|
||
const selectedImageGeneratorBusy = selectedImageGeneratorNode?.status === "generating";
|
||
const selectedPenStrokeNode = selectedControlNode && isCanvasPenStrokeNode(selectedControlNode) ? selectedControlNode : null;
|
||
const selectedFrameNode = selectedControlNode?.type === "frame" && selectedControlNode.layerRole === "manual-frame" ? selectedControlNode : null;
|
||
const selectedShapeNode = selectedControlNode && isShapeNode(selectedControlNode) ? selectedControlNode : null;
|
||
const selectedBooleanShapeGroupNode = selectedControlNode && isBooleanShapeGroupNode(selectedControlNode) ? selectedControlNode : null;
|
||
const selectedTextNode = selectedControlNode?.type === "text" ? selectedControlNode : null;
|
||
const hasCurrentConversation = messages.some((message) => message.content.trim() || message.title.trim());
|
||
const selectedNodeBounds = useMemo<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]);
|
||
|
||
useEffect(() => {
|
||
if (!selectedImageGeneratorNode) return;
|
||
const targetSize = imageGeneratorTargetSizeFromNode(selectedImageGeneratorNode);
|
||
setComposerImageSettings((current) => {
|
||
if (current.width === targetSize.width && current.height === targetSize.height) return current;
|
||
return { ...current, width: targetSize.width, height: targetSize.height, ratio: "auto" };
|
||
});
|
||
}, [selectedImageGeneratorNode?.id]);
|
||
|
||
const selectedFrameBounds = useMemo<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 || layersPanelOpen ? canvasSidePanelWidth : 0;
|
||
const insertCanvasImageReference = useCallback((node: CanvasNode, options: { focus?: boolean; pending?: boolean; replacePending?: boolean } = {}) => {
|
||
const reference = canvasNodeToReferenceImage(node);
|
||
if (!reference) return;
|
||
agentComposerRef.current?.upsertReference(reference, options);
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (!selectedImageNode) return;
|
||
insertCanvasImageReference(selectedImageNode, {
|
||
focus: false,
|
||
pending: true,
|
||
replacePending: true
|
||
});
|
||
}, [insertCanvasImageReference, selectedImageNode?.content, selectedImageNode?.id, selectedImageNode?.semanticContext]);
|
||
|
||
const multiSelectionBounds = useMemo<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("");
|
||
setVisualAnnotationMarks([]);
|
||
setVisualAnnotationSubmitting(false);
|
||
setEraserMarks([]);
|
||
setTextEditItems([]);
|
||
setTextEditLoading(false);
|
||
setTextEditError("");
|
||
setMockupDropActive(false);
|
||
setMockupDragNodeId(null);
|
||
setMockupPendingTargetId(null);
|
||
setMockupReadyTargetId(null);
|
||
setMockupPlacement(null);
|
||
setMockupSurface(null);
|
||
setMockupModel(null);
|
||
mockupPrepareRequestRef.current += 1;
|
||
if (mockupPrepareTimerRef.current) {
|
||
clearTimeout(mockupPrepareTimerRef.current);
|
||
mockupPrepareTimerRef.current = null;
|
||
}
|
||
setMoveSelection(null);
|
||
setMoveRecognitionLabel("");
|
||
setMoveRecognitionLoading(false);
|
||
setCropSelection(null);
|
||
setCropPrompt("");
|
||
}, [selectedId, selectedIds.size]);
|
||
|
||
useEffect(() => {
|
||
const pending = pendingWorkspaceCropRef.current;
|
||
if (!pending || selectedId !== pending.nodeId || selectedIds.size !== 1) return;
|
||
pendingWorkspaceCropRef.current = null;
|
||
setCropSelection(pending.selection);
|
||
setImageActionMode("crop");
|
||
}, [selectedId, selectedIds.size]);
|
||
|
||
useEffect(() => {
|
||
if (imageActionMode !== "move-object" || !selectedImageNode || !moveSelection || moveSelection.srcBox.width < 0.02 || moveSelection.srcBox.height < 0.02) {
|
||
moveRecognitionRequestRef.current += 1;
|
||
setMoveRecognitionLabel("");
|
||
setMoveRecognitionLoading(false);
|
||
return;
|
||
}
|
||
if (moveSelection.dstBox) {
|
||
moveRecognitionRequestRef.current += 1;
|
||
setMoveRecognitionLoading(false);
|
||
return;
|
||
}
|
||
|
||
const requestId = moveRecognitionRequestRef.current + 1;
|
||
moveRecognitionRequestRef.current = requestId;
|
||
setMoveRecognitionLabel("");
|
||
setMoveRecognitionLoading(true);
|
||
const timer = window.setTimeout(() => {
|
||
designGateway
|
||
.recognizeObject(selectedImageNode.content, moveSelection.srcBox, locale === "zh-CN" ? "zh" : "en")
|
||
.then((result) => {
|
||
if (moveRecognitionRequestRef.current !== requestId) return;
|
||
setMoveRecognitionLabel(result.label);
|
||
const bbox = result.bbox;
|
||
if (bbox && bbox.width >= 0.02 && bbox.height >= 0.02) {
|
||
setMoveSelection((current) => {
|
||
if (!current || current.dstBox) return current;
|
||
if (Math.abs(current.srcBox.x - bbox.x) < 0.004 && Math.abs(current.srcBox.y - bbox.y) < 0.004 && Math.abs(current.srcBox.width - bbox.width) < 0.004 && Math.abs(current.srcBox.height - bbox.height) < 0.004) {
|
||
return current;
|
||
}
|
||
return {
|
||
srcBox: {
|
||
x: bbox.x,
|
||
y: bbox.y,
|
||
width: bbox.width,
|
||
height: bbox.height
|
||
}
|
||
};
|
||
});
|
||
}
|
||
})
|
||
.catch(() => {
|
||
if (moveRecognitionRequestRef.current !== requestId) return;
|
||
setMoveRecognitionLabel("");
|
||
})
|
||
.finally(() => {
|
||
if (moveRecognitionRequestRef.current !== requestId) return;
|
||
setMoveRecognitionLoading(false);
|
||
});
|
||
}, 300);
|
||
|
||
return () => window.clearTimeout(timer);
|
||
}, [imageActionMode, locale, moveSelection, selectedImageNode?.content]);
|
||
|
||
const applyViewportImperatively = useCallback(
|
||
(nextViewport: CanvasViewport) => {
|
||
viewportRef.current = nextViewport;
|
||
if (canvasWorldRef.current) {
|
||
canvasWorldRef.current.style.transform = `translate(${nextViewport.x}px, ${nextViewport.y}px) scale(${nextViewport.k})`;
|
||
}
|
||
leaferRendererRef.current?.setViewport(nextViewport);
|
||
const grid = containerRef.current?.querySelector<HTMLElement>(".canvas-grid");
|
||
if (grid) {
|
||
const gridSize = 48 * nextViewport.k;
|
||
grid.style.backgroundSize = `${gridSize}px ${gridSize}px`;
|
||
grid.style.backgroundPosition = `${nextViewport.x % gridSize}px ${nextViewport.y % gridSize}px`;
|
||
}
|
||
},
|
||
[viewportRef]
|
||
);
|
||
|
||
const commitImperativeViewport = useCallback(() => {
|
||
viewportCommitTimerRef.current = null;
|
||
const nextViewport = { ...viewportRef.current };
|
||
containerRef.current?.classList.remove("is-viewport-transforming");
|
||
startTransition(() => setViewport(nextViewport));
|
||
setDirty(true);
|
||
}, [setViewport, viewportRef]);
|
||
|
||
const scheduleViewportCommit = useCallback(() => {
|
||
if (viewportCommitTimerRef.current) window.clearTimeout(viewportCommitTimerRef.current);
|
||
viewportCommitTimerRef.current = window.setTimeout(commitImperativeViewport, 90);
|
||
}, [commitImperativeViewport]);
|
||
|
||
useEffect(
|
||
() => () => {
|
||
if (viewportCommitTimerRef.current) window.clearTimeout(viewportCommitTimerRef.current);
|
||
},
|
||
[]
|
||
);
|
||
|
||
const handleWheel = useCallback((event: WheelEvent) => {
|
||
if (shouldBypassCanvasWheel(event.target)) {
|
||
return;
|
||
}
|
||
event.preventDefault();
|
||
const rect = containerRef.current?.getBoundingClientRect();
|
||
if (!rect) return;
|
||
const currentViewport = viewportRef.current;
|
||
const factor = Math.pow(1.1, -event.deltaY / 120);
|
||
const nextScale = clamp(currentViewport.k * factor, minCanvasZoom, maxCanvasZoom);
|
||
const mouseX = event.clientX - rect.left;
|
||
const mouseY = event.clientY - rect.top;
|
||
const worldX = (mouseX - currentViewport.x) / currentViewport.k;
|
||
const worldY = (mouseY - currentViewport.y) / currentViewport.k;
|
||
const nextViewport = {
|
||
x: mouseX - worldX * nextScale,
|
||
y: mouseY - worldY * nextScale,
|
||
k: nextScale
|
||
};
|
||
containerRef.current?.classList.add("is-viewport-transforming");
|
||
applyViewportImperatively(nextViewport);
|
||
scheduleViewportCommit();
|
||
}, [applyViewportImperatively, scheduleViewportCommit, viewportRef]);
|
||
|
||
useEffect(() => {
|
||
const stage = containerRef.current;
|
||
if (!stage) return;
|
||
return installCanvasWheelListener(stage, handleWheel);
|
||
}, [handleWheel, project?.id]);
|
||
|
||
const zoomCanvasTo = (nextZoom: number) => {
|
||
const current = viewportRef.current;
|
||
const nextScale = clamp(nextZoom, minCanvasZoom, maxCanvasZoom);
|
||
const centerX = canvasStageSize.width / 2;
|
||
const centerY = canvasStageSize.height / 2;
|
||
const worldX = (centerX - current.x) / current.k;
|
||
const worldY = (centerY - current.y) / current.k;
|
||
const nextViewport = {
|
||
x: centerX - worldX * nextScale,
|
||
y: centerY - worldY * nextScale,
|
||
k: nextScale
|
||
};
|
||
if (viewportCommitTimerRef.current) window.clearTimeout(viewportCommitTimerRef.current);
|
||
viewportCommitTimerRef.current = null;
|
||
applyViewportImperatively(nextViewport);
|
||
containerRef.current?.classList.remove("is-viewport-transforming");
|
||
setViewport(nextViewport);
|
||
setDirty(true);
|
||
};
|
||
|
||
const fitCanvasToAllNodes = () => {
|
||
const bounds = nodesRef.current.length > 0 ? nodeBounds(nodesRef.current) : { x: 0, y: 0, width: 1200, height: 800 };
|
||
const padding = Math.max(96, Math.min(Math.max(bounds.width, bounds.height) * 0.08, 240));
|
||
const nextScale = clamp(
|
||
Math.min(canvasStageSize.width / Math.max(bounds.width + padding * 2, 1), canvasStageSize.height / Math.max(bounds.height + padding * 2, 1)),
|
||
minCanvasZoom,
|
||
maxCanvasZoom
|
||
);
|
||
const nextViewport = {
|
||
x: canvasStageSize.width / 2 - (bounds.x + bounds.width / 2) * nextScale,
|
||
y: canvasStageSize.height / 2 - (bounds.y + bounds.height / 2) * nextScale,
|
||
k: nextScale
|
||
};
|
||
if (viewportCommitTimerRef.current) window.clearTimeout(viewportCommitTimerRef.current);
|
||
viewportCommitTimerRef.current = null;
|
||
applyViewportImperatively(nextViewport);
|
||
containerRef.current?.classList.remove("is-viewport-transforming");
|
||
setViewport(nextViewport);
|
||
setDirty(true);
|
||
};
|
||
|
||
const updateCanvasBackgroundColor = (color: string) => {
|
||
const nextColor = normalizeHexColor(color, defaultCanvasBackgroundColor);
|
||
setCanvasBackgroundColor(nextColor);
|
||
window.localStorage.setItem(canvasBackgroundStorageKey(projectId), nextColor);
|
||
};
|
||
|
||
const startCanvasPan = (event: React.PointerEvent<HTMLElement>) => {
|
||
event.preventDefault();
|
||
setCanvasPanning(true);
|
||
const currentViewport = viewportRef.current;
|
||
const start = {
|
||
pointerX: event.clientX,
|
||
pointerY: event.clientY,
|
||
x: currentViewport.x,
|
||
y: currentViewport.y
|
||
};
|
||
|
||
const handleMove = (moveEvent: PointerEvent) => {
|
||
applyViewportImperatively({
|
||
...viewportRef.current,
|
||
x: start.x + moveEvent.clientX - start.pointerX,
|
||
y: start.y + moveEvent.clientY - start.pointerY
|
||
});
|
||
};
|
||
|
||
const handleUp = () => {
|
||
window.removeEventListener("pointermove", handleMove);
|
||
window.removeEventListener("pointerup", handleUp);
|
||
window.removeEventListener("pointercancel", handleUp);
|
||
setCanvasPanning(false);
|
||
startTransition(() => setViewport({ ...viewportRef.current }));
|
||
setDirty(true);
|
||
scheduleCanvasSave();
|
||
};
|
||
|
||
window.addEventListener("pointermove", handleMove);
|
||
window.addEventListener("pointerup", handleUp);
|
||
window.addEventListener("pointercancel", handleUp);
|
||
};
|
||
|
||
const syncAnnotations = (nextAnnotations: CanvasAnnotation[]) => {
|
||
annotationsRef.current = nextAnnotations;
|
||
setAnnotations(nextAnnotations);
|
||
setAnnotationPreview((current) => (current && nextAnnotations.some((annotation) => annotation.id === current.annotation.id) ? current : null));
|
||
};
|
||
|
||
const canvasPointFromClient = (clientX: number, clientY: number) => {
|
||
const rect = containerRef.current?.getBoundingClientRect();
|
||
const currentViewport = viewportRef.current;
|
||
if (!rect) {
|
||
return {
|
||
x: -currentViewport.x / currentViewport.k,
|
||
y: -currentViewport.y / currentViewport.k
|
||
};
|
||
}
|
||
return {
|
||
x: (clientX - rect.left - currentViewport.x) / currentViewport.k,
|
||
y: (clientY - rect.top - currentViewport.y) / currentViewport.k
|
||
};
|
||
};
|
||
|
||
const showCanvasToast = useCallback((message: string, tone: "success" | "error" = "success") => {
|
||
showToast(message, tone);
|
||
}, [showToast]);
|
||
|
||
const attachFrameChildren = (current: CanvasNode[], candidateIds: Set<string>) => attachNodesToContainingFrames(current, candidateIds);
|
||
|
||
const setNodesWithFrameAttachment = (updater: (current: CanvasNode[]) => { nodes: CanvasNode[]; candidateIds: Set<string> }) => {
|
||
setNodes((current) => {
|
||
const result = updater(current);
|
||
const next = attachFrameChildren(result.nodes, result.candidateIds);
|
||
nodesRef.current = next;
|
||
return next;
|
||
});
|
||
};
|
||
|
||
const frameAwareNodeIds = (nodeIds: Iterable<string>) => nodeIdsWithFrameChildren(nodesRef.current, new Set(nodeIds));
|
||
|
||
const findAnnotationTarget = (x: number, y: number, candidate?: CanvasNode) => {
|
||
const visualNodes = orderedCanvasNodesForRender(nodesRef.current).filter((node) => (node.type === "image" || node.type === "frame") && node.status !== "generating");
|
||
if (candidate && visualNodes.some((node) => node.id === candidate.id) && pointInsideNode(x, y, candidate)) return candidate;
|
||
return [...visualNodes].reverse().find((node) => pointInsideNode(x, y, node)) ?? null;
|
||
};
|
||
|
||
const addAnnotationAtClientPoint = (clientX: number, clientY: number, candidate?: CanvasNode) => {
|
||
const point = canvasPointFromClient(clientX, clientY);
|
||
const target = findAnnotationTarget(point.x, point.y, candidate);
|
||
if (!target) return false;
|
||
const nextIndex = annotationsRef.current.reduce((max, annotation) => Math.max(max, annotation.index), 0) + 1;
|
||
const annotation: CanvasAnnotation = {
|
||
id: cryptoFallbackId(),
|
||
index: nextIndex,
|
||
nodeId: target.id,
|
||
x: point.x,
|
||
y: point.y,
|
||
label: t("annotationLabel", { index: nextIndex })
|
||
};
|
||
syncAnnotations([...annotationsRef.current, annotation]);
|
||
agentComposerRef.current?.insertAnnotation(annotation);
|
||
return true;
|
||
};
|
||
|
||
const addFrameFromRect = useCallback(
|
||
(rect: FrameWorldRect) => {
|
||
const node: CanvasNode = {
|
||
id: cryptoFallbackId(),
|
||
type: "frame",
|
||
title: t("newFrameNode"),
|
||
x: rect.x,
|
||
y: rect.y,
|
||
width: rect.width,
|
||
height: rect.height,
|
||
content: "",
|
||
tone: "natural",
|
||
status: "draft",
|
||
layerRole: "manual-frame",
|
||
opacity: 1,
|
||
fillColor: "transparent",
|
||
strokeColor: defaultFrameStrokeColor,
|
||
strokeWidth: defaultFrameStrokeWidth,
|
||
strokeStyle: defaultFrameStrokeStyle
|
||
};
|
||
setNodes((current) => {
|
||
const candidateIds = new Set(current.map((item) => item.id));
|
||
const next = attachFrameChildren([...current, node], candidateIds);
|
||
nodesRef.current = next;
|
||
return next;
|
||
});
|
||
selectOnlyNode(node.id);
|
||
setActiveTool("select");
|
||
setMode("select");
|
||
setDirty(true);
|
||
},
|
||
[selectOnlyNode, t]
|
||
);
|
||
|
||
const addShapeFromDraft = useCallback(
|
||
(draft: ShapeWorldDraft) => {
|
||
const node = createShapeNode({
|
||
id: cryptoFallbackId(),
|
||
title: shapeTitle(selectedShapeKind, t),
|
||
kind: selectedShapeKind,
|
||
draft
|
||
});
|
||
setNodesWithFrameAttachment((current) => ({
|
||
nodes: [...current, node],
|
||
candidateIds: new Set([node.id])
|
||
}));
|
||
selectOnlyNode(node.id);
|
||
setActiveTool("select");
|
||
setMode("select");
|
||
setDirty(true);
|
||
},
|
||
[nodesRef, selectOnlyNode, selectedShapeKind, t]
|
||
);
|
||
|
||
const startPenDrawing = useCallback(
|
||
(event: React.PointerEvent<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"));
|
||
setNodesWithFrameAttachment((current) => ({
|
||
nodes: [...current, node],
|
||
candidateIds: new Set([node.id])
|
||
}));
|
||
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 finishWorkspaceCropPreselection = (targetNode: CanvasNode, selection: NormalizedRect) => {
|
||
workspaceCropDetectionRequestRef.current += 1;
|
||
setWorkspaceCropDetecting(false);
|
||
setWorkspaceCropDraftBounds(null);
|
||
setWorkspaceCropTargetId(null);
|
||
setActiveTool("select");
|
||
setMode("select");
|
||
if (selectedId !== targetNode.id || selectedIds.size !== 1) {
|
||
pendingWorkspaceCropRef.current = { nodeId: targetNode.id, selection };
|
||
selectOnlyNode(targetNode.id);
|
||
return;
|
||
}
|
||
setCropSelection(selection);
|
||
setImageActionMode("crop");
|
||
};
|
||
|
||
const startWorkspaceCropPreselection = (event: React.PointerEvent<HTMLDivElement>, targetNode: CanvasNode) => {
|
||
const stageRect = containerRef.current?.getBoundingClientRect();
|
||
if (!stageRect) return;
|
||
event.preventDefault();
|
||
const bounds = canvasNodeScreenBounds(targetNode, viewportRef.current);
|
||
const visibleSelection = visibleCropSelectionForBounds(bounds, { width: stageRect.width, height: stageRect.height });
|
||
const startClient = { x: event.clientX, y: event.clientY };
|
||
const start = { x: event.clientX - stageRect.left, y: event.clientY - stageRect.top };
|
||
let dragged = false;
|
||
|
||
const selectionAt = (clientX: number, clientY: number) =>
|
||
cropSelectionFromScreenDrag(bounds, visibleSelection, start, {
|
||
x: clientX - stageRect.left,
|
||
y: clientY - stageRect.top
|
||
});
|
||
|
||
const handleMove = (moveEvent: PointerEvent) => {
|
||
const movement = Math.hypot(moveEvent.clientX - startClient.x, moveEvent.clientY - startClient.y);
|
||
if (!dragged && movement < 4) return;
|
||
dragged = true;
|
||
setWorkspaceCropDraftBounds(cropSelectionScreenBounds(bounds, selectionAt(moveEvent.clientX, moveEvent.clientY)));
|
||
};
|
||
|
||
const cleanup = () => {
|
||
window.removeEventListener("pointermove", handleMove);
|
||
window.removeEventListener("pointerup", handleUp);
|
||
window.removeEventListener("pointercancel", handleCancel);
|
||
setWorkspaceCropDraftBounds(null);
|
||
};
|
||
|
||
const handleCancel = () => {
|
||
cleanup();
|
||
};
|
||
|
||
const handleUp = (upEvent: PointerEvent) => {
|
||
cleanup();
|
||
if (dragged) {
|
||
finishWorkspaceCropPreselection(targetNode, clampCropSelection(selectionAt(upEvent.clientX, upEvent.clientY)));
|
||
return;
|
||
}
|
||
|
||
const point = {
|
||
x: clamp((start.x - bounds.left) / Math.max(bounds.width, 1), 0, 1),
|
||
y: clamp((start.y - bounds.top) / Math.max(bounds.height, 1), 0, 1)
|
||
};
|
||
const requestId = workspaceCropDetectionRequestRef.current + 1;
|
||
workspaceCropDetectionRequestRef.current = requestId;
|
||
setWorkspaceCropDetecting(true);
|
||
void detectColorBoundaryCrop(canvasImageUrl(canvasNodeRenderContent(targetNode)), point, visibleSelection)
|
||
.catch(() => visibleSelection)
|
||
.then((selection) => {
|
||
if (workspaceCropDetectionRequestRef.current !== requestId) return;
|
||
finishWorkspaceCropPreselection(targetNode, clampCropSelection(selection));
|
||
});
|
||
};
|
||
|
||
window.addEventListener("pointermove", handleMove);
|
||
window.addEventListener("pointerup", handleUp);
|
||
window.addEventListener("pointercancel", handleCancel);
|
||
};
|
||
|
||
const handleBackgroundPointerDown = (event: React.PointerEvent<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;
|
||
}
|
||
|
||
if (activeTool === "crop") {
|
||
event.preventDefault();
|
||
return;
|
||
}
|
||
|
||
const start = {
|
||
pointerX: event.clientX,
|
||
pointerY: event.clientY,
|
||
stageLeft: containerRef.current?.getBoundingClientRect().left ?? 0,
|
||
stageTop: containerRef.current?.getBoundingClientRect().top ?? 0,
|
||
x: viewportRef.current.x,
|
||
y: viewportRef.current.y
|
||
};
|
||
|
||
if (mode === "select") {
|
||
const additiveIds = event.shiftKey ? selectedNodes.map((node) => node.id) : [];
|
||
let dragged = false;
|
||
const handleMove = (moveEvent: PointerEvent) => {
|
||
const box = normalizeScreenRect(start.pointerX - start.stageLeft, start.pointerY - start.stageTop, moveEvent.clientX - start.stageLeft, moveEvent.clientY - start.stageTop);
|
||
if (box.width < 4 && box.height < 4) return;
|
||
dragged = true;
|
||
setSelectionBox(box);
|
||
const worldRect = screenRectToWorldRect(box, viewportRef.current);
|
||
const hitIds = nodesRef.current.filter((node) => !hiddenNodeIds.has(node.id) && nodeIntersectsRect(node, worldRect)).map((node) => node.id);
|
||
selectNodes([...additiveIds, ...hitIds]);
|
||
};
|
||
|
||
const handleUp = () => {
|
||
window.removeEventListener("pointermove", handleMove);
|
||
window.removeEventListener("pointerup", handleUp);
|
||
setSelectionBox(null);
|
||
if (!dragged) selectOnlyNode(null);
|
||
};
|
||
|
||
window.addEventListener("pointermove", handleMove);
|
||
window.addEventListener("pointerup", handleUp);
|
||
return;
|
||
}
|
||
|
||
selectOnlyNode(null);
|
||
startCanvasPan(event);
|
||
};
|
||
|
||
const startMockupSourceDrag = (event: React.PointerEvent<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: viewportRef.current.k
|
||
};
|
||
let dragged = false;
|
||
|
||
const updateDropState = (clientX: number, clientY: number) => {
|
||
const point = canvasPointFromClient(clientX, clientY);
|
||
setMockupDropActive(pointInsideMockupPlacement(point.x, point.y, targetNode, targetPlacement));
|
||
return point;
|
||
};
|
||
|
||
updateDropState(event.clientX, event.clientY);
|
||
|
||
const handleMove = (moveEvent: PointerEvent) => {
|
||
const movement = Math.hypot(moveEvent.clientX - start.pointerX, moveEvent.clientY - start.pointerY);
|
||
if (movement > 3) dragged = true;
|
||
const dx = (moveEvent.clientX - start.pointerX) / start.scale;
|
||
const dy = (moveEvent.clientY - start.pointerY) / start.scale;
|
||
const nextSourceNode = { ...sourceNode, x: start.x + dx, y: start.y + dy };
|
||
const nextNodes = nodesRef.current.map((node) => (node.id === sourceNode.id ? nextSourceNode : node));
|
||
nodesRef.current = nextNodes;
|
||
setNodes(nextNodes);
|
||
updateDropState(moveEvent.clientX, moveEvent.clientY);
|
||
setDirty(true);
|
||
};
|
||
|
||
const handleUp = (upEvent: PointerEvent) => {
|
||
window.removeEventListener("pointermove", handleMove);
|
||
window.removeEventListener("pointerup", handleUp);
|
||
setMockupDropActive(false);
|
||
setMockupDragNodeId(null);
|
||
const dropPoint = canvasPointFromClient(upEvent.clientX, upEvent.clientY);
|
||
const droppedOnTarget = pointInsideMockupPlacement(dropPoint.x, dropPoint.y, targetNode, targetPlacement);
|
||
const droppedNode = nodesRef.current.find((node) => node.id === sourceNode.id);
|
||
const restoredSourceNode = droppedNode ? { ...droppedNode, x: start.x, y: start.y } : sourceNode;
|
||
const restoredNodes = nodesRef.current.map((node) => (node.id === sourceNode.id ? restoredSourceNode : node));
|
||
nodesRef.current = restoredNodes;
|
||
setNodes(restoredNodes);
|
||
if (droppedOnTarget && droppedNode && dragged) {
|
||
void applyMockupPrint(restoredSourceNode, targetNode, targetPlacement, targetSurface, targetModel, dropPoint);
|
||
return;
|
||
}
|
||
setDirty(wasDirty);
|
||
};
|
||
|
||
window.addEventListener("pointermove", handleMove);
|
||
window.addEventListener("pointerup", handleUp);
|
||
};
|
||
|
||
const handleNodePointerDown = (event: React.PointerEvent<HTMLDivElement>, node: CanvasNode) => {
|
||
event.stopPropagation();
|
||
if (event.button !== 0) return;
|
||
if (activeTool === "crop") {
|
||
if (!workspaceCropDetecting && workspaceCropTargetId === node.id && isRealCanvasImageNode(node) && !lockedNodeIds.has(node.id) && !hiddenNodeIds.has(node.id)) {
|
||
startWorkspaceCropPreselection(event, node);
|
||
}
|
||
return;
|
||
}
|
||
if (
|
||
imageActionMode === "mockup" &&
|
||
selectedImageNode &&
|
||
mockupReadyTargetId === selectedImageNode.id &&
|
||
mockupPlacement &&
|
||
node.id !== selectedImageNode.id &&
|
||
isRealCanvasImageNode(node) &&
|
||
!lockedNodeIds.has(node.id) &&
|
||
!hiddenNodeIds.has(node.id)
|
||
) {
|
||
startMockupSourceDrag(event, node, selectedImageNode);
|
||
return;
|
||
}
|
||
if (activeTool === "text" && (node.type === "image" || node.type === "frame")) {
|
||
event.preventDefault();
|
||
addTextNodeAt(canvasPointFromClient(event.clientX, event.clientY));
|
||
return;
|
||
}
|
||
if (activeTool === "pen") {
|
||
startPenDrawing(event);
|
||
return;
|
||
}
|
||
if (activeTool === "shape") {
|
||
startShapeDrawing(event);
|
||
return;
|
||
}
|
||
if (activeTool === "pin") {
|
||
event.preventDefault();
|
||
addAnnotationAtClientPoint(event.clientX, event.clientY, node);
|
||
return;
|
||
}
|
||
if (mode === "pan") {
|
||
startCanvasPan(event);
|
||
return;
|
||
}
|
||
if (editingTextNodeId && editingTextNodeId !== node.id) {
|
||
finishTextEditing(editingTextNodeId);
|
||
}
|
||
const additive = event.shiftKey || event.metaKey || event.ctrlKey;
|
||
if (additive) {
|
||
toggleNodeSelection(node.id);
|
||
return;
|
||
}
|
||
const shouldEditTextOnClick =
|
||
node.type === "text" &&
|
||
node.layerRole === "editable-text" &&
|
||
selectedIds.has(node.id) &&
|
||
selectedIds.size === 1 &&
|
||
activeTool === "select" &&
|
||
mode === "select" &&
|
||
!lockedNodeIds.has(node.id) &&
|
||
!editingTextNodeId;
|
||
const frameChild = isFrameChildNode(node, nodesRef.current);
|
||
const nodeGroupIds = node.parentId && !frameChild ? nodesRef.current.filter((item) => item.parentId === node.parentId).map((item) => item.id) : [];
|
||
const useExistingSelection = selectedIds.has(node.id) && selectedIds.size > 1;
|
||
const groupDrag = useExistingSelection || nodeGroupIds.length > 1;
|
||
const selectedDragIds = groupDrag ? new Set(useExistingSelection ? selectedIds : nodeGroupIds) : new Set([node.id]);
|
||
const dragIds = nodeIdsWithFrameChildren(nodesRef.current, selectedDragIds);
|
||
if (groupDrag) {
|
||
selectNodes(Array.from(selectedDragIds));
|
||
} else {
|
||
selectOnlyNode(node.id);
|
||
}
|
||
if (lockedNodeIds.has(node.id)) return;
|
||
|
||
const dragNodeById = new Map(nodesRef.current.map((item) => [item.id, item]));
|
||
const movesWithSelectedFrame = (item: CanvasNode) => {
|
||
const parent = item.parentId ? dragNodeById.get(item.parentId) : null;
|
||
return Boolean(parent && selectedDragIds.has(parent.id) && isFrameContainerNode(parent));
|
||
};
|
||
const start = {
|
||
pointerX: event.clientX,
|
||
pointerY: event.clientY,
|
||
positions: nodesRef.current
|
||
.filter((item) => dragIds.has(item.id) && (!lockedNodeIds.has(item.id) || movesWithSelectedFrame(item)))
|
||
.map((item) => ({ id: item.id, x: item.x, y: item.y })),
|
||
scale: viewportRef.current.k
|
||
};
|
||
const draggedElements = new Map(
|
||
start.positions.map((position) => [position.id, canvasWorldRef.current?.querySelector<HTMLElement>(`[data-canvas-node-id="${CSS.escape(position.id)}"]`) ?? null])
|
||
);
|
||
let dragged = false;
|
||
|
||
const handleMove = (moveEvent: PointerEvent) => {
|
||
const movement = Math.hypot(moveEvent.clientX - start.pointerX, moveEvent.clientY - start.pointerY);
|
||
if (movement > 3) dragged = true;
|
||
if (shouldEditTextOnClick && !dragged) return;
|
||
const dx = (moveEvent.clientX - start.pointerX) / start.scale;
|
||
const dy = (moveEvent.clientY - start.pointerY) / start.scale;
|
||
const nextNodes = moveNodesByDelta(nodesRef.current, start.positions, dx, dy);
|
||
const movedNodes = nextNodes.filter((item) => draggedElements.has(item.id));
|
||
const alignmentNodes = movedNodes.filter((item) => selectedDragIds.has(item.id));
|
||
nodesRef.current = nextNodes;
|
||
movedNodes.forEach((item) => {
|
||
const element = draggedElements.get(item.id);
|
||
if (element) {
|
||
element.style.left = `${item.x}px`;
|
||
element.style.top = `${item.y}px`;
|
||
}
|
||
leaferRendererRef.current?.setNodePosition(item.id, item.x, item.y);
|
||
});
|
||
containerRef.current?.classList.add("is-node-dragging");
|
||
renderAlignmentGuidesImperatively(imperativeAlignmentGuideRef.current, buildAlignmentGuides(alignmentNodes, nextNodes, viewportRef.current));
|
||
};
|
||
|
||
const handleUp = () => {
|
||
window.removeEventListener("pointermove", handleMove);
|
||
window.removeEventListener("pointerup", handleUp);
|
||
containerRef.current?.classList.remove("is-node-dragging");
|
||
renderAlignmentGuidesImperatively(imperativeAlignmentGuideRef.current, []);
|
||
setAlignmentGuides([]);
|
||
if (shouldEditTextOnClick && !dragged) {
|
||
setEditingTextNodeId(node.id);
|
||
return;
|
||
}
|
||
if (dragged) {
|
||
const nextNodes = attachFrameChildren(nodesRef.current, dragIds);
|
||
nodesRef.current = nextNodes;
|
||
startTransition(() => setNodes(nextNodes));
|
||
setDirty(true);
|
||
}
|
||
if (dragged && Array.from(dragIds).some((nodeId) => nodesRef.current.find((item) => item.id === nodeId)?.layerRole === "mockup-print")) {
|
||
void refreshMockupPrintTextures(Array.from(dragIds));
|
||
return;
|
||
}
|
||
scheduleCanvasSave();
|
||
};
|
||
|
||
window.addEventListener("pointermove", handleMove);
|
||
window.addEventListener("pointerup", handleUp);
|
||
};
|
||
|
||
const handleTextNodeResizePointerDown = (event: React.PointerEvent<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([]);
|
||
const nextNodes = attachFrameChildren(nodesRef.current, new Set([node.id]));
|
||
nodesRef.current = nextNodes;
|
||
setNodes(nextNodes);
|
||
if (node.layerRole === "mockup-print") {
|
||
void refreshMockupPrintTextures([node.id]);
|
||
return;
|
||
}
|
||
scheduleCanvasSave();
|
||
};
|
||
|
||
window.addEventListener("pointermove", handleMove);
|
||
window.addEventListener("pointerup", handleUp);
|
||
};
|
||
|
||
const handleNodeResizePointerDown = (event: React.PointerEvent<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 });
|
||
const nextNodes = isFrameContainerNode(node) ? resizeFrameWithChildren(nodesRef.current, node.id, resizedNode) : nodesRef.current.map((item) => (item.id === node.id ? resizedNode : item));
|
||
nodesRef.current = nextNodes;
|
||
setNodes(nextNodes);
|
||
setAlignmentGuides(buildAlignmentGuides([resizedNode], nodesRef.current, viewportRef.current));
|
||
setDirty(true);
|
||
};
|
||
|
||
const handleUp = () => {
|
||
window.removeEventListener("pointermove", handleMove);
|
||
window.removeEventListener("pointerup", handleUp);
|
||
setAlignmentGuides([]);
|
||
if (!isFrameContainerNode(node)) {
|
||
const nextNodes = attachFrameChildren(nodesRef.current, new Set([node.id]));
|
||
nodesRef.current = nextNodes;
|
||
setNodes(nextNodes);
|
||
}
|
||
scheduleCanvasSave();
|
||
};
|
||
|
||
window.addEventListener("pointermove", handleMove);
|
||
window.addEventListener("pointerup", handleUp);
|
||
};
|
||
|
||
const handleShapeEndpointPointerDown = (event: React.PointerEvent<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([]);
|
||
scheduleCanvasSave();
|
||
};
|
||
|
||
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([]);
|
||
scheduleCanvasSave();
|
||
};
|
||
|
||
window.addEventListener("pointermove", handleMove);
|
||
window.addEventListener("pointerup", handleUp);
|
||
};
|
||
|
||
const updateNodeContent = (nodeId: string, content: string) => {
|
||
const nextNodes = updateNodeContentOp(nodesRef.current, nodeId, content);
|
||
nodesRef.current = nextNodes;
|
||
setNodes(nextNodes);
|
||
setDirty(true);
|
||
};
|
||
|
||
const renameNodeTitle = (nodeId: string, title: string) => {
|
||
const nextTitle = title.trim();
|
||
if (!nextTitle) return;
|
||
const applyPatch = (item: CanvasNode) => (item.id === nodeId ? { ...item, title: nextTitle } : item);
|
||
nodesRef.current = nodesRef.current.map(applyPatch);
|
||
setNodes((current) => current.map(applyPatch));
|
||
setDirty(true);
|
||
};
|
||
|
||
const updateFrameStyle = (nodeId: string, patch: FrameStylePatch) => {
|
||
const frame = nodesRef.current.find((item) => item.id === nodeId);
|
||
if (!frame) return;
|
||
const nextFrame = patchFrameStyleNode(frame, patch);
|
||
const nextNodes = isFrameContainerNode(frame) ? resizeFrameWithChildren(nodesRef.current, nodeId, nextFrame) : nodesRef.current.map((item) => (item.id === nodeId ? nextFrame : item));
|
||
nodesRef.current = nextNodes;
|
||
setNodes(nextNodes);
|
||
setDirty(true);
|
||
};
|
||
|
||
const updateShapeStyle = (nodeId: string, patch: ShapeStylePatch) => {
|
||
const applyPatch = (item: CanvasNode) => (item.id === nodeId && isShapeNode(item) ? patchShapeStyleNode(item, patch) : item);
|
||
nodesRef.current = nodesRef.current.map(applyPatch);
|
||
setNodes((current) => current.map(applyPatch));
|
||
setDirty(true);
|
||
};
|
||
|
||
const updatePenStrokeStyle = (nodeId: string, patch: PenStrokePatch) => {
|
||
const applyPatch = (item: CanvasNode) => (item.id === nodeId && isCanvasPenStrokeNode(item) ? updatePenStrokeNode(item, patch, viewportRef.current.k) : item);
|
||
nodesRef.current = nodesRef.current.map(applyPatch);
|
||
setNodes((current) => current.map(applyPatch));
|
||
setDirty(true);
|
||
};
|
||
|
||
const updateTextStyle = (nodeId: string, patch: TextStylePatch) => {
|
||
const shouldFit = textStylePatchChangesMetrics(patch);
|
||
const applyPatch = (item: CanvasNode) => {
|
||
if (item.id !== nodeId || item.type !== "text") return item;
|
||
const nextNode = {
|
||
...item,
|
||
...patch
|
||
};
|
||
return shouldFit ? fitTextNodeToContent(nextNode, { keepX: true, keepY: true }) : nextNode;
|
||
};
|
||
nodesRef.current = nodesRef.current.map(applyPatch);
|
||
setNodes((current) => current.map(applyPatch));
|
||
setDirty(true);
|
||
};
|
||
|
||
const removeNode = (nodeId: string) => {
|
||
const ids = frameAwareNodeIds([nodeId]);
|
||
nodesRef.current = removeNodesById(nodesRef.current, ids);
|
||
setNodes((current) => removeNodesById(current, ids));
|
||
setEditingTextNodeId((current) => (current && ids.has(current) ? null : current));
|
||
setHiddenNodeIds((current) => {
|
||
const next = new Set(current);
|
||
ids.forEach((id) => next.delete(id));
|
||
return next;
|
||
});
|
||
setLockedNodeIds((current) => {
|
||
const next = new Set(current);
|
||
ids.forEach((id) => next.delete(id));
|
||
return next;
|
||
});
|
||
setSelectedIds((current) => {
|
||
const next = new Set(current);
|
||
ids.forEach((id) => next.delete(id));
|
||
return next;
|
||
});
|
||
if (selectedId && ids.has(selectedId)) selectOnlyNode(null);
|
||
setDirty(true);
|
||
};
|
||
|
||
const finishTextEditing = (nodeId: string, contentOverride?: string) => {
|
||
const node = nodesRef.current.find((item) => item.id === nodeId);
|
||
const content = contentOverride ?? node?.content ?? "";
|
||
setEditingTextNodeId(null);
|
||
setActiveTool("select");
|
||
setMode("select");
|
||
|
||
if (!node) return;
|
||
if (node.type === "text" && node.layerRole === "editable-text" && isBlankTextContent(content)) {
|
||
removeNode(nodeId);
|
||
return;
|
||
}
|
||
const nextNode = fitTextNodeToContent({ ...node, content }, { keepX: true, keepY: true });
|
||
nodesRef.current = nodesRef.current.map((item) => (item.id === nodeId ? nextNode : item));
|
||
setNodes((current) => current.map((item) => (item.id === nodeId ? nextNode : item)));
|
||
|
||
selectOnlyNode(nodeId);
|
||
setDirty(true);
|
||
};
|
||
|
||
const duplicateNode = (node: CanvasNode) => {
|
||
const copy = cloneCanvasNode(node, cryptoFallbackId());
|
||
setNodesWithFrameAttachment((current) => ({
|
||
nodes: [...current, copy],
|
||
candidateIds: new Set([copy.id])
|
||
}));
|
||
selectOnlyNode(copy.id);
|
||
setDirty(true);
|
||
};
|
||
|
||
const copyNode = (node: CanvasNode) => {
|
||
void writeCanvasNodeToSystemClipboard(node)
|
||
.then(() => showCanvasToast(t("clipboardCopied")))
|
||
.catch(() => showCanvasToast(t("clipboardCopyFailed"), "error"));
|
||
};
|
||
|
||
const reorderNode = (nodeId: string, action: "forward" | "backward" | "front" | "back") => {
|
||
setNodes((current) => {
|
||
const next = reorderNodeOp(current, nodeId, action);
|
||
nodesRef.current = next;
|
||
return next;
|
||
});
|
||
setDirty(true);
|
||
};
|
||
|
||
const reorderLayer = (request: LayerReorderRequest) => {
|
||
setNodes((current) => {
|
||
const next = reorderLayerOp(current, request);
|
||
nodesRef.current = next;
|
||
return next;
|
||
});
|
||
setDirty(true);
|
||
};
|
||
|
||
const toggleLayerHidden = (nodeId: string) => {
|
||
const ids = Array.from(frameAwareNodeIds([nodeId]));
|
||
setHiddenNodeIds((current) => toggleSetValues(current, ids));
|
||
setDirty(true);
|
||
};
|
||
|
||
const toggleLayerLocked = (nodeId: string) => {
|
||
const ids = Array.from(frameAwareNodeIds([nodeId]));
|
||
setLockedNodeIds((current) => toggleSetValues(current, ids));
|
||
setDirty(true);
|
||
};
|
||
|
||
const toggleLayersHidden = (nodeIds: string[]) => {
|
||
if (nodeIds.length === 0) return;
|
||
setHiddenNodeIds((current) => toggleSetValues(current, Array.from(frameAwareNodeIds(nodeIds))));
|
||
setDirty(true);
|
||
};
|
||
|
||
const toggleLayersLocked = (nodeIds: string[]) => {
|
||
if (nodeIds.length === 0) return;
|
||
setLockedNodeIds((current) => toggleSetValues(current, Array.from(frameAwareNodeIds(nodeIds))));
|
||
setDirty(true);
|
||
};
|
||
|
||
const updateNodeTransform = (nodeId: string, patch: Partial<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 = frameAwareNodeIds(selectedNodes.filter((node) => !lockedNodeIds.has(node.id) && !hiddenNodeIds.has(node.id)).map((node) => node.id));
|
||
if (ids.size === 0) return;
|
||
nodesRef.current = removeNodesById(nodesRef.current, ids);
|
||
setNodes((current) => removeNodesById(current, ids));
|
||
setHiddenNodeIds((current) => new Set(Array.from(current).filter((nodeId) => !ids.has(nodeId))));
|
||
setLockedNodeIds((current) => new Set(Array.from(current).filter((nodeId) => !ids.has(nodeId))));
|
||
selectOnlyNode(null);
|
||
setDirty(true);
|
||
};
|
||
|
||
const clipboardPasteAnchor = () => {
|
||
const pointer = lastCanvasPointerRef.current;
|
||
return pointer ? canvasPointFromClient(pointer.clientX, pointer.clientY) : canvasCenterPoint();
|
||
};
|
||
|
||
const pasteClipboardText = (value: string, anchor = clipboardPasteAnchor()) => {
|
||
const content = value.replace(/\u00a0/g, " ");
|
||
if (!content.trim()) return false;
|
||
|
||
const currentViewport = viewportRef.current;
|
||
const zoom = Math.max(currentViewport.k, 0.01);
|
||
const size = { width: defaultTextVisibleBox.width / zoom, height: defaultTextVisibleBox.height / zoom };
|
||
const node = fitTextNodeToContent({
|
||
id: cryptoFallbackId(),
|
||
type: "text",
|
||
title: t("newTextNode"),
|
||
x: anchor.x - size.width / 2,
|
||
y: anchor.y - size.height / 2,
|
||
width: size.width,
|
||
height: size.height,
|
||
content,
|
||
tone: "ink",
|
||
status: "draft",
|
||
layerRole: "editable-text",
|
||
fontFamily: "Inter",
|
||
fontWeight: "Regular",
|
||
fontSize: defaultTextVisibleFontSize / zoom,
|
||
color: "#000000",
|
||
textAlign: "left",
|
||
opacity: 1,
|
||
strokeColor: "#000000",
|
||
strokeWidth: 0,
|
||
lineHeight: 0,
|
||
letterSpacing: 0,
|
||
textDecoration: "none",
|
||
textTransform: "none"
|
||
});
|
||
setNodesWithFrameAttachment((current) => ({
|
||
nodes: [...current, node],
|
||
candidateIds: new Set([node.id])
|
||
}));
|
||
selectOnlyNode(node.id);
|
||
setDirty(true);
|
||
return true;
|
||
};
|
||
|
||
const pasteFromClipboardData = (dataTransfer: DataTransfer) => {
|
||
const anchor = clipboardPasteAnchor();
|
||
const imageFile = latestDataTransferImageFile(dataTransfer, "clipboard-image");
|
||
if (imageFile) {
|
||
void uploadImagesToCanvas([imageFile], anchor);
|
||
return true;
|
||
}
|
||
return pasteClipboardText(dataTransfer.getData("text/plain"), anchor);
|
||
};
|
||
|
||
const pasteFromSystemClipboard = async () => {
|
||
const anchor = clipboardPasteAnchor();
|
||
const imageFile = await latestNavigatorClipboardImageFile("clipboard-image").catch(() => null);
|
||
if (imageFile) {
|
||
await uploadImagesToCanvas([imageFile], anchor);
|
||
return;
|
||
}
|
||
const text = await navigator.clipboard?.readText?.().catch(() => "");
|
||
if (text) pasteClipboardText(text, anchor);
|
||
};
|
||
|
||
const sendNodeToAgentComposer = (node: CanvasNode) => {
|
||
if (isRealCanvasImageNode(node)) {
|
||
insertCanvasImageReference(node, { focus: true });
|
||
return;
|
||
}
|
||
if (node.type === "text") {
|
||
const content = node.content.trim();
|
||
if (!content) return;
|
||
setPrompt((current) => (current.trim() ? `${current.trim()}\n${content}` : content));
|
||
setComposerMode("agent");
|
||
}
|
||
};
|
||
|
||
const createNodeContextMenuHandlers = (node: CanvasNode): NodeContextMenuHandlers => ({
|
||
copy: () => copyNode(node),
|
||
cut: () => {
|
||
copyNode(node);
|
||
removeNode(node.id);
|
||
},
|
||
paste: () => void pasteFromSystemClipboard(),
|
||
duplicate: () => duplicateNode(node),
|
||
bringForward: () => reorderNode(node.id, "forward"),
|
||
sendBackward: () => reorderNode(node.id, "backward"),
|
||
bringToFront: () => reorderNode(node.id, "front"),
|
||
sendToBack: () => reorderNode(node.id, "back"),
|
||
sendToChat: () => sendNodeToAgentComposer(node),
|
||
createGroup: createGroupFromSelectedNodes,
|
||
ungroup: () => (isBooleanShapeGroupNode(node) ? ungroupBooleanShapeGroup(node.id) : ungroupNodesByGroupId(node.parentId)),
|
||
autoLayout: () => autoLayoutAroundNode(node),
|
||
mergeLayers: () => void mergeSelectedNodes(),
|
||
toggleHidden: () => toggleLayerHidden(node.id),
|
||
toggleLocked: () => toggleLayerLocked(node.id),
|
||
flipHorizontal: () => toggleNodeTransform(node.id, "flipX"),
|
||
flipVertical: () => toggleNodeTransform(node.id, "flipY"),
|
||
exportFormats: node.type === "text" ? ["svg"] : ["png", "jpg", "svg", "psd"],
|
||
exportAs: (format) => exportCanvasNode(node, format, { nodes: nodesRef.current, hiddenNodeIds }),
|
||
deleteNode: () => removeNode(node.id)
|
||
});
|
||
|
||
useEffect(() => {
|
||
const handleKeyDown = (event: KeyboardEvent) => {
|
||
if (isEditableShortcutTarget(event.target)) return;
|
||
if (!canEdit) return;
|
||
|
||
const key = event.key.toLowerCase();
|
||
const primary = isPrimaryShortcut(event);
|
||
const selected = selectedNode;
|
||
const selectedHandlers = selected && !lockedNodeIds.has(selected.id) && canUseNodeContextMenu(selected) ? createNodeContextMenuHandlers(selected) : null;
|
||
const selectedCanDelete = selected && !lockedNodeIds.has(selected.id) && !hiddenNodeIds.has(selected.id);
|
||
const run = (action: (() => void) | undefined, prevent = true) => {
|
||
if (!action) return false;
|
||
if (prevent) event.preventDefault();
|
||
action();
|
||
return true;
|
||
};
|
||
|
||
if (!primary && !event.shiftKey && !event.altKey && key === "escape" && (imageActionMode || mockupPendingTargetId || activeTool === "crop")) {
|
||
return void run(() => {
|
||
chooseCanvasTool("select");
|
||
setImageActionMode(null);
|
||
setMockupDropActive(false);
|
||
setMockupDragNodeId(null);
|
||
setMockupPendingTargetId(null);
|
||
setMockupReadyTargetId(null);
|
||
setMockupPlacement(null);
|
||
setMockupSurface(null);
|
||
setMockupModel(null);
|
||
mockupPrepareRequestRef.current += 1;
|
||
if (mockupPrepareTimerRef.current) {
|
||
clearTimeout(mockupPrepareTimerRef.current);
|
||
mockupPrepareTimerRef.current = null;
|
||
}
|
||
});
|
||
}
|
||
if (!primary && !event.shiftKey && !event.altKey && key === "v") return void run(() => chooseCanvasTool("select"));
|
||
if (!primary && !event.shiftKey && !event.altKey && key === "h") return void run(() => chooseCanvasTool("pan"));
|
||
if (!primary && !event.shiftKey && !event.altKey && key === "f") return void run(() => chooseCanvasTool("frame"));
|
||
if (!primary && !event.shiftKey && !event.altKey && key === "t") return void run(() => chooseCanvasTool("text"));
|
||
if (!primary && !event.shiftKey && !event.altKey && key === "r") return void run(() => chooseShapeTool("rectangle"));
|
||
if (!primary && !event.shiftKey && !event.altKey && key === "l") return void run(() => chooseShapeTool("line"));
|
||
if (!primary && event.shiftKey && !event.altKey && key === "l") return void run(() => chooseShapeTool("arrow"));
|
||
if (!primary && !event.shiftKey && !event.altKey && key === "o") return void run(() => chooseShapeTool("ellipse"));
|
||
if (primary && (key === "+" || key === "=")) return void run(() => zoomCanvasTo(viewportRef.current.k + 0.1));
|
||
if (primary && !event.shiftKey && key === "-") return void run(() => zoomCanvasTo(viewportRef.current.k - 0.1));
|
||
if (!primary && event.shiftKey && (key === "!" || key === "1")) return void run(fitCanvasToAllNodes);
|
||
if (primary && !event.shiftKey && key === "c") return void run(selectedHandlers?.copy);
|
||
if (primary && !event.shiftKey && key === "x") return void run(selectedHandlers?.cut);
|
||
if (primary && !event.shiftKey && key === "d") return void run(selectedHandlers?.duplicate);
|
||
if (primary && !event.shiftKey && event.key === "]") return void run(selectedHandlers?.bringForward);
|
||
if (primary && !event.shiftKey && event.key === "[") return void run(selectedHandlers?.sendBackward);
|
||
if (!primary && !event.shiftKey && event.key === "]") return void run(selectedHandlers?.bringToFront);
|
||
if (!primary && !event.shiftKey && event.key === "[") return void run(selectedHandlers?.sendToBack);
|
||
if (primary && event.shiftKey && key === "g") return void run(ungroupSelectedNodes);
|
||
if (primary && !event.shiftKey && key === "g") return void run(createGroupFromSelectedNodes);
|
||
if (!primary && event.shiftKey && key === "a") return void run(selectedHandlers?.autoLayout);
|
||
if (primary && !event.shiftKey && key === "e") return void run(selectedHandlers?.mergeLayers);
|
||
if (primary && event.shiftKey && key === "h") return void run(selectedHandlers?.toggleHidden);
|
||
if (primary && event.shiftKey && key === "l") return void run(selectedHandlers?.toggleLocked);
|
||
if ((event.key === "Backspace" || event.key === "Delete") && selectedNodes.length > 1) return void run(deleteSelectedNodes);
|
||
if ((event.key === "Backspace" || event.key === "Delete") && selected) return void run(selectedHandlers?.deleteNode ?? (selectedCanDelete ? () => removeNode(selected.id) : undefined));
|
||
};
|
||
|
||
window.addEventListener("keydown", handleKeyDown);
|
||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||
});
|
||
|
||
useEffect(() => {
|
||
const handlePaste = (event: ClipboardEvent) => {
|
||
if (isEditableShortcutTarget(event.target)) return;
|
||
if (!canEdit) return;
|
||
if (!event.clipboardData) return;
|
||
if (pasteFromClipboardData(event.clipboardData)) {
|
||
event.preventDefault();
|
||
}
|
||
};
|
||
|
||
window.addEventListener("paste", handlePaste);
|
||
return () => window.removeEventListener("paste", handlePaste);
|
||
});
|
||
|
||
const addCanvasNode = (type: CanvasNode["type"]) => {
|
||
const rect = containerRef.current?.getBoundingClientRect();
|
||
const stageWidth = rect?.width ?? 1100;
|
||
const stageHeight = rect?.height ?? 760;
|
||
const centerX = (stageWidth / 2 - viewport.x) / viewport.k;
|
||
const centerY = (stageHeight / 2 - viewport.y) / viewport.k;
|
||
const size =
|
||
type === "text"
|
||
? { width: 280, height: 180 }
|
||
: type === "image"
|
||
? { width: 360, height: 460 }
|
||
: { width: 520, height: 320 };
|
||
const node: CanvasNode = {
|
||
id: typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID() : `${Date.now()}-${type}`,
|
||
type,
|
||
title: type === "text" ? t("newTextNode") : type === "image" ? t("newImageNode") : t("newFrameNode"),
|
||
x: centerX - size.width / 2,
|
||
y: centerY - size.height / 2,
|
||
width: size.width,
|
||
height: size.height,
|
||
content: type === "text" ? t("newTextNodeContent") : project?.brief ?? t("unnamedCanvasProject"),
|
||
tone: "natural",
|
||
status: "draft"
|
||
};
|
||
setNodesWithFrameAttachment((current) => ({
|
||
nodes: [...current, node],
|
||
candidateIds: new Set([node.id])
|
||
}));
|
||
selectOnlyNode(node.id);
|
||
setDirty(true);
|
||
};
|
||
|
||
const addImageGeneratorNode = (point?: { x: number; y: number }) => {
|
||
const rect = containerRef.current?.getBoundingClientRect();
|
||
const stageWidth = rect?.width ?? 1100;
|
||
const stageHeight = rect?.height ?? 760;
|
||
const center = {
|
||
x: (stageWidth / 2 - viewport.x) / viewport.k,
|
||
y: (stageHeight / 2 - viewport.y) / viewport.k
|
||
};
|
||
const origin = point ?? center;
|
||
const size = imageGeneratorPlaceholderSize(composerImageSettings, viewport.k, { width: stageWidth, height: stageHeight });
|
||
const position = point ? { x: origin.x - size.width / 2, y: origin.y - size.height / 2 } : nextImageGeneratorPosition(origin, size, nodesRef.current, viewport.k);
|
||
const node: CanvasNode = {
|
||
id: cryptoFallbackId(),
|
||
type: "image",
|
||
title: t("imageGeneratorNode"),
|
||
x: position.x,
|
||
y: position.y,
|
||
width: size.width,
|
||
height: size.height,
|
||
content: "",
|
||
tone: "image-generator",
|
||
status: "draft",
|
||
layerRole: "image-generator",
|
||
imageAdjustments: serializeImageGeneratorTargetSize(composerImageSettings)
|
||
};
|
||
setComposerMode("image");
|
||
setActiveTool("select");
|
||
setMode("select");
|
||
setNodesWithFrameAttachment((current) => ({
|
||
nodes: [...current, node],
|
||
candidateIds: new Set([node.id])
|
||
}));
|
||
selectOnlyNode(node.id);
|
||
setDirty(true);
|
||
};
|
||
|
||
const updateComposerImageSettings = (settings: ComposerImageSettings) => {
|
||
setComposerImageSettings(settings);
|
||
const generator = selectedImageGeneratorNode;
|
||
if (!generator) return;
|
||
const size = imageGeneratorPlaceholderSize(settings, viewportRef.current.k, canvasStageSize);
|
||
setNodes((current) => {
|
||
const next = current.map((node) => {
|
||
if (node.id !== generator.id || node.type !== "image" || node.layerRole !== "image-generator") return node;
|
||
const centerX = node.x + node.width / 2;
|
||
const centerY = node.y + node.height / 2;
|
||
return {
|
||
...node,
|
||
x: centerX - size.width / 2,
|
||
y: centerY - size.height / 2,
|
||
width: size.width,
|
||
height: size.height,
|
||
imageAdjustments: serializeImageGeneratorTargetSize(settings, node.imageAdjustments)
|
||
};
|
||
});
|
||
nodesRef.current = next;
|
||
return next;
|
||
});
|
||
setDirty(true);
|
||
};
|
||
|
||
const addTextNodeAt = (point?: { x: number; y: number }) => {
|
||
const rect = containerRef.current?.getBoundingClientRect();
|
||
const stageWidth = rect?.width ?? 1100;
|
||
const stageHeight = rect?.height ?? 760;
|
||
const currentViewport = viewportRef.current;
|
||
const center = {
|
||
x: (stageWidth / 2 - currentViewport.x) / currentViewport.k,
|
||
y: (stageHeight / 2 - currentViewport.y) / currentViewport.k
|
||
};
|
||
const origin = point ?? center;
|
||
const zoom = Math.max(currentViewport.k, 0.01);
|
||
const screenFontSize = defaultTextVisibleFontSize;
|
||
const size = { width: defaultTextVisibleBox.width / zoom, height: defaultTextVisibleBox.height / zoom };
|
||
const x = point ? origin.x : origin.x - size.width / 2;
|
||
const y = point ? origin.y : origin.y - size.height / 2;
|
||
const node: CanvasNode = {
|
||
id: cryptoFallbackId(),
|
||
type: "text",
|
||
title: t("newTextNode"),
|
||
x,
|
||
y,
|
||
width: size.width,
|
||
height: size.height,
|
||
content: "",
|
||
tone: "ink",
|
||
status: "draft",
|
||
layerRole: "editable-text",
|
||
fontFamily: "Inter",
|
||
fontWeight: "Regular",
|
||
fontSize: screenFontSize / zoom,
|
||
color: "#000000",
|
||
textAlign: "left",
|
||
opacity: 1,
|
||
strokeColor: "#000000",
|
||
strokeWidth: 0,
|
||
lineHeight: 0,
|
||
letterSpacing: 0,
|
||
textDecoration: "none",
|
||
textTransform: "none"
|
||
};
|
||
setNodesWithFrameAttachment((current) => ({
|
||
nodes: [...current, node],
|
||
candidateIds: new Set([node.id])
|
||
}));
|
||
selectOnlyNode(node.id);
|
||
setEditingTextNodeId(node.id);
|
||
setDirty(true);
|
||
};
|
||
|
||
const chooseCanvasTool = (tool: CanvasTool) => {
|
||
if (!canEdit) return;
|
||
if (tool !== "crop") {
|
||
workspaceCropDetectionRequestRef.current += 1;
|
||
setWorkspaceCropDetecting(false);
|
||
setWorkspaceCropDraftBounds(null);
|
||
setWorkspaceCropTargetId(null);
|
||
}
|
||
setActiveTool(tool);
|
||
setCanvasToolMenuOpen(false);
|
||
if (tool !== "shape") setShapeToolMenuOpen(false);
|
||
if (tool === "select" || tool === "pin") {
|
||
setMode("select");
|
||
return;
|
||
}
|
||
if (tool === "pan") {
|
||
setMode("pan");
|
||
return;
|
||
}
|
||
if (tool === "image") {
|
||
addImageGeneratorNode();
|
||
return;
|
||
}
|
||
if (tool === "frame") {
|
||
setMode("select");
|
||
return;
|
||
}
|
||
if (tool === "shape") {
|
||
setMode("select");
|
||
return;
|
||
}
|
||
if (tool === "pen") {
|
||
setMode("select");
|
||
return;
|
||
}
|
||
if (tool === "crop") {
|
||
setMode("select");
|
||
return;
|
||
}
|
||
setMode("select");
|
||
};
|
||
|
||
const chooseShapeTool = (kind: ShapeKind) => {
|
||
setSelectedShapeKind(kind);
|
||
setShapeToolMenuOpen(false);
|
||
chooseCanvasTool("shape");
|
||
};
|
||
|
||
const syncReferenceImages = (references: UploadedReferenceImage[]) => {
|
||
referenceImagesRef.current = references;
|
||
setReferenceImages(references);
|
||
};
|
||
|
||
const handleReferenceRemoved = (reference: UploadedReferenceImage) => {
|
||
revokeReferenceImage(reference);
|
||
if (reference.external) return;
|
||
if (isCanvasNodeReferenceImage(reference)) return;
|
||
if (isAssetReferencedByCanvas(reference.publicUrl, nodesRef.current)) return;
|
||
if (reference.publicUrl) {
|
||
void designGateway.deleteAsset(reference.publicUrl).catch((err: Error) => console.warn("Failed to queue reference image cleanup", err));
|
||
}
|
||
};
|
||
|
||
const beginNewAgentConversation = () => {
|
||
autoScrollAgentFeedRef.current = false;
|
||
selectedAgentThreadIdRef.current = newAgentConversationId;
|
||
setSelectedAgentThreadId(newAgentConversationId);
|
||
setAgentHistoryLoading(false);
|
||
setAgentHistoryOpen(false);
|
||
setPrompt("");
|
||
setMessages([]);
|
||
setError("");
|
||
agentComposerRef.current?.reset("");
|
||
activeAgentThreadIdRef.current = null;
|
||
};
|
||
|
||
const selectAgentStarterPrompt = (nextPrompt: string) => {
|
||
if (!canEdit) return;
|
||
setComposerMode("agent");
|
||
setPrompt(nextPrompt);
|
||
window.requestAnimationFrame(() => agentComposerRef.current?.focus());
|
||
};
|
||
|
||
const selectAgentThread = (threadId: string) => {
|
||
autoScrollAgentFeedRef.current = false;
|
||
selectedAgentThreadIdRef.current = threadId;
|
||
setSelectedAgentThreadId(threadId);
|
||
setAgentHistoryOpen(false);
|
||
const currentProject = projectRef.current;
|
||
if (currentProject) {
|
||
setAgentHistoryLoading(true);
|
||
setMessages(visibleAgentMessagesForThread(currentProject, imageGeneratorThreadIdsRef.current, threadId));
|
||
void designGateway
|
||
.getProjectHistory(currentProject.id, threadId)
|
||
.then((history) => {
|
||
if (selectedAgentThreadIdRef.current !== threadId) return;
|
||
const nextProject = {
|
||
...currentProject,
|
||
lastThreadId: history.threadId || currentProject.lastThreadId,
|
||
messages: history.messages
|
||
};
|
||
projectRef.current = nextProject;
|
||
setProject(nextProject);
|
||
setMessages(visibleAgentMessagesForThread(nextProject, imageGeneratorThreadIdsRef.current, threadId));
|
||
})
|
||
.catch((err) => {
|
||
console.warn("Failed to load agent thread history", err);
|
||
})
|
||
.finally(() => {
|
||
if (selectedAgentThreadIdRef.current === threadId) setAgentHistoryLoading(false);
|
||
});
|
||
}
|
||
};
|
||
|
||
const removeAgentThreadFromMenu = (threadId: string) => {
|
||
setAgentThreads((current) => current.filter((thread) => thread.agentThreadId !== threadId));
|
||
};
|
||
|
||
const generate = async (source: "agent" | "image-generator" = "agent", imageGeneratorContents?: AgentContent[], resetImageGenerator?: () => void) => {
|
||
const selectedGeneratorNode = source === "image-generator" && selectedImageGeneratorNode ? selectedImageGeneratorNode : null;
|
||
const activeMode: ComposerMode = source === "image-generator" ? "image" : composerMode;
|
||
const enableSearch = source === "agent" && useAgentWebSearch;
|
||
const activeRef = agentComposerRef;
|
||
const activePrompt = prompt;
|
||
const baseContents =
|
||
source === "image-generator"
|
||
? imageGeneratorContents ?? []
|
||
: activeRef.current?.toAgentContents() ?? [{ type: "text" as const, text: activePrompt, textSource: "input" }];
|
||
const hasUserText = baseContents.some((content) => content.type === "text" && !isHiddenTextSource(content.textSource) && content.text.replace(/\u00a0/g, " ").trim());
|
||
const contents =
|
||
activeMode === "image"
|
||
? withImageGeneratorTarget(baseContents, selectedGeneratorNode?.id)
|
||
: baseContents;
|
||
const visibleContents = activeMode === "image" ? baseContents : contents;
|
||
if (!project || !hasUserText) return;
|
||
const selectedThreadId = selectedAgentThreadIdRef.current;
|
||
const reusableAgentThreadId =
|
||
source === "agent" && selectedThreadId !== newAgentConversationId
|
||
? selectedThreadId || latestAgentThreadId(project, imageGeneratorThreadIdsRef.current)
|
||
: "";
|
||
const threadId = reusableAgentThreadId || cryptoFallbackId();
|
||
if (source === "image-generator") {
|
||
setImageGeneratorGenerating(true);
|
||
if (selectedGeneratorNode) {
|
||
const lockedPrompt = imageGeneratorSubmittedPrompt(baseContents);
|
||
setNodes((current) => {
|
||
const next = current.map((node) =>
|
||
node.id === selectedGeneratorNode.id && node.type === "image" && node.layerRole === "image-generator"
|
||
? { ...node, content: lockedPrompt, status: "generating", imageAdjustments: serializeImageGeneratorTargetSize(composerImageSettings, node.imageAdjustments) }
|
||
: node
|
||
);
|
||
nodesRef.current = next;
|
||
return next;
|
||
});
|
||
}
|
||
} else {
|
||
activeAgentThreadIdRef.current = threadId;
|
||
selectedAgentThreadIdRef.current = threadId;
|
||
setSelectedAgentThreadId(threadId);
|
||
setAgentGenerating(true);
|
||
autoScrollAgentFeedRef.current = true;
|
||
}
|
||
setError("");
|
||
if (source === "image-generator") {
|
||
imageGeneratorThreadIdsRef.current.add(threadId);
|
||
} else {
|
||
setMessages((current) => [...current, ...createOptimisticAgentMessages(threadId, visibleContents)]);
|
||
}
|
||
try {
|
||
if (dirty) {
|
||
await saveCanvasSnapshot({ quiet: true });
|
||
}
|
||
const modelContents = await prepareModelAgentContents(contents);
|
||
startAgentSocket(
|
||
project.id,
|
||
{
|
||
messages: [{ role: "user", contents: modelContents }],
|
||
mode: source === "image-generator" ? "image-generator" : activeMode === "image" ? "image" : "design",
|
||
threadId,
|
||
threadIdType: 9,
|
||
imageModel: toImageApiModel(selectedCanvasModel.id),
|
||
imageSize: activeMode === "image" ? composerImageSize(composerImageSettings) : undefined,
|
||
imageQuality: activeMode === "image" ? composerImageSettings.quality : undefined,
|
||
imageCount: activeMode === "image" ? composerImageSettings.count : undefined,
|
||
enableWebSearch: enableSearch,
|
||
toolConfig: {
|
||
preferToolCategories: {
|
||
...(enableSearch ? { SEARCH: ["search_web_info", "search_images"] } : {}),
|
||
IMAGE: ["generate_image_gpt_image_2"]
|
||
}
|
||
}
|
||
},
|
||
source
|
||
);
|
||
if (source === "image-generator") {
|
||
resetImageGenerator?.();
|
||
} else {
|
||
activeRef.current?.reset("");
|
||
setPrompt("");
|
||
}
|
||
setDirty(false);
|
||
} catch (err) {
|
||
setError(toUserMessageText(err, "generateError"));
|
||
if (source === "image-generator") {
|
||
setImageGeneratorGenerating(false);
|
||
} else {
|
||
activeAgentThreadIdRef.current = null;
|
||
setAgentGenerating(false);
|
||
}
|
||
}
|
||
};
|
||
|
||
const stopAgentTask = async () => {
|
||
if (!project) return;
|
||
const threadId = activeAgentThreadIdRef.current || project.lastThreadId;
|
||
autoScrollAgentFeedRef.current = false;
|
||
stopGenerationStream("agent");
|
||
activeAgentThreadIdRef.current = null;
|
||
setAgentGenerating(false);
|
||
setError("");
|
||
try {
|
||
const { project: nextProject } = await designGateway.stopAgentTask(project.id, threadId);
|
||
applyProject(nextProject);
|
||
} catch (err) {
|
||
setError(toUserMessageText(err, "generateError"));
|
||
}
|
||
};
|
||
|
||
const runImageNodeAction = async (action: NodeActionRequest["action"], request: Omit<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);
|
||
setVisualAnnotationMarks([]);
|
||
setEraserMarks([]);
|
||
setTextEditItems([]);
|
||
setTextEditLoading(false);
|
||
setTextEditError("");
|
||
setMoveSelection(null);
|
||
setMoveRecognitionLabel("");
|
||
setMoveRecognitionLoading(false);
|
||
setCropSelection(null);
|
||
setQuickEditPrompt("");
|
||
setDirty(false);
|
||
} catch (err) {
|
||
setError(toUserMessageText(err, "generateError"));
|
||
setCanvasActionGenerating(false);
|
||
setMoveRecognitionLoading(false);
|
||
}
|
||
};
|
||
|
||
const syncProjectWithLocalCanvas = (nextProject: Project, nextNodes = nodesRef.current) => {
|
||
const syncedProject = {
|
||
...nextProject,
|
||
viewport: viewportRef.current,
|
||
nodes: nextNodes
|
||
};
|
||
projectRef.current = syncedProject;
|
||
setProject(syncedProject);
|
||
return syncedProject;
|
||
};
|
||
|
||
const prepareMockupPlacement = () => {
|
||
const currentProject = projectRef.current ?? project;
|
||
if (!currentProject || !selectedImageNode || selectedImageNodeBusy || !isRealCanvasImageNode(selectedImageNode)) return;
|
||
const targetId = selectedImageNode.id;
|
||
const requestId = mockupPrepareRequestRef.current + 1;
|
||
mockupPrepareRequestRef.current = requestId;
|
||
if (mockupPrepareTimerRef.current) clearTimeout(mockupPrepareTimerRef.current);
|
||
setMockupDropActive(false);
|
||
setMockupDragNodeId(null);
|
||
setMockupReadyTargetId(null);
|
||
setMockupPlacement(null);
|
||
setMockupSurface(null);
|
||
setMockupModel(null);
|
||
setMockupPendingTargetId(targetId);
|
||
setImageActionMode(null);
|
||
let closeMockupEvents: (() => void) | null = null;
|
||
let settled = false;
|
||
const minimumPrepareTime = new Promise<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) || t("generateError"));
|
||
return;
|
||
}
|
||
if (task.data.status === "completed") {
|
||
const { project: nextProject } = await designGateway.getProject(response.project.id);
|
||
if (closed || mockupPrepareRequestRef.current !== requestId) return;
|
||
const ready = await finishFromProject(nextProject);
|
||
if (ready) return;
|
||
failPreparation(projectFailureMessage(nextProject, t) || t("generateError"));
|
||
return;
|
||
}
|
||
timer = setTimeout(poll, generatorTaskPollIntervalMs);
|
||
} catch (err) {
|
||
if (!closed) failPreparation(toUserMessageText(err, "generateError"));
|
||
}
|
||
};
|
||
void poll();
|
||
} catch (err) {
|
||
if (mockupPrepareRequestRef.current !== requestId) return;
|
||
failPreparation(toUserMessageText(err, "generateError"));
|
||
}
|
||
})();
|
||
};
|
||
|
||
const applyMockupPrint = async (sourceNode: CanvasNode, targetNode: CanvasNode, placement: MockupPlacement, surface: MockupSurface, model: MockupModel, dropPoint?: { x: number; y: number }) => {
|
||
if (!project || !isRealCanvasImageNode(sourceNode)) return;
|
||
const printPlacement = mockupPrintPlacementForDrop(sourceNode, targetNode, placement, dropPoint);
|
||
const fit = mockupPlacementWorldRect(targetNode, printPlacement);
|
||
let content = sourceNode.content;
|
||
try {
|
||
content = await renderMockupPrintTexture(sourceNode.content, targetNode.content, printPlacement, fit, model);
|
||
} catch (err) {
|
||
console.warn("Failed to render mockup texture preview", err);
|
||
}
|
||
const printNode: CanvasNode = {
|
||
id: cryptoFallbackId(),
|
||
type: "image",
|
||
title: t("mockupPrintTitle"),
|
||
x: fit.x,
|
||
y: fit.y,
|
||
width: fit.width,
|
||
height: fit.height,
|
||
content,
|
||
tone: "mockup-print",
|
||
status: "success",
|
||
layerRole: "mockup-print",
|
||
opacity: 0.92,
|
||
mockupSurface: surface,
|
||
mockupModel: model,
|
||
mockupSourceContent: sourceNode.mockupSourceContent || sourceNode.content
|
||
};
|
||
const targetIndex = nodesRef.current.findIndex((node) => node.id === targetNode.id);
|
||
const nextNodes = [...nodesRef.current];
|
||
nextNodes.splice(targetIndex >= 0 ? targetIndex + 1 : nextNodes.length, 0, printNode);
|
||
nodesRef.current = nextNodes;
|
||
setNodes(nextNodes);
|
||
selectOnlyNode(printNode.id);
|
||
setImageActionMode(null);
|
||
setMockupDropActive(false);
|
||
setMockupReadyTargetId(null);
|
||
setMockupPlacement(null);
|
||
setMockupSurface(null);
|
||
setMockupModel(null);
|
||
setDirty(true);
|
||
scheduleCanvasSave();
|
||
};
|
||
|
||
const refreshMockupPrintTextures = async (nodeIds: string[]) => {
|
||
const nextNodes = [...nodesRef.current];
|
||
let changed = false;
|
||
for (const nodeId of nodeIds) {
|
||
const index = nextNodes.findIndex((node) => node.id === nodeId);
|
||
const printNode = nextNodes[index];
|
||
const targetId = printNode?.mockupSurface?.targetId;
|
||
const model = printNode?.mockupModel;
|
||
if (!printNode || printNode.layerRole !== "mockup-print" || !printNode.mockupSourceContent || !targetId || !model) continue;
|
||
const targetNode = nextNodes.find((node) => node.id === targetId);
|
||
if (!targetNode || !isRealCanvasImageNode(targetNode)) continue;
|
||
try {
|
||
const placement = mockupPlacementFromPrintNode(printNode, targetNode);
|
||
const content = await renderMockupPrintTexture(printNode.mockupSourceContent, targetNode.content, placement, { width: printNode.width, height: printNode.height }, model);
|
||
nextNodes[index] = { ...printNode, content };
|
||
changed = true;
|
||
} catch (err) {
|
||
console.warn("Failed to refresh mockup texture preview", err);
|
||
}
|
||
}
|
||
if (!changed) {
|
||
scheduleCanvasSave();
|
||
return;
|
||
}
|
||
nodesRef.current = nextNodes;
|
||
setNodes(nextNodes);
|
||
setDirty(true);
|
||
scheduleCanvasSave();
|
||
};
|
||
|
||
const restoreQuickEditViewport = useCallback(() => {
|
||
const focus = quickEditFocusRef.current;
|
||
if (focus) {
|
||
viewportRef.current = focus.viewport;
|
||
setViewport(focus.viewport);
|
||
setDirty(focus.dirty);
|
||
quickEditFocusRef.current = null;
|
||
}
|
||
setImageActionMode(null);
|
||
}, [setViewport, viewportRef]);
|
||
|
||
useEffect(() => {
|
||
if (imageActionMode !== "quick-edit") return;
|
||
const handlePointerDown = (event: PointerEvent) => {
|
||
const target = event.target as Element | null;
|
||
if (target?.closest(".quick-edit-panel")) return;
|
||
restoreQuickEditViewport();
|
||
};
|
||
document.addEventListener("pointerdown", handlePointerDown, true);
|
||
return () => document.removeEventListener("pointerdown", handlePointerDown, true);
|
||
}, [imageActionMode, restoreQuickEditViewport]);
|
||
|
||
const applyQuickEdit = () => {
|
||
if (!quickEditPrompt.trim()) return;
|
||
restoreQuickEditViewport();
|
||
void runImageNodeAction("quick-edit", { prompt: quickEditPrompt.trim() });
|
||
};
|
||
|
||
const applyVisualAnnotation = async () => {
|
||
const targetNode = selectedImageNode;
|
||
if (!targetNode || visualAnnotationSubmitting || visualAnnotationMarks.length === 0 || visualAnnotationMarks.some((mark) => !mark.instruction.trim())) return;
|
||
setVisualAnnotationSubmitting(true);
|
||
setError("");
|
||
try {
|
||
const source = await imageNodeRasterBlob(targetNode, "image/png");
|
||
const guide = await renderVisualAnnotationGuide(source, visualAnnotationMarks);
|
||
const guideFile = new File([guide], `visual-annotation-${Date.now()}.png`, { type: "image/png" });
|
||
const annotationImage = await designGateway.uploadImage(guideFile);
|
||
const instructions = visualAnnotationMarks
|
||
.map((mark) => `${mark.index}. ${visualAnnotationToolLabel(mark.tool, t)}: ${mark.instruction.trim()}`)
|
||
.join("\n");
|
||
await runImageNodeAction("visual-annotation", {
|
||
prompt: t("visualAnnotationPrompt", { instructions }),
|
||
annotationImage,
|
||
selection: JSON.stringify(visualAnnotationPayload(visualAnnotationMarks))
|
||
});
|
||
} catch (err) {
|
||
setError(toUserMessageText(err, "generateError"));
|
||
} finally {
|
||
setVisualAnnotationSubmitting(false);
|
||
}
|
||
};
|
||
|
||
const openQuickEditPanel = () => {
|
||
if (!selectedImageNode) return;
|
||
const stageWidth = canvasStageSize.width;
|
||
const stageHeight = canvasStageSize.height;
|
||
if (!quickEditFocusRef.current) {
|
||
quickEditFocusRef.current = {
|
||
viewport: { ...viewportRef.current },
|
||
dirty
|
||
};
|
||
}
|
||
if (stageWidth > 0 && stageHeight > 0) {
|
||
const topMargin = 52;
|
||
const sideMargin = 56;
|
||
const footerReserve = 188;
|
||
const availableWidth = Math.max(320, stageWidth - sideMargin * 2);
|
||
const availableHeight = Math.max(240, stageHeight - topMargin - footerReserve);
|
||
const scale = clamp(Math.min(availableWidth / Math.max(selectedImageNode.width, 1), availableHeight / Math.max(selectedImageNode.height, 1)), minCanvasZoom, maxCanvasZoom);
|
||
const nextViewport = {
|
||
x: stageWidth / 2 - (selectedImageNode.x + selectedImageNode.width / 2) * scale,
|
||
y: topMargin - selectedImageNode.y * scale,
|
||
k: scale
|
||
};
|
||
viewportRef.current = nextViewport;
|
||
setViewport(nextViewport);
|
||
}
|
||
setMode("select");
|
||
setImageActionMode("quick-edit");
|
||
};
|
||
|
||
const applyEraserEdit = () => {
|
||
if (eraserMarks.length === 0) return;
|
||
void runImageNodeAction("eraser", {
|
||
prompt: t("eraserDefaultPrompt"),
|
||
mask: JSON.stringify({ brushSize: eraserBrushSize, brushColor: eraserBrushColor, brushOpacity: eraserBrushOpacity, points: eraserMarks })
|
||
});
|
||
};
|
||
|
||
const openTextEditPanel = async () => {
|
||
if (!project || !selectedImageNode || isImageActionBusy(selectedImageNode)) return;
|
||
const targetNodeId = selectedImageNode.id;
|
||
textExtractionEventsCloseRef.current?.();
|
||
textExtractionEventsCloseRef.current = null;
|
||
setImageActionMode(null);
|
||
setTextEditItems([]);
|
||
setTextEditLoading(true);
|
||
setTextEditError("");
|
||
setCanvasActionGenerating(true);
|
||
try {
|
||
const response = await designGateway.extractNodeTextAsync(project.id, targetNodeId, "edit-text");
|
||
applyProject(response.project, { preserveMessages: true });
|
||
const openPanelWithResult = (result: ExtractNodeTextResponse) => {
|
||
setTextEditItems(result.items.map(toEditableTextLayer));
|
||
setTextEditError("");
|
||
setImageActionMode("edit-text");
|
||
setTextEditLoading(false);
|
||
};
|
||
let closed = false;
|
||
let timer: ReturnType<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) || t("editTextOcrFailed"));
|
||
textExtractionEventsCloseRef.current = null;
|
||
return;
|
||
}
|
||
timer = setTimeout(poll, generatorTaskPollIntervalMs);
|
||
} catch (nextError) {
|
||
if (closed) return;
|
||
setTextEditLoading(false);
|
||
setCanvasActionGenerating(false);
|
||
setError(toUserMessageText(nextError, "editTextOcrFailed"));
|
||
textExtractionEventsCloseRef.current = null;
|
||
}
|
||
};
|
||
void poll();
|
||
} catch (err) {
|
||
setError(toUserMessageText(err, "editTextOcrFailed"));
|
||
setTextEditLoading(false);
|
||
setCanvasActionGenerating(false);
|
||
}
|
||
};
|
||
|
||
const updateTextEditItem = (id: string, text: string) => {
|
||
setTextEditItems((current) => current.map((item) => (item.id === id ? { ...item, text, removed: false } : item)));
|
||
};
|
||
|
||
const removeTextEditItem = (id: string) => {
|
||
setTextEditItems((current) => current.map((item) => (item.id === id ? { ...item, text: "", removed: true } : item)));
|
||
};
|
||
|
||
const cancelTextEdit = () => {
|
||
textExtractionEventsCloseRef.current?.();
|
||
textExtractionEventsCloseRef.current = null;
|
||
setImageActionMode(null);
|
||
setTextEditItems([]);
|
||
setTextEditLoading(false);
|
||
setTextEditError("");
|
||
};
|
||
|
||
const applyTextEdit = () => {
|
||
if (textEditItems.length === 0 || textEditLoading) return;
|
||
void runImageNodeAction("edit-text", {
|
||
prompt: buildTextEditPrompt(textEditItems, t("editTextPrompt")),
|
||
options: {
|
||
textLayers: JSON.stringify(textEditItems.map(toTextLayerPayload))
|
||
}
|
||
});
|
||
};
|
||
|
||
const applyMoveObject = () => {
|
||
if (!moveSelection?.dstBox) return;
|
||
const objectLabel = moveRecognitionLabel.trim();
|
||
void runImageNodeAction("move-object", {
|
||
prompt: movePrompt.trim() || t("moveDefaultPrompt"),
|
||
selection: JSON.stringify({
|
||
src_box: moveSelection.srcBox,
|
||
dst_box: moveSelection.dstBox
|
||
}),
|
||
options: objectLabel ? { object_label: objectLabel } : undefined
|
||
});
|
||
};
|
||
|
||
const applyMultiAngle = () => {
|
||
void runImageNodeAction("multi-angle", {
|
||
prompt: multiAngleOptions.prompt.trim() || t("multiAngleDefaultPrompt"),
|
||
options: {
|
||
tab: multiAngleOptions.tab,
|
||
rotation: String(multiAngleOptions.yaw),
|
||
tilt: String(multiAngleOptions.pitch),
|
||
zoom: String(multiAngleOptions.zoom)
|
||
}
|
||
});
|
||
};
|
||
|
||
const updateSelectedImageAdjustments = (nextAdjustments: AdjustOptions) => {
|
||
setAdjustOptions(nextAdjustments);
|
||
const targetId = selectedImageNode?.id;
|
||
if (!targetId) return;
|
||
const imageAdjustments = serializeImageAdjustments(nextAdjustments) || undefined;
|
||
const applyPatch = (node: CanvasNode) => (node.id === targetId ? { ...node, imageAdjustments } : node);
|
||
const nextNodes = nodesRef.current.map(applyPatch);
|
||
nodesRef.current = nextNodes;
|
||
setNodes((current) => current.map(applyPatch));
|
||
setDirty(true);
|
||
};
|
||
|
||
const openAdjustPanel = () => {
|
||
setAdjustOptions(parseImageAdjustments(selectedImageNode?.imageAdjustments));
|
||
setImageActionMode("adjust");
|
||
};
|
||
|
||
const autoAdjustSelectedImage = () => {
|
||
updateSelectedImageAdjustments(autoImageAdjustments(adjustOptions));
|
||
};
|
||
|
||
const openExpandPanel = () => {
|
||
setExpandOptions((current) => ({ ...current, imageRect: undefined }));
|
||
setImageActionMode("expand");
|
||
};
|
||
|
||
const applyExpandImage = () => {
|
||
if (!selectedImageNode) return;
|
||
const target = expandTargetGeometryForNode(selectedImageNode, expandOptions);
|
||
void runImageNodeAction("expand", {
|
||
prompt: expandOptions.prompt.trim() || t("expandPrompt"),
|
||
imageSize: expandImageSize(expandOptions.ratio, target.width / Math.max(target.height, 1)),
|
||
selection: JSON.stringify({
|
||
target: { x: target.x, y: target.y, width: target.width, height: target.height },
|
||
source: target.imageRect
|
||
}),
|
||
options: {
|
||
ratio: expandOptions.ratio,
|
||
scale: `${expandOptions.scale}x`,
|
||
targetX: String(target.x),
|
||
targetY: String(target.y),
|
||
targetWidth: String(target.width),
|
||
targetHeight: String(target.height),
|
||
sourceRect: JSON.stringify(target.imageRect)
|
||
}
|
||
});
|
||
};
|
||
|
||
const openCropForNode = (targetNode: CanvasNode) => {
|
||
if (isImageActionBusy(targetNode)) return;
|
||
chooseCanvasTool("select");
|
||
const selection = visibleCropSelectionForBounds(canvasNodeScreenBounds(targetNode, viewportRef.current), canvasStageSize);
|
||
if (selectedId !== targetNode.id || selectedIds.size !== 1) {
|
||
pendingWorkspaceCropRef.current = { nodeId: targetNode.id, selection };
|
||
selectOnlyNode(targetNode.id);
|
||
return;
|
||
}
|
||
setCropSelection(selection);
|
||
setImageActionMode("crop");
|
||
};
|
||
|
||
const openWorkspaceCrop = () => {
|
||
if (!workspaceCropTarget || workspaceCropTargetBusy) return;
|
||
if (activeTool === "crop" && workspaceCropTargetId === workspaceCropTarget.id) {
|
||
chooseCanvasTool("select");
|
||
return;
|
||
}
|
||
setImageActionMode(null);
|
||
setCropSelection(null);
|
||
setCropPrompt("");
|
||
setWorkspaceCropTargetId(workspaceCropTarget.id);
|
||
chooseCanvasTool("crop");
|
||
if (selectedId !== workspaceCropTarget.id || selectedIds.size !== 1) selectOnlyNode(workspaceCropTarget.id);
|
||
};
|
||
|
||
const applyCropImage = async () => {
|
||
const targetNode = selectedImageNode;
|
||
const currentProject = projectRef.current ?? project;
|
||
if (!targetNode || !currentProject) return;
|
||
const selection = cropSelection ?? defaultCropSelection;
|
||
if (selection.width < 0.02 || selection.height < 0.02) return;
|
||
setSaving(true);
|
||
setError("");
|
||
try {
|
||
const cropped = await cropImageNodeFile(targetNode, selection);
|
||
const publicUrl = await designGateway.uploadImage(cropped.file);
|
||
const preserveSource = isVectorImageNode(targetNode);
|
||
const croppedNode: CanvasNode | null = preserveSource
|
||
? createVectorCropSiblingNode(targetNode, {
|
||
id: cryptoFallbackId(),
|
||
title: t("cropResultTitle", { title: targetNode.title || t("imageNode") }),
|
||
publicUrl,
|
||
cropWidth: cropped.displayWidth,
|
||
cropHeight: cropped.displayHeight,
|
||
semanticContext: cropped.semanticContext
|
||
})
|
||
: null;
|
||
const nextNodes = croppedNode
|
||
? [...nodesRef.current, croppedNode]
|
||
: nodesRef.current.map((node) =>
|
||
node.id === targetNode.id
|
||
? {
|
||
...node,
|
||
x: targetNode.x + targetNode.width * selection.x,
|
||
y: targetNode.y + targetNode.height * selection.y,
|
||
width: cropped.displayWidth,
|
||
height: cropped.displayHeight,
|
||
content: publicUrl,
|
||
status: "success"
|
||
}
|
||
: node
|
||
);
|
||
nodesRef.current = nextNodes;
|
||
setNodes(nextNodes);
|
||
setImageActionMode(null);
|
||
setCropSelection(null);
|
||
setCropPrompt("");
|
||
setDirty(true);
|
||
const { project: savedProject } = await designGateway.saveCanvas(currentProject.id, viewportRef.current, nextNodes, [], currentProject);
|
||
applyProject(savedProject);
|
||
if (croppedNode) {
|
||
selectOnlyNode(croppedNode.id);
|
||
showCanvasToast(t("cropCreated"));
|
||
}
|
||
setDirty(false);
|
||
} catch (err) {
|
||
setError(toUserMessageText(err, "generateError"));
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
};
|
||
|
||
const attachImage = async (file: File | null) => {
|
||
if (!file) return;
|
||
const localPreviewUrl = URL.createObjectURL(file);
|
||
try {
|
||
const publicUrl = await designGateway.uploadImage(file);
|
||
const previewUrl = publicUrl || localPreviewUrl;
|
||
if (previewUrl !== localPreviewUrl) URL.revokeObjectURL(localPreviewUrl);
|
||
const reference = {
|
||
id: `${Date.now()}-${file.name}`,
|
||
name: file.name,
|
||
publicUrl,
|
||
previewUrl
|
||
};
|
||
if (agentComposerRef.current) {
|
||
agentComposerRef.current.insertReference(reference);
|
||
} else {
|
||
syncReferenceImages([...referenceImagesRef.current, reference]);
|
||
}
|
||
} catch (err) {
|
||
URL.revokeObjectURL(localPreviewUrl);
|
||
setError(toUserMessageText(err, "generateError"));
|
||
}
|
||
};
|
||
|
||
const uploadReferenceToImageGenerator = async (file: File | null) => {
|
||
if (!file) return null;
|
||
const localPreviewUrl = URL.createObjectURL(file);
|
||
try {
|
||
const publicUrl = await designGateway.uploadImage(file);
|
||
const previewUrl = publicUrl || localPreviewUrl;
|
||
if (previewUrl !== localPreviewUrl) URL.revokeObjectURL(localPreviewUrl);
|
||
const reference = {
|
||
id: `${Date.now()}-${file.name}`,
|
||
name: file.name,
|
||
publicUrl,
|
||
previewUrl
|
||
};
|
||
return reference;
|
||
} catch (err) {
|
||
URL.revokeObjectURL(localPreviewUrl);
|
||
setError(toUserMessageText(err, "generateError"));
|
||
return null;
|
||
}
|
||
};
|
||
|
||
const canvasCenterPoint = () => {
|
||
const rect = containerRef.current?.getBoundingClientRect();
|
||
const currentViewport = viewportRef.current;
|
||
return {
|
||
x: ((rect?.width ?? 1100) / 2 - currentViewport.x) / currentViewport.k,
|
||
y: ((rect?.height ?? 760) / 2 - currentViewport.y) / currentViewport.k
|
||
};
|
||
};
|
||
|
||
const uploadImagesToCanvas = async (files: File[], anchor = canvasCenterPoint()) => {
|
||
const currentProject = projectRef.current;
|
||
const importFiles = files.filter(isCanvasImportFile);
|
||
if (!currentProject || importFiles.length === 0) return;
|
||
|
||
setSaving(true);
|
||
setError("");
|
||
const preparedUploads: Array<{ asset: PreparedCanvasImportAsset; sourcePreviewUrl: string; renderPreviewUrl?: string; node: CanvasNode }> = [];
|
||
try {
|
||
const imageAssets: PreparedCanvasImportAsset[] = [];
|
||
let cadImportCount = 0;
|
||
let cadImportError: unknown = null;
|
||
if (importFiles.some(isCadFile)) showCanvasToast(t("cadImportConverting"));
|
||
for (const file of importFiles) {
|
||
try {
|
||
imageAssets.push(await prepareCanvasImportAsset(file));
|
||
if (isCadFile(file)) cadImportCount += 1;
|
||
} catch (error) {
|
||
cadImportError ??= error;
|
||
}
|
||
}
|
||
if (cadImportError) {
|
||
const message = t(cadImportMessageKey(cadImportError));
|
||
setError(message);
|
||
showCanvasToast(message, "error");
|
||
}
|
||
if (imageAssets.length === 0) return;
|
||
|
||
for (const [index, asset] of imageAssets.entries()) {
|
||
const { file, renderFile, semanticContext } = asset;
|
||
const sourcePreviewUrl = URL.createObjectURL(file);
|
||
const renderPreviewUrl = renderFile ? URL.createObjectURL(renderFile) : undefined;
|
||
const dimensions = await readImageDimensions(file).catch(() => ({ width: 512, height: 512 }));
|
||
const size = originalCanvasImageSize(dimensions.width, dimensions.height);
|
||
const tone = imageMaySupportTransparency(file) ? "transparent-image" : "natural";
|
||
preparedUploads.push({
|
||
asset,
|
||
sourcePreviewUrl,
|
||
renderPreviewUrl,
|
||
node: {
|
||
id: cryptoFallbackId(),
|
||
type: "image",
|
||
title: file.name || t("newImageNode"),
|
||
x: anchor.x - size.width / 2 + index * 36,
|
||
y: anchor.y - size.height / 2 + index * 36,
|
||
width: size.width,
|
||
height: size.height,
|
||
content: sourcePreviewUrl,
|
||
renderContent: renderPreviewUrl,
|
||
tone,
|
||
layerRole: semanticContext ? "cad-vector" : isSvgSource(file.name) || file.type.toLowerCase().includes("svg") ? "vector-image" : undefined,
|
||
semanticContext,
|
||
status: "uploading"
|
||
}
|
||
});
|
||
}
|
||
|
||
const optimisticNodes = preparedUploads.map((item) => item.node);
|
||
const nextNodesWithUploads = attachFrameChildren([...nodesRef.current, ...optimisticNodes], new Set(optimisticNodes.map((node) => node.id)));
|
||
nodesRef.current = nextNodesWithUploads;
|
||
setNodes(nextNodesWithUploads);
|
||
selectNodes(optimisticNodes.map((node) => node.id));
|
||
setDirty(true);
|
||
|
||
const uploadedResults = await Promise.allSettled(
|
||
preparedUploads.map(async ({ asset, node }) => ({
|
||
id: node.id,
|
||
uploaded: await uploadPreparedCanvasImportAsset(asset, designGateway.uploadImage, designGateway.deleteAsset)
|
||
}))
|
||
);
|
||
const uploadedById = new Map<string, { content: string; renderContent?: 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.uploaded);
|
||
else failedIds.add(nodeId);
|
||
});
|
||
|
||
const finalUploadedNodeIds = new Set(uploadedById.keys());
|
||
const finalNodes = nodesRef.current
|
||
.filter((node) => !failedIds.has(node.id))
|
||
.map((node) => {
|
||
const uploaded = uploadedById.get(node.id);
|
||
return uploaded ? { ...node, content: uploaded.content, renderContent: uploaded.renderContent, status: "success" } : node;
|
||
});
|
||
nodesRef.current = finalNodes;
|
||
setNodes(finalNodes);
|
||
selectNodes(finalNodes.filter((node) => finalUploadedNodeIds.has(node.id)).map((node) => node.id));
|
||
preparedUploads.forEach(revokePreparedUploadUrls);
|
||
|
||
if (failedIds.size > 0) {
|
||
setError(t("generateError"));
|
||
}
|
||
|
||
const { project: savedProject } = await designGateway.saveCanvas(currentProject.id, viewportRef.current, finalNodes, [], currentProject);
|
||
applyProject(savedProject);
|
||
setDirty(false);
|
||
if (failedIds.size === 0 && finalUploadedNodeIds.size > 0) {
|
||
showCanvasToast(cadImportCount > 0 ? t("cadImportSuccess", { count: cadImportCount }) : t("imageUploadSuccess"));
|
||
}
|
||
} catch (err) {
|
||
preparedUploads.forEach(revokePreparedUploadUrls);
|
||
const failedUploadIds = new Set(preparedUploads.map((item) => item.node.id));
|
||
if (failedUploadIds.size > 0) {
|
||
const nextNodes = nodesRef.current.filter((node) => !failedUploadIds.has(node.id));
|
||
nodesRef.current = nextNodes;
|
||
setNodes(nextNodes);
|
||
}
|
||
setError(toUserMessageText(err, "generateError"));
|
||
} finally {
|
||
setSaving(false);
|
||
setCanvasFileDragging(false);
|
||
}
|
||
};
|
||
|
||
const handleCanvasAssetInput = (event: React.ChangeEvent<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 (!dataTransferHasCanvasFile(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 = dataTransferCanvasFiles(event.dataTransfer);
|
||
if (files.length === 0) return;
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
setCanvasFileDragging(false);
|
||
void uploadImagesToCanvas(files, canvasPointFromClient(event.clientX, event.clientY));
|
||
};
|
||
|
||
if (!project) {
|
||
return (
|
||
<div className="workspace-loading">
|
||
<Loader2 className="spin" size={28} />
|
||
<span>{error || t("loadingCanvas")}</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const displayProjectTitle = localizedCanvasTitle(project.title, t("canvasUntitled"));
|
||
|
||
return (
|
||
<div className={`workspace-shell ${assistantPanelOpen && canViewChat ? "" : "assistant-panel-collapsed"} ${canEdit ? "" : "is-readonly"} ${canViewChat ? "" : "is-canvas-only"}`}>
|
||
<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" : ""} ${activeTool === "crop" ? "is-crop-tool" : ""} ${workspaceCropDetecting ? "is-crop-detecting" : ""}`}
|
||
style={{ "--canvas-background": canvasBackgroundColor } as CSSProperties}
|
||
ref={containerRef}
|
||
onPointerDown={canEdit ? handleBackgroundPointerDown : undefined}
|
||
onPointerMove={handleCanvasPointerMove}
|
||
onDragOver={canEdit ? handleCanvasDragOver : undefined}
|
||
onDragLeave={canEdit ? handleCanvasDragLeave : undefined}
|
||
onDrop={canEdit ? handleCanvasDrop : undefined}
|
||
>
|
||
<input ref={canvasAssetInputRef} type="file" accept="image/*,.dwg,.dxf" multiple hidden disabled={!canEdit} onChange={handleCanvasAssetInput} />
|
||
<div className={`workspace-stage-title ${generatedFilesPanelOpen || layersPanelOpen ? "is-offset-by-canvas-panel" : ""}`}>
|
||
<WorkspaceTitle
|
||
title={displayProjectTitle}
|
||
untitledLabel={t("canvasUntitled")}
|
||
renameLabel={t("renameCanvasTitle")}
|
||
placeholder={t("projectTitlePlaceholder")}
|
||
backHomeLabel={t("backHome")}
|
||
copyLabel={t("copyProject")}
|
||
moreLabel={t("more")}
|
||
disabled={!canEdit}
|
||
onBackHome={openHome}
|
||
onRename={updateProjectTitle}
|
||
/>
|
||
{canManageBrandKit && (
|
||
<BrandKitSelector
|
||
kits={brandKits}
|
||
selectedId={project.brandKitId ?? null}
|
||
loading={brandKitsLoading || brandKitUpdating}
|
||
disabled={!canEdit || brandKitUpdating || anyGenerating}
|
||
onSelect={selectProjectBrandKit}
|
||
onCreate={openBrandKit}
|
||
/>
|
||
)}
|
||
</div>
|
||
{canViewChat ? (
|
||
assistantPanelOpen ? (
|
||
<CanvasAccountControls credits={credits} />
|
||
) : (
|
||
<CanvasAccountControls credits={credits} collapsed onOpenConversation={() => setAssistantPanelOpen(true)} />
|
||
)
|
||
) : (
|
||
<CanvasAccountControls credits={credits} />
|
||
)}
|
||
{!canViewChat && (
|
||
<div className="canvas-share-control">
|
||
<SharePopover projectId={project.id} projectTitle={project.title} canManage={canManageSharing} onToast={showCanvasToast} />
|
||
</div>
|
||
)}
|
||
{layersPanelOpen && (
|
||
<CanvasLayerPanel
|
||
nodes={nodes}
|
||
selectedIds={selectedIds}
|
||
hiddenNodeIds={hiddenNodeIds}
|
||
lockedNodeIds={lockedNodeIds}
|
||
onSelect={selectOnlyNode}
|
||
onSelectMany={selectNodes}
|
||
onRenameNode={renameNodeTitle}
|
||
onToggleHidden={toggleLayerHidden}
|
||
onToggleLocked={toggleLayerLocked}
|
||
onToggleHiddenMany={toggleLayersHidden}
|
||
onToggleLockedMany={toggleLayersLocked}
|
||
onReorderLayer={reorderLayer}
|
||
onClose={() => setLayersPanelOpen(false)}
|
||
/>
|
||
)}
|
||
{generatedFilesPanelOpen && (
|
||
<CanvasGeneratedFilesPanel
|
||
nodes={nodes}
|
||
messages={project?.messages ?? []}
|
||
selectedIds={selectedIds}
|
||
onSelect={selectOnlyNode}
|
||
onDownload={(node) => exportImageNode(node, isVectorImageNode(node) ? "svg" : "png")}
|
||
onClose={() => setGeneratedFilesPanelOpen(false)}
|
||
/>
|
||
)}
|
||
{showGrid && <CanvasGrid viewport={viewport} />}
|
||
<div className="leafer-canvas-layer" ref={leaferMountRef} aria-hidden="true" />
|
||
<div ref={canvasWorldRef} 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 = canEdit && !locked;
|
||
const suppressSelectionFrame = ((imageActionMode === "expand" || imageActionMode === "crop") && selectedImageNode?.id === node.id) || (activeTool === "crop" && workspaceCropTargetId === node.id);
|
||
const imageGeneratorTargetSize =
|
||
node.type === "image" && node.layerRole === "image-generator"
|
||
? node.id === selectedImageGeneratorNode?.id
|
||
? imageGeneratorTargetSizeFromSettings(composerImageSettings)
|
||
: imageGeneratorTargetSizeFromNode(node)
|
||
: undefined;
|
||
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}
|
||
imageGeneratorTargetSize={imageGeneratorTargetSize}
|
||
hasClipboard
|
||
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} />
|
||
{workspaceCropDraftBounds && (
|
||
<div className="workspace-crop-draft" style={workspaceCropDraftBounds} aria-hidden="true">
|
||
<span className="workspace-crop-rule vertical first" />
|
||
<span className="workspace-crop-rule vertical second" />
|
||
<span className="workspace-crop-rule horizontal first" />
|
||
<span className="workspace-crop-rule horizontal second" />
|
||
</div>
|
||
)}
|
||
{selectedImageGeneratorBounds && selectedImageGeneratorNode?.status === "draft" && (
|
||
<ImageGeneratorFloatingComposer
|
||
bounds={selectedImageGeneratorBounds}
|
||
stageWidth={canvasStageSize.width}
|
||
stageHeight={canvasStageSize.height}
|
||
models={imageModels}
|
||
selectedModel={selectedCanvasModel}
|
||
settings={composerImageSettings}
|
||
generating={selectedImageGeneratorBusy}
|
||
lockedPrompt=""
|
||
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} />}
|
||
<div ref={imperativeAlignmentGuideRef} className="canvas-imperative-alignment-guides" aria-hidden="true" />
|
||
{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 && (
|
||
<>
|
||
{isVectorImageNode(selectedImageNode) ? (
|
||
<>
|
||
{imageActionMode !== "crop" && activeTool !== "crop" && (
|
||
<VectorizedImageToolbar
|
||
bounds={selectedNodeBounds}
|
||
leftInset={floatingToolbarLeftInset}
|
||
onCrop={() => openCropForNode(selectedImageNode)}
|
||
onDownload={() => exportImageNode(selectedImageNode, "svg")}
|
||
/>
|
||
)}
|
||
{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}
|
||
/>
|
||
)}
|
||
</>
|
||
) : (
|
||
<>
|
||
{activeTool !== "crop" && imageActionMode !== "quick-edit" && imageActionMode !== "visual-annotation" && 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}
|
||
onVisualAnnotation={() => {
|
||
setVisualAnnotationMarks([]);
|
||
setImageActionMode("visual-annotation");
|
||
}}
|
||
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={() => openCropForNode(selectedImageNode)}
|
||
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 === "visual-annotation" && (
|
||
<VisualAnnotationPanel
|
||
bounds={selectedNodeBounds}
|
||
stageWidth={canvasStageSize.width}
|
||
stageHeight={canvasStageSize.height}
|
||
marks={visualAnnotationMarks}
|
||
models={imageModels}
|
||
selectedModel={selectedCanvasModel}
|
||
disabled={selectedImageNodeBusy || visualAnnotationSubmitting}
|
||
submitting={visualAnnotationSubmitting}
|
||
onMarksChange={setVisualAnnotationMarks}
|
||
onModelChange={setSelectedCanvasModelId}
|
||
onCancel={() => {
|
||
if (visualAnnotationSubmitting) return;
|
||
setImageActionMode(null);
|
||
setVisualAnnotationMarks([]);
|
||
}}
|
||
onApply={() => void applyVisualAnnotation()}
|
||
/>
|
||
)}
|
||
{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)}
|
||
/>
|
||
)}
|
||
|
||
{!canEdit && <div className="workspace-readonly-shield" aria-hidden="true" />}
|
||
|
||
<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={scheduleCanvasSave}
|
||
/>
|
||
)}
|
||
<div className={`canvas-statusbar ${canEdit ? "" : "is-disabled"}`} aria-disabled={!canEdit} inert={!canEdit}>
|
||
<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}
|
||
cropEnabled={Boolean(workspaceCropTarget && !workspaceCropTargetBusy)}
|
||
cropActive={imageActionMode === "crop" || activeTool === "crop"}
|
||
disabled={!canEdit}
|
||
offsetBy={generatedFilesPanelOpen ? "files" : layersPanelOpen ? "layers" : null}
|
||
onCanvasToolMenuOpenChange={setCanvasToolMenuOpen}
|
||
onShapeToolMenuOpenChange={setShapeToolMenuOpen}
|
||
onChooseTool={chooseCanvasTool}
|
||
onChooseShapeKind={setSelectedShapeKind}
|
||
onCrop={openWorkspaceCrop}
|
||
onToggleGrid={() => setShowGrid((value) => !value)}
|
||
onUploadAsset={() => canvasAssetInputRef.current?.click()}
|
||
/>
|
||
{canEdit && activeTool === "pen" && (
|
||
<PenToolControls
|
||
settings={penSettings}
|
||
offsetBy={generatedFilesPanelOpen ? "files" : layersPanelOpen ? "layers" : null}
|
||
onChange={setPenSettings}
|
||
/>
|
||
)}
|
||
</div>
|
||
|
||
{canViewChat && assistantPanelOpen && (
|
||
<aside className="assistant-panel">
|
||
<div className="assistant-header">
|
||
<div className="assistant-header-title">
|
||
<strong>{agentHeaderTitle(messages, project, displayProjectTitle)}</strong>
|
||
</div>
|
||
<div className="assistant-header-actions">
|
||
<button className="assistant-header-icon" type="button" aria-label={t("newChat")} title={t("newChat")} onClick={beginNewAgentConversation} disabled={!canEdit || !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">
|
||
{canEdit && !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"
|
||
disabled={!canEdit}
|
||
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>
|
||
<SharePopover projectId={project.id} projectTitle={project.title} canManage={canManageSharing} onToast={showCanvasToast} />
|
||
<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" ref={assistantFeedRef} onScroll={updateAssistantScrollButton}>
|
||
{agentHistoryLoading ? (
|
||
<div className="assistant-history-loading" role="status" aria-live="polite" data-testid="agent-history-loading">
|
||
<Loader2 className="spin" size={20} aria-hidden="true" />
|
||
<span>{t("loadingConversationHistory")}</span>
|
||
</div>
|
||
) : !hasCurrentConversation && !agentTimelineActive ? (
|
||
<CanvasAgentEmptyState config={agentStarterConfig} disabled={!canEdit} onSelectPrompt={selectAgentStarterPrompt} />
|
||
) : (
|
||
<CanvasAgentTimeline messages={messages} nodes={nodes} active={agentTimelineActive} />
|
||
)}
|
||
</div>
|
||
|
||
{showAssistantScrollButton && (
|
||
<div className="assistant-scroll-to-end" style={{ bottom: assistantFooterHeight > 0 ? assistantFooterHeight + 12 : 178 }}>
|
||
<button
|
||
type="button"
|
||
aria-label={t("scrollToLatest")}
|
||
title={t("scrollToLatest")}
|
||
onClick={() => scrollAssistantFeedToEnd("smooth")}
|
||
>
|
||
<ArrowDown size={18} />
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
<div className={`assistant-footer-dock ${canEdit ? "" : "is-disabled"}`} ref={assistantFooterRef} aria-disabled={!canEdit} inert={!canEdit}>
|
||
<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()}
|
||
/>
|
||
</div>
|
||
</aside>
|
||
)}
|
||
</main>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function CanvasNodeView({
|
||
node,
|
||
selected,
|
||
mockupDragging,
|
||
editing,
|
||
showResizeHandles,
|
||
hidden,
|
||
locked,
|
||
mockupClipPath,
|
||
transform,
|
||
selectionScale,
|
||
viewportScale,
|
||
imageGeneratorTargetSize,
|
||
hasClipboard,
|
||
contextMenuHandlers,
|
||
onPointerDown,
|
||
onContextMenu,
|
||
onTitleRename,
|
||
onResizePointerDown,
|
||
onShapeEndpointPointerDown,
|
||
onContentChange,
|
||
onTextEditFinish
|
||
}: {
|
||
node: CanvasNode;
|
||
selected: boolean;
|
||
mockupDragging: boolean;
|
||
editing: boolean;
|
||
showResizeHandles: boolean;
|
||
hidden: boolean;
|
||
locked: boolean;
|
||
mockupClipPath?: string;
|
||
transform: NodeTransform;
|
||
selectionScale: number;
|
||
viewportScale: number;
|
||
imageGeneratorTargetSize?: ImageGeneratorTargetSize;
|
||
hasClipboard: boolean;
|
||
contextMenuHandlers: NodeContextMenuHandlers | null;
|
||
onPointerDown: (event: React.PointerEvent<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 imageGenerator = node.type === "image" && node.layerRole === "image-generator";
|
||
const textOverlay = node.type === "text" && node.layerRole === "editable-text";
|
||
const textOverlayStyle = textOverlay ? editableTextLayerStyle(node) : undefined;
|
||
const textEditorRef = useRef<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 visibleSelectionSize = imageGenerator && imageGeneratorTargetSize ? imageGeneratorTargetSize : { width: node.width, height: node.height };
|
||
const selectionSize = `${Math.round(visibleSelectionSize.width)} × ${Math.round(visibleSelectionSize.height)}`;
|
||
const SelectionIcon = node.type === "frame" ? Frame : node.type === "text" ? Type : penStroke ? PenLine : ImageIcon;
|
||
|
||
useEffect(() => {
|
||
if (!editing || !textOverlay) return;
|
||
const editor = textEditorRef.current;
|
||
if (!editor) return;
|
||
editor.focus();
|
||
const cursor = editor.value.length;
|
||
editor.setSelectionRange(cursor, cursor);
|
||
}, [editing, node.id, textOverlay]);
|
||
|
||
const handleContextMenu = (event: React.MouseEvent<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" : ""}`}
|
||
data-canvas-node-id={node.id}
|
||
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} imageGeneratorTargetSize={imageGeneratorTargetSize} />
|
||
) : 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" : imageGenerator ? "image-generator" : penStroke ? "pen" : shapeNode || booleanShapeGroup ? "shape" : textOverlay ? "text" : undefined}
|
||
selectionScale={selectionScale}
|
||
transform={transform}
|
||
onResizePointerDown={onResizePointerDown}
|
||
onTitleRename={onTitleRename}
|
||
renameLabel={t("renameLayer")}
|
||
titlePlaceholder={t("layerTitlePlaceholder")}
|
||
/>
|
||
)}
|
||
{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 canvasNodeScreenBounds(node: CanvasNode, viewport: CanvasViewport): NodeScreenBounds {
|
||
return {
|
||
left: viewport.x + node.x * viewport.k,
|
||
top: viewport.y + node.y * viewport.k,
|
||
width: node.width * viewport.k,
|
||
height: node.height * viewport.k
|
||
};
|
||
}
|
||
|
||
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 vectorSource = isVectorImageNode(node);
|
||
const svgText = vectorSource ? await blob.text() : "";
|
||
const semanticContext = node.layerRole === "cad-vector" ? cadSemanticContextFromSvgText(svgText, rect) : node.semanticContext || "";
|
||
const vectorCrop = vectorSource ? createCroppedSvgRasterSource(svgText, rect) : null;
|
||
const imageUrl = URL.createObjectURL(vectorCrop?.source ?? blob);
|
||
try {
|
||
const image = await loadCanvasImage(imageUrl);
|
||
const naturalWidth = image.naturalWidth || image.width;
|
||
const naturalHeight = image.naturalHeight || image.height;
|
||
const sourceX = vectorCrop ? 0 : Math.round(rect.x * naturalWidth);
|
||
const sourceY = vectorCrop ? 0 : Math.round(rect.y * naturalHeight);
|
||
const sourceWidth = vectorCrop ? naturalWidth : Math.max(1, Math.round(rect.width * naturalWidth));
|
||
const sourceHeight = vectorCrop ? naturalHeight : Math.max(1, Math.round(rect.height * naturalHeight));
|
||
const outputSize = vectorCrop?.outputSize ?? { width: sourceWidth, height: sourceHeight };
|
||
const canvas = document.createElement("canvas");
|
||
canvas.width = outputSize.width;
|
||
canvas.height = outputSize.height;
|
||
const context = canvas.getContext("2d");
|
||
if (!context) throw new Error("Failed to create crop canvas");
|
||
context.clearRect(0, 0, outputSize.width, outputSize.height);
|
||
if (vectorCrop) context.drawImage(image, 0, 0, outputSize.width, outputSize.height);
|
||
else context.drawImage(image, sourceX, sourceY, sourceWidth, sourceHeight, 0, 0, outputSize.width, outputSize.height);
|
||
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),
|
||
semanticContext
|
||
};
|
||
} 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}`;
|
||
}
|
||
|
||
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 imageMaySupportTransparency(file: File) {
|
||
const type = file.type.toLowerCase();
|
||
const name = file.name.toLowerCase();
|
||
return (
|
||
type.includes("png") ||
|
||
type.includes("webp") ||
|
||
type.includes("avif") ||
|
||
type.includes("gif") ||
|
||
type.includes("svg") ||
|
||
name.endsWith(".png") ||
|
||
name.endsWith(".webp") ||
|
||
name.endsWith(".avif") ||
|
||
name.endsWith(".gif") ||
|
||
name.endsWith(".svg")
|
||
);
|
||
}
|
||
|
||
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 latestDataTransferImageFile(dataTransfer: DataTransfer, fallbackBaseName = "canvas-image") {
|
||
return dataTransferImageFiles(dataTransfer, fallbackBaseName).at(-1) ?? null;
|
||
}
|
||
|
||
async function latestNavigatorClipboardImageFile(fallbackBaseName = "clipboard-image") {
|
||
if (!navigator.clipboard?.read) return null;
|
||
const items = await navigator.clipboard.read();
|
||
for (let itemIndex = items.length - 1; itemIndex >= 0; itemIndex -= 1) {
|
||
const item = items[itemIndex];
|
||
const type = item.types.filter((itemType) => itemType.startsWith("image/")).at(-1);
|
||
if (!type) continue;
|
||
const blob = await item.getType(type);
|
||
return ensureImageFileName(new File([blob], "", { type: blob.type || type, lastModified: Date.now() }), `${fallbackBaseName}-${Date.now()}-${itemIndex + 1}`);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
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 dataTransferCanvasFiles(dataTransfer: DataTransfer, fallbackBaseName = "canvas-asset") {
|
||
const candidates = [
|
||
...Array.from(dataTransfer.files),
|
||
...Array.from(dataTransfer.items)
|
||
.filter((item) => item.kind === "file")
|
||
.map((item) => item.getAsFile())
|
||
.filter((file): file is File => Boolean(file))
|
||
];
|
||
const seen = new Set<string>();
|
||
return candidates.filter(isCanvasImportFile).reduce<File[]>((files, file, index) => {
|
||
const normalizedFile = isImageFile(file) ? ensureImageFileName(file, `${fallbackBaseName}-${Date.now()}-${index + 1}`) : file;
|
||
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 dataTransferHasCanvasFile(dataTransfer: DataTransfer) {
|
||
if (dataTransferCanvasFiles(dataTransfer).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 originalCanvasImageSize(width: number, height: number) {
|
||
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
|
||
return { width: 512, height: 512 };
|
||
}
|
||
return {
|
||
width: Math.round(width),
|
||
height: Math.round(height)
|
||
};
|
||
}
|
||
|
||
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 visiblePromptText(thread.text) || "新对话";
|
||
}
|
||
|
||
function latestAgentThreadId(project: Project | null, hiddenThreadIds: ReadonlySet<string>) {
|
||
return fallbackAgentThreads(project, hiddenThreadIds)
|
||
.sort((a, b) => b.updateTimestamp - a.updateTimestamp)[0]?.agentThreadId || "";
|
||
}
|
||
|
||
function preserveLocalDraftImageGenerators(nextProject: Project, currentNodes: CanvasNode[]) {
|
||
const nextNodeIds = new Set(nextProject.nodes.map((node) => node.id));
|
||
const localDraftGenerators = currentNodes.filter((node) => node.type === "image" && node.layerRole === "image-generator" && node.status === "draft" && !nextNodeIds.has(node.id));
|
||
if (localDraftGenerators.length === 0) return nextProject;
|
||
return {
|
||
...nextProject,
|
||
nodes: [...nextProject.nodes, ...localDraftGenerators]
|
||
};
|
||
}
|
||
|
||
function nextImageGeneratorPosition(origin: { x: number; y: number }, size: { width: number; height: number }, nodes: CanvasNode[], viewportScale: number) {
|
||
const base = {
|
||
x: origin.x - size.width / 2,
|
||
y: origin.y - size.height / 2
|
||
};
|
||
const generators = nodes.filter((node) => node.type === "image" && node.layerRole === "image-generator");
|
||
const gap = 24 / Math.max(viewportScale, 0.01);
|
||
const step = {
|
||
x: size.width + gap,
|
||
y: size.height + gap
|
||
};
|
||
const overlapsAnyGenerator = (candidate: { x: number; y: number }) =>
|
||
generators.some((node) => rectanglesOverlap({ ...candidate, ...size }, node));
|
||
|
||
if (!overlapsAnyGenerator(base)) return base;
|
||
|
||
for (let ring = 1; ring <= 6; ring += 1) {
|
||
for (let row = -ring; row <= ring; row += 1) {
|
||
for (let column = -ring; column <= ring; column += 1) {
|
||
if (Math.max(Math.abs(row), Math.abs(column)) !== ring) continue;
|
||
const candidate = {
|
||
x: base.x + column * step.x,
|
||
y: base.y + row * step.y
|
||
};
|
||
if (!overlapsAnyGenerator(candidate)) return candidate;
|
||
}
|
||
}
|
||
}
|
||
|
||
const fallbackOffset = generators.length * gap;
|
||
return {
|
||
x: base.x + fallbackOffset,
|
||
y: base.y + fallbackOffset
|
||
};
|
||
}
|
||
|
||
function rectanglesOverlap(a: { x: number; y: number; width: number; height: number }, b: CanvasNode) {
|
||
return a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y;
|
||
}
|
||
|
||
function agentThreadTitleText(content: string) {
|
||
return visiblePromptText(content);
|
||
}
|
||
|
||
function agentHeaderTitle(messages: AgentMessage[], project: Project, displayProjectTitle: string) {
|
||
const firstUserMessage = messages.find((message) => message.role === "user");
|
||
const firstUserText = firstUserMessage ? visiblePromptText(firstUserMessage.content) : "";
|
||
if (firstUserText) return firstUserText;
|
||
const brief = visiblePromptText(project.brief);
|
||
return brief && !isDefaultCanvasTitle(brief) ? brief : displayProjectTitle;
|
||
}
|
||
|
||
function localizedCanvasTitle(title: string, fallback: string) {
|
||
const trimmedTitle = title.trim();
|
||
return !trimmedTitle || isDefaultCanvasTitle(trimmedTitle) ? fallback : trimmedTitle;
|
||
}
|
||
|
||
function isDefaultCanvasTitle(title: string) {
|
||
return ["Untitled", "Untitled design project", "未命名设计项目"].includes(title.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, t: (key: string) => string) {
|
||
return friendlyProjectFailureMessage(project.messages, t);
|
||
}
|
||
|
||
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 visualAnnotationToolLabel(tool: VisualAnnotationTool, t: (key: string) => string) {
|
||
const keys: Record<VisualAnnotationTool, string> = {
|
||
brush: "visualAnnotationBrush",
|
||
circle: "visualAnnotationCircle",
|
||
rectangle: "visualAnnotationRectangle",
|
||
cross: "visualAnnotationCross"
|
||
};
|
||
return t(keys[tool]);
|
||
}
|
||
|
||
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[]) {
|
||
let imageIndex = 0;
|
||
return contents
|
||
.map((content) => {
|
||
if (content.type === "text") return isHiddenTextSource(content.textSource) ? "" : content.text;
|
||
imageIndex += 1;
|
||
return `参考图 ${imageIndex}(${agentContentImageName(content)}):${content.imageUrl}`;
|
||
})
|
||
.filter((item) => item.trim())
|
||
.join("\n\n");
|
||
}
|
||
|
||
function agentContentImageName(content: Extract<AgentContent, { type: "image" }>) {
|
||
return content.imageName?.replace(/\s+/g, " ").trim() || "image";
|
||
}
|
||
|
||
function imageGeneratorSubmittedPrompt(contents: AgentContent[]) {
|
||
let imageIndex = 0;
|
||
return cleanImageGeneratorPrompt(
|
||
contents
|
||
.map((content) => {
|
||
if (content.type === "text" && !isHiddenTextSource(content.textSource)) return content.text;
|
||
if (content.type === "image") {
|
||
imageIndex += 1;
|
||
return `参考图 ${imageIndex}(${agentContentImageName(content)}):${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 isHiddenTextSource(value?: string) {
|
||
const source = value?.trim().toLowerCase();
|
||
return source === "settings" || source === "cad-context";
|
||
}
|
||
|
||
function isBlankTextContent(content: string) {
|
||
return content.replace(/[\u200B-\u200D\u2060\uFEFF]/g, "").trim().length === 0;
|
||
}
|