feat(canvas): add leafer GPU renderer and interactive color-boundary crop tool

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>
This commit is contained in:
2026-07-16 20:46:31 +08:00
parent 8e1506e4c4
commit b363dcf888
12 changed files with 1426 additions and 49 deletions
+303 -46
View File
@@ -1,7 +1,7 @@
"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 { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "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";
@@ -40,8 +40,9 @@ import { autoImageAdjustments, defaultImageAdjustments, parseImageAdjustments, s
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 } from "@/ui/lib/imageDelivery";
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";
@@ -51,7 +52,9 @@ import { alignNodes, attachNodesToContainingFrames, autoLayoutAroundNode as auto
import { CanvasSelectionFrame } from "@/ui/pages/CanvasWorkspace/canvas/CanvasSelectionFrame";
import { createCroppedSvgRasterSource, createVectorCropSiblingNode } from "./canvas/cadCrop";
import { installCanvasWheelListener } from "./canvas/canvasWheel";
import { visibleCropSelectionForBounds } from "./canvas/cropViewport";
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";
@@ -115,6 +118,34 @@ const canvasWheelGuardSelector = [
".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;
@@ -564,6 +595,11 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
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);
@@ -582,6 +618,7 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
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[]>([]);
@@ -662,6 +699,9 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
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",
@@ -1028,6 +1068,40 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
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(() => {
@@ -1256,6 +1330,43 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
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;
@@ -1270,13 +1381,15 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
const mouseY = event.clientY - rect.top;
const worldX = (mouseX - currentViewport.x) / currentViewport.k;
const worldY = (mouseY - currentViewport.y) / currentViewport.k;
setViewport({
const nextViewport = {
x: mouseX - worldX * nextScale,
y: mouseY - worldY * nextScale,
k: nextScale
});
setDirty(true);
}, [setViewport, viewportRef]);
};
containerRef.current?.classList.add("is-viewport-transforming");
applyViewportImperatively(nextViewport);
scheduleViewportCommit();
}, [applyViewportImperatively, scheduleViewportCommit, viewportRef]);
useEffect(() => {
const stage = containerRef.current;
@@ -1296,7 +1409,10 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
y: centerY - worldY * nextScale,
k: nextScale
};
viewportRef.current = nextViewport;
if (viewportCommitTimerRef.current) window.clearTimeout(viewportCommitTimerRef.current);
viewportCommitTimerRef.current = null;
applyViewportImperatively(nextViewport);
containerRef.current?.classList.remove("is-viewport-transforming");
setViewport(nextViewport);
setDirty(true);
};
@@ -1314,7 +1430,10 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
y: canvasStageSize.height / 2 - (bounds.y + bounds.height / 2) * nextScale,
k: nextScale
};
viewportRef.current = nextViewport;
if (viewportCommitTimerRef.current) window.clearTimeout(viewportCommitTimerRef.current);
viewportCommitTimerRef.current = null;
applyViewportImperatively(nextViewport);
containerRef.current?.classList.remove("is-viewport-transforming");
setViewport(nextViewport);
setDirty(true);
};
@@ -1328,20 +1447,20 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
const startCanvasPan = (event: React.PointerEvent<HTMLElement>) => {
event.preventDefault();
setCanvasPanning(true);
const currentViewport = viewportRef.current;
const start = {
pointerX: event.clientX,
pointerY: event.clientY,
x: viewport.x,
y: viewport.y
x: currentViewport.x,
y: currentViewport.y
};
const handleMove = (moveEvent: PointerEvent) => {
setViewport((current) => ({
...current,
applyViewportImperatively({
...viewportRef.current,
x: start.x + moveEvent.clientX - start.pointerX,
y: start.y + moveEvent.clientY - start.pointerY
}));
setDirty(true);
});
};
const handleUp = () => {
@@ -1349,6 +1468,8 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
window.removeEventListener("pointerup", handleUp);
window.removeEventListener("pointercancel", handleUp);
setCanvasPanning(false);
startTransition(() => setViewport({ ...viewportRef.current }));
setDirty(true);
scheduleCanvasSave();
};
@@ -1517,6 +1638,83 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
[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;
@@ -1590,13 +1788,18 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
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: viewport.x,
y: viewport.y
x: viewportRef.current.x,
y: viewportRef.current.y
};
if (mode === "select") {
@@ -1641,7 +1844,7 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
pointerY: event.clientY,
x: sourceNode.x,
y: sourceNode.y,
scale: viewport.k
scale: viewportRef.current.k
};
let dragged = false;
@@ -1692,6 +1895,12 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
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 &&
@@ -1768,8 +1977,11 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
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: viewport.k
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) => {
@@ -1779,16 +1991,26 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
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) => selectedDragIds.has(item.id) && !lockedNodeIds.has(item.id));
const movedNodes = nextNodes.filter((item) => draggedElements.has(item.id));
const alignmentNodes = movedNodes.filter((item) => selectedDragIds.has(item.id));
nodesRef.current = nextNodes;
setNodes(nextNodes);
setAlignmentGuides(buildAlignmentGuides(movedNodes, nextNodes, viewportRef.current));
setDirty(true);
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);
@@ -1797,7 +2019,8 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
if (dragged) {
const nextNodes = attachFrameChildren(nodesRef.current, dragIds);
nodesRef.current = nextNodes;
setNodes(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));
@@ -2436,8 +2659,9 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
return true;
};
if (!primary && !event.shiftKey && !event.altKey && key === "escape" && (imageActionMode || mockupPendingTargetId)) {
if (!primary && !event.shiftKey && !event.altKey && key === "escape" && (imageActionMode || mockupPendingTargetId || activeTool === "crop")) {
return void run(() => {
chooseCanvasTool("select");
setImageActionMode(null);
setMockupDropActive(false);
setMockupDragNodeId(null);
@@ -2643,6 +2867,12 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
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);
@@ -2670,6 +2900,10 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
setMode("select");
return;
}
if (tool === "crop") {
setMode("select");
return;
}
setMode("select");
};
@@ -3371,7 +3605,16 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
const openWorkspaceCrop = () => {
if (!workspaceCropTarget || workspaceCropTargetBusy) return;
openCropForNode(workspaceCropTarget);
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 () => {
@@ -3492,7 +3735,7 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
setSaving(true);
setError("");
const preparedUploads: Array<{ file: File; previewUrl: string; node: CanvasNode }> = [];
const preparedUploads: Array<{ asset: PreparedCanvasImportAsset; sourcePreviewUrl: string; renderPreviewUrl?: string; node: CanvasNode }> = [];
try {
const imageAssets: PreparedCanvasImportAsset[] = [];
let cadImportCount = 0;
@@ -3514,14 +3757,16 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
if (imageAssets.length === 0) return;
for (const [index, asset] of imageAssets.entries()) {
const { file, semanticContext } = asset;
const previewUrl = URL.createObjectURL(file);
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({
file,
previewUrl,
asset,
sourcePreviewUrl,
renderPreviewUrl,
node: {
id: cryptoFallbackId(),
type: "image",
@@ -3530,7 +3775,8 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
y: anchor.y - size.height / 2 + index * 36,
width: size.width,
height: size.height,
content: previewUrl,
content: sourcePreviewUrl,
renderContent: renderPreviewUrl,
tone,
layerRole: semanticContext ? "cad-vector" : isSvgSource(file.name) || file.type.toLowerCase().includes("svg") ? "vector-image" : undefined,
semanticContext,
@@ -3547,17 +3793,17 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
setDirty(true);
const uploadedResults = await Promise.allSettled(
preparedUploads.map(async ({ file, node }) => ({
preparedUploads.map(async ({ asset, node }) => ({
id: node.id,
publicUrl: await designGateway.uploadImage(file)
uploaded: await uploadPreparedCanvasImportAsset(asset, designGateway.uploadImage, designGateway.deleteAsset)
}))
);
const uploadedById = new Map<string, string>();
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.publicUrl);
if (result.status === "fulfilled") uploadedById.set(result.value.id, result.value.uploaded);
else failedIds.add(nodeId);
});
@@ -3565,13 +3811,13 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
const finalNodes = nodesRef.current
.filter((node) => !failedIds.has(node.id))
.map((node) => {
const publicUrl = uploadedById.get(node.id);
return publicUrl ? { ...node, content: publicUrl, status: "success" } : node;
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((item) => URL.revokeObjectURL(item.previewUrl));
preparedUploads.forEach(revokePreparedUploadUrls);
if (failedIds.size > 0) {
setError(t("generateError"));
@@ -3584,7 +3830,7 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
showCanvasToast(cadImportCount > 0 ? t("cadImportSuccess", { count: cadImportCount }) : t("imageUploadSuccess"));
}
} catch (err) {
preparedUploads.forEach((item) => URL.revokeObjectURL(item.previewUrl));
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));
@@ -3667,7 +3913,7 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
<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" : ""}`}
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}
@@ -3743,12 +3989,13 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
/>
)}
{showGrid && <CanvasGrid viewport={viewport} />}
<div className="canvas-world" style={{ transform: `translate(${viewport.x}px, ${viewport.y}px) scale(${viewport.k})` }}>
<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;
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
@@ -3794,6 +4041,14 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
<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}
@@ -3813,6 +4068,7 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
)}
{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}
@@ -3865,7 +4121,7 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
<>
{isVectorImageNode(selectedImageNode) ? (
<>
{imageActionMode !== "crop" && (
{imageActionMode !== "crop" && activeTool !== "crop" && (
<VectorizedImageToolbar
bounds={selectedNodeBounds}
leftInset={floatingToolbarLeftInset}
@@ -3893,7 +4149,7 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
</>
) : (
<>
{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 && (
{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}
@@ -4202,7 +4458,7 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
selectedShapeKind={selectedShapeKind}
showGrid={showGrid}
cropEnabled={Boolean(workspaceCropTarget && !workspaceCropTargetBusy)}
cropActive={imageActionMode === "crop"}
cropActive={imageActionMode === "crop" || activeTool === "crop"}
disabled={!canEdit}
offsetBy={generatedFilesPanelOpen ? "files" : layersPanelOpen ? "layers" : null}
onCanvasToolMenuOpenChange={setCanvasToolMenuOpen}
@@ -4454,6 +4710,7 @@ function CanvasNodeView({
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}