From 874bf9b215cfd980886922997e79f997736a821e Mon Sep 17 00:00:00 2001 From: liangxu Date: Sat, 18 Jul 2026 00:24:02 +0800 Subject: [PATCH] feat(canvas): wire history, node clipboard, and boolean results into the workspace Integrate the new canvas modules into CanvasWorkspace and the layer panel: - Capture debounced history snapshots per operation and show them in the layer panel's History section with row preview, a restore button, and an edit-blocking shield while previewing; saving is paused during preview and restore truncates newer states - Copy nodes through the lossless clipboard payload alongside the PNG/HTML/plain-text representations, and prefer it when pasting from the context menu, keyboard, or system clipboard - Apply boolean operations as flattened path result nodes (replacing the operand group) and route path-editor commits through applyEditedPathToNodes so operand children are dropped - Drive the Pen tool controls from the selected path node's style and keep the Pen tool active after committing a draft - Add history i18n strings (en-US, zh-CN) and layer-history styles Co-Authored-By: Claude Fable 5 --- frontend/src/i18n/locales/en-US.json | 13 + frontend/src/i18n/locales/zh-CN.json | 13 + .../src/ui/components/CanvasPanels/index.tsx | 63 +++- .../src/ui/pages/CanvasWorkspace/index.tsx | 274 ++++++++++++++---- frontend/src/ui/styles.css | 187 +++++++++++- frontend/tsconfig.tsbuildinfo | 2 +- 6 files changed, 486 insertions(+), 66 deletions(-) diff --git a/frontend/src/i18n/locales/en-US.json b/frontend/src/i18n/locales/en-US.json index 108b786..1d96112 100644 --- a/frontend/src/i18n/locales/en-US.json +++ b/frontend/src/i18n/locales/en-US.json @@ -460,6 +460,19 @@ "layers": "Layers", "layerHistory": "History", "layerNoHistory": "No history yet", + "historyCurrent": "Current", + "historyRestore": "Restore this state", + "historyRestored": "Restored the selected history state", + "historyBooleanOperation": "Boolean · {operation}", + "historyAdd": "Added · {name}", + "historyDelete": "Deleted · {name}", + "historyEditPath": "Edited path · {name}", + "historyMove": "Moved · {name}", + "historyResize": "Resized · {name}", + "historyStyle": "Updated style · {name}", + "historyRename": "Renamed · {name}", + "historyReorder": "Reordered layers", + "historyEdit": "Edited canvas · {name}", "layerNoLayers": "No layers", "generatedFiles": "Generated files", "generatedFilesEmpty": "No generated files yet", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index c75a47a..e675167 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -460,6 +460,19 @@ "layers": "图层", "layerHistory": "历史记录", "layerNoHistory": "暂无历史记录", + "historyCurrent": "当前", + "historyRestore": "恢复到此状态", + "historyRestored": "已恢复到所选历史状态", + "historyBooleanOperation": "布尔运算 · {operation}", + "historyAdd": "添加 · {name}", + "historyDelete": "删除 · {name}", + "historyEditPath": "编辑路径 · {name}", + "historyMove": "移动 · {name}", + "historyResize": "调整尺寸 · {name}", + "historyStyle": "调整样式 · {name}", + "historyRename": "重命名 · {name}", + "historyReorder": "调整图层顺序", + "historyEdit": "编辑画布 · {name}", "layerNoLayers": "暂无图层", "generatedFiles": "已生成文件列表", "generatedFilesEmpty": "暂无已生成文件", diff --git a/frontend/src/ui/components/CanvasPanels/index.tsx b/frontend/src/ui/components/CanvasPanels/index.tsx index faee3ec..a40783b 100644 --- a/frontend/src/ui/components/CanvasPanels/index.tsx +++ b/frontend/src/ui/components/CanvasPanels/index.tsx @@ -1,11 +1,12 @@ "use client"; -import { ChevronDown, ChevronRight, ChevronUp, Download, Eye, EyeOff, Frame, GripVertical, Image as ImageIcon, Layers, Lock, Type, Unlock, X } from "lucide-react"; +import { Check, ChevronDown, ChevronRight, ChevronUp, Download, Eye, EyeOff, Frame, GripVertical, History, Image as ImageIcon, Layers, Lock, RotateCcw, Type, Unlock, X } from "lucide-react"; import { Fragment, useEffect, useMemo, useRef, useState, type DragEvent as ReactDragEvent } from "react"; import type { AgentMessage, CanvasNode, CanvasViewport } from "@/domain/design"; import { useI18n } from "@/i18n/i18n"; import { canvasNodeRenderContent, thumbnailImageUrl } from "@/ui/lib/imageDelivery"; import { isFrameContainerNode, type LayerReorderItem, type LayerReorderPlacement, type LayerReorderRequest } from "@/ui/pages/CanvasWorkspace/canvas/canvasNodeOps"; +import type { CanvasHistoryEntry } from "@/ui/pages/CanvasWorkspace/canvas/canvasHistory"; import { ShapeNodePreview } from "@/ui/pages/CanvasWorkspace/canvas/ShapeNodePreview"; import { isBooleanShapeGroupNode, isBooleanShapeOperandNode, isShapeNode } from "@/ui/pages/CanvasWorkspace/canvas/shapeNode"; @@ -132,6 +133,11 @@ export function CanvasLayerPanel({ onToggleHiddenMany, onToggleLockedMany, onReorderLayer, + historyEntries, + activeHistoryId, + previewHistoryId, + onPreviewHistory, + onRestoreHistory, onClose }: { nodes: CanvasNode[]; @@ -146,10 +152,16 @@ export function CanvasLayerPanel({ onToggleHiddenMany: (nodeIds: string[]) => void; onToggleLockedMany: (nodeIds: string[]) => void; onReorderLayer: (request: LayerReorderRequest) => void; + historyEntries: Array>; + activeHistoryId: string | null; + previewHistoryId: string | null; + onPreviewHistory: (historyId: string) => void; + onRestoreHistory: (historyId: string) => void; onClose: () => void; }) { - const { t } = useI18n(); + const { locale, t } = useI18n(); const layers = useMemo(() => buildLayerPanelItems(nodes), [nodes]); + const [historyExpanded, setHistoryExpanded] = useState(true); const [collapsedLayerIds, setCollapsedLayerIds] = useState>(() => new Set()); const [draggingItem, setDraggingItem] = useState(null); const [dropTarget, setDropTarget] = useState<{ item: LayerReorderItem; placement: LayerReorderPlacement } | null>(null); @@ -213,15 +225,48 @@ export function CanvasLayerPanel({ -
- -
- - {t("layerNoHistory")} -
+ {historyExpanded && + (historyEntries.length === 0 ? ( +
+ + {t("layerNoHistory")} +
+ ) : ( +
    + {historyEntries.map((entry) => { + const active = entry.id === activeHistoryId; + const previewing = entry.id === previewHistoryId; + return ( +
  1. +
    + + {active ? ( + {t("historyCurrent")} + ) : ( + + )} +
    +
  2. + ); + })} +
+ ))}
{layers.length === 0 ? ( diff --git a/frontend/src/ui/pages/CanvasWorkspace/index.tsx b/frontend/src/ui/pages/CanvasWorkspace/index.tsx index 926d408..b684a8c 100644 --- a/frontend/src/ui/pages/CanvasWorkspace/index.tsx +++ b/frontend/src/ui/pages/CanvasWorkspace/index.tsx @@ -50,6 +50,8 @@ import { isImageGeneratorThreadMessage, visibleAgentMessages } from "./canvas/ag 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 { canvasNodeClipboardBaseMime, canvasNodeClipboardMime, canvasNodeFromClipboardData, canvasNodeFromClipboardItems, createPastedCanvasNode, serializeCanvasNodeClipboard, serializeCanvasNodeClipboardSvg } from "@/ui/pages/CanvasWorkspace/canvas/canvasNodeClipboard"; +import { appendCanvasHistory, canvasHistoryOperation, canvasHistoryOperationLabel, cloneCanvasHistoryNodes, sameCanvasNodes, truncateCanvasHistory, type CanvasHistoryEntry } from "@/ui/pages/CanvasWorkspace/canvas/canvasHistory"; import { CanvasSelectionFrame } from "@/ui/pages/CanvasWorkspace/canvas/CanvasSelectionFrame"; import { CanvasPathEditor, type CanvasPathEditorControl } from "@/ui/pages/CanvasWorkspace/canvas/CanvasPathEditor"; import { createCroppedSvgRasterSource, createVectorCropSiblingNode } from "./canvas/cadCrop"; @@ -61,7 +63,7 @@ import { beginFrameDrawing, FrameDraftOverlay, type FrameDraft, type FrameWorldR import { FrameStyleToolbar } from "./canvas/FrameStyleToolbar"; import { defaultFrameStrokeColor, defaultFrameStrokeStyle, defaultFrameStrokeWidth, normalizeHexColor } from "./canvas/frameStyle"; import { generatorTaskPollIntervalMs } from "@/ui/pages/CanvasWorkspace/canvas/generatorTaskPolling"; -import { applyEditedPathToNode, editablePathForNode, isEditablePathNode, type EditablePathData } from "@/ui/pages/CanvasWorkspace/canvas/editablePath"; +import { applyEditedPathToNodes, editablePathForNode, isEditablePathNode, type EditablePathData } from "@/ui/pages/CanvasWorkspace/canvas/editablePath"; import { useCanvasDocument } from "./canvas/hooks/useCanvasDocument"; import { useCanvasPersistence } from "./canvas/hooks/useCanvasPersistence"; import { useCanvasSelection } from "./canvas/hooks/useCanvasSelection"; @@ -70,13 +72,14 @@ import { imageGeneratorPlaceholderSize, imageGeneratorTargetSizeFromNode, imageG import { canvasNodeToReferenceImage, isAssetReferencedByCanvas, isCanvasNodeReferenceImage, withLastCanvasReferenceAnchor } from "./canvas/nodeReferenceImages"; import { canvasNodeSvg, exportCanvasNode, exportImageNode, exportTextNode, imageNodeRasterBlob, svgToRasterBlob } from "./canvas/nodeExport"; import { beginPenStroke, createPenStrokeNode, PathPenDraftOverlay, PenStrokeDraftOverlay, PenToolControls, type PenDraft, type PenSettings } from "@/ui/pages/CanvasWorkspace/canvas/PenTool"; -import { appendPathPenAnchor, canCommitPathPenDraft, closePathPenDraft, constrainPathPenPoint, createPathPenNode, isPathPenNearStart, removeLastPathPenAnchor, setLastPathPenAnchorHandles, setPathPenHover, startPathPenDraft, type PathPenDraft, type PathPenPosition } from "@/ui/pages/CanvasWorkspace/canvas/pathPen"; +import { appendPathPenAnchor, canCommitPathPenDraft, closePathPenDraft, constrainPathPenPoint, createPathPenNode, isPathPenNearStart, pathPenSettingsForNode, pathPenSettingsToShapeStyle, removeLastPathPenAnchor, setLastPathPenAnchorHandles, setPathPenHover, startPathPenDraft, type PathPenDraft, type PathPenPosition } from "@/ui/pages/CanvasWorkspace/canvas/pathPen"; 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, + booleanShapeGroupToPathNode, + createBooleanShapeResultNode, createShapeNode, isBooleanCapableShapeNode, isBooleanShapeGroupNode, @@ -86,7 +89,6 @@ import { patchShapeStyleNode, resizeLineShapeEndpoint, shapeTitle, - updateBooleanShapeOperation, type BooleanShapeOperation, type ShapeEndpointKey, type ShapeKind, @@ -627,8 +629,15 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess const pathEditorExitToolRef = useRef(null); const pathPenDraftRef = useRef(null); const pathPenDraggingRef = useRef(false); + const canvasHistoryCaptureTimerRef = useRef(null); + const canvasHistoryBaselineRef = useRef(null); + const activeCanvasHistoryIdRef = useRef(null); + const previewCanvasHistoryIdRef = useRef(null); const { project, setProject, projectRef, viewport, setViewport, viewportRef, nodes, setNodes, nodesRef, applyProjectDocument } = useCanvasDocument(); const { selectedId, selectedIds, setSelectedIds, selectedNodes, selectedNode, selectOnlyNode, selectNodes, toggleNodeSelection } = useCanvasSelection(nodes); + const [canvasHistoryEntries, setCanvasHistoryEntries] = useState([]); + const [activeCanvasHistoryId, setActiveCanvasHistoryId] = useState(null); + const [previewCanvasHistoryId, setPreviewCanvasHistoryId] = useState(null); const [messages, setMessages] = useState([]); const [agentHistoryLoading, setAgentHistoryLoading] = useState(canViewChat); const [prompt, setPrompt] = useState(""); @@ -911,7 +920,7 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess nodesRef, dirty, anyGenerating, - disabled: !canEdit, + disabled: !canEdit || Boolean(previewCanvasHistoryId), setProject, setDirty, setSaving, @@ -922,6 +931,95 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess } }); + useEffect(() => { + if (canvasHistoryCaptureTimerRef.current) window.clearTimeout(canvasHistoryCaptureTimerRef.current); + canvasHistoryCaptureTimerRef.current = null; + canvasHistoryBaselineRef.current = null; + activeCanvasHistoryIdRef.current = null; + previewCanvasHistoryIdRef.current = null; + setCanvasHistoryEntries([]); + setActiveCanvasHistoryId(null); + setPreviewCanvasHistoryId(null); + }, [projectId]); + + useEffect(() => { + if (!project?.id || project.id !== projectId) return; + if (previewCanvasHistoryIdRef.current) return; + const baseline = canvasHistoryBaselineRef.current; + if (!baseline) { + canvasHistoryBaselineRef.current = cloneCanvasHistoryNodes(nodes); + return; + } + if (sameCanvasNodes(baseline, nodes)) return; + if (canvasHistoryCaptureTimerRef.current) window.clearTimeout(canvasHistoryCaptureTimerRef.current); + canvasHistoryCaptureTimerRef.current = window.setTimeout(() => { + const previous = canvasHistoryBaselineRef.current; + const next = cloneCanvasHistoryNodes(nodes); + if (!previous) { + canvasHistoryBaselineRef.current = next; + return; + } + const operation = canvasHistoryOperation(previous, next); + canvasHistoryBaselineRef.current = next; + if (!operation) return; + const entry: CanvasHistoryEntry = { + id: `history-${Date.now()}-${Math.random().toString(16).slice(2)}`, + label: canvasHistoryOperationLabel(operation, t), + createdAt: Date.now(), + nodes: next + }; + setCanvasHistoryEntries((current) => appendCanvasHistory(current, entry, activeCanvasHistoryIdRef.current)); + activeCanvasHistoryIdRef.current = entry.id; + setActiveCanvasHistoryId(entry.id); + canvasHistoryCaptureTimerRef.current = null; + }, 320); + return () => { + if (canvasHistoryCaptureTimerRef.current) window.clearTimeout(canvasHistoryCaptureTimerRef.current); + canvasHistoryCaptureTimerRef.current = null; + }; + }, [nodes, project?.id, projectId, t]); + + const previewCanvasHistory = (historyId: string) => { + const entry = canvasHistoryEntries.find((item) => item.id === historyId); + if (!entry) return; + if (canvasHistoryCaptureTimerRef.current) window.clearTimeout(canvasHistoryCaptureTimerRef.current); + canvasHistoryCaptureTimerRef.current = null; + const previewId = historyId === activeCanvasHistoryIdRef.current ? null : historyId; + const previewNodes = cloneCanvasHistoryNodes(entry.nodes); + previewCanvasHistoryIdRef.current = previewId; + setPreviewCanvasHistoryId(previewId); + canvasHistoryBaselineRef.current = cloneCanvasHistoryNodes(entry.nodes); + nodesRef.current = previewNodes; + setNodes(previewNodes); + selectOnlyNode(null); + setEditingTextNodeId(null); + setPathEditingNodeId(null); + }; + + const restoreCanvasHistory = (historyId: string) => { + const entry = canvasHistoryEntries.find((item) => item.id === historyId); + if (!entry || historyId === activeCanvasHistoryIdRef.current) return; + if (canvasHistoryCaptureTimerRef.current) window.clearTimeout(canvasHistoryCaptureTimerRef.current); + canvasHistoryCaptureTimerRef.current = null; + const restoredNodes = cloneCanvasHistoryNodes(entry.nodes); + canvasHistoryBaselineRef.current = cloneCanvasHistoryNodes(entry.nodes); + activeCanvasHistoryIdRef.current = entry.id; + previewCanvasHistoryIdRef.current = null; + setCanvasHistoryEntries((current) => truncateCanvasHistory(current, entry.id)); + setActiveCanvasHistoryId(entry.id); + setPreviewCanvasHistoryId(null); + nodesRef.current = restoredNodes; + setNodes(restoredNodes); + setHiddenNodeIds((current) => new Set(Array.from(current).filter((nodeId) => restoredNodes.some((node) => node.id === nodeId)))); + setLockedNodeIds((current) => new Set(Array.from(current).filter((nodeId) => restoredNodes.some((node) => node.id === nodeId)))); + selectOnlyNode(null); + setEditingTextNodeId(null); + setPathEditingNodeId(null); + setDirty(true); + scheduleCanvasSave(); + showCanvasToast(t("historyRestored")); + }; + useEffect(() => { if (!agentGenerating) { activeAgentThreadIdRef.current = null; @@ -1144,6 +1242,12 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess const selectedBooleanShapeGroupNode = selectedControlNode && isBooleanShapeGroupNode(selectedControlNode) ? selectedControlNode : null; const selectedTextNode = selectedControlNode?.type === "text" ? selectedControlNode : null; const pathEditingNode = pathEditingNodeId ? nodes.find((node) => node.id === pathEditingNodeId && isEditablePathNode(node)) ?? null : null; + const pathPenControlNode = activeTool === "path" && selectedShapeNode && isEditablePathNode(selectedShapeNode) ? selectedShapeNode : null; + const pathPenControlSettings = pathPenDraft + ? { color: pathPenDraft.color, width: pathPenDraft.width } + : pathPenControlNode + ? pathPenSettingsForNode(pathPenControlNode, viewport.k) + : pathPenSettings; const hasCurrentConversation = messages.some((message) => message.content.trim() || message.title.trim()); const selectedNodeBounds = useMemo(() => { if (!selectedImageNode) return null; @@ -1638,7 +1742,7 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess }, []); const commitPathPenDrawing = useCallback( - (draft: PathPenDraft, closed = false, editAfterCommit = true) => { + (draft: PathPenDraft, closed = false) => { const completed = closed ? closePathPenDraft(draft) : draft; if (!canCommitPathPenDraft(completed)) return false; const node = createPathPenNode(completed, t("pathPen")); @@ -1648,12 +1752,8 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess })); updatePathPenDraft(null); selectOnlyNode(node.id); - setActiveTool(editAfterCommit ? "path" : "select"); + setActiveTool("path"); setMode("select"); - if (editAfterCommit) { - pathEditorExitToolRef.current = null; - setPathEditingNodeId(node.id); - } setDirty(true); return true; }, @@ -1661,9 +1761,9 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess ); const finishPathPenDrawing = useCallback( - (closed = false, editAfterCommit = true) => { + (closed = false) => { const draft = pathPenDraftRef.current; - if (!draft || !commitPathPenDrawing(draft, closed, editAfterCommit)) updatePathPenDraft(null); + if (!draft || !commitPathPenDrawing(draft, closed)) updatePathPenDraft(null); }, [commitPathPenDrawing, updatePathPenDraft] ); @@ -2053,7 +2153,7 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess const currentPathPenDraft = pathPenDraftRef.current; if (currentPathPenDraft) { if (canCommitPathPenDraft(currentPathPenDraft)) { - commitPathPenDrawing(currentPathPenDraft, false, false); + commitPathPenDrawing(currentPathPenDraft); } else { updatePathPenDraft(null); } @@ -2188,7 +2288,7 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess }; const commitPathEditing = (nodeId: string, pathData: EditablePathData) => { - const nextNodes = nodesRef.current.map((node) => (node.id === nodeId ? applyEditedPathToNode(node, pathData) : node)); + const nextNodes = applyEditedPathToNodes(nodesRef.current, nodeId, pathData); nodesRef.current = nextNodes; setNodes(nextNodes); setDirty(true); @@ -2594,31 +2694,36 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess 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)); + const resultNode = booleanShapeGroupToPathNode(selectedBooleanGroup, operation); + const nextNodes = nodesRef.current.flatMap((node) => { + if (node.id === selectedBooleanGroup.id) return [resultNode]; + if (node.parentId === selectedBooleanGroup.id) return []; + return [node]; + }); nodesRef.current = nextNodes; setNodes(nextNodes); - selectOnlyNode(selectedBooleanGroup.id); + selectOnlyNode(resultNode.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, + const resultNode = createBooleanShapeResultNode({ + id: `boolean-${cryptoFallbackId()}`, 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])); + const nextNodes = nodesRef.current.flatMap((node, index) => { + if (index === highestSelectedIndex) return [resultNode]; + return selectedSet.has(node.id) ? [] : [node]; + }); nodesRef.current = nextNodes; setNodes(nextNodes); - selectOnlyNode(groupId); + selectOnlyNode(resultNode.id); setDirty(true); }; @@ -2752,8 +2857,23 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess return true; }; + const pasteClipboardNode = (source: CanvasNode, anchor = clipboardPasteAnchor()) => { + const node = createPastedCanvasNode(source, cryptoFallbackId(), anchor); + setNodesWithFrameAttachment((current) => ({ + nodes: [...current, node], + candidateIds: new Set([node.id]) + })); + selectOnlyNode(node.id); + setDirty(true); + }; + const pasteFromClipboardData = (dataTransfer: DataTransfer) => { const anchor = clipboardPasteAnchor(); + const canvasNode = canvasNodeFromClipboardData(dataTransfer); + if (canvasNode) { + pasteClipboardNode(canvasNode, anchor); + return true; + } const imageFile = latestDataTransferImageFile(dataTransfer, "clipboard-image"); if (imageFile) { void uploadImagesToCanvas([imageFile], anchor); @@ -2764,13 +2884,11 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess 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 clipboard = await latestNavigatorClipboardContent("clipboard-image").catch(() => null); + if (!clipboard) return; + if (clipboard.canvasNode) return void pasteClipboardNode(clipboard.canvasNode, anchor); + if (clipboard.imageFile) return void (await uploadImagesToCanvas([clipboard.imageFile], anchor)); + if (clipboard.text) pasteClipboardText(clipboard.text, anchor); }; const sendNodeToAgentComposer = (node: CanvasNode) => { @@ -3056,7 +3174,7 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess pathEditorExitToolRef.current = tool; pathEditorControlRef.current?.close(); } - if (tool !== "path" && pathPenDraftRef.current) finishPathPenDrawing(false, false); + if (tool !== "path" && pathPenDraftRef.current) finishPathPenDrawing(); if (tool !== "crop") { workspaceCropDetectionRequestRef.current += 1; setWorkspaceCropDetecting(false); @@ -4198,6 +4316,11 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess onToggleHiddenMany={toggleLayersHidden} onToggleLockedMany={toggleLayersLocked} onReorderLayer={reorderLayer} + historyEntries={canvasHistoryEntries} + activeHistoryId={activeCanvasHistoryId} + previewHistoryId={previewCanvasHistoryId} + onPreviewHistory={previewCanvasHistory} + onRestoreHistory={restoreCanvasHistory} onClose={() => setLayersPanelOpen(false)} /> )} @@ -4623,6 +4746,7 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess )} {!canEdit &&