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 &&