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 <noreply@anthropic.com>
This commit is contained in:
2026-07-18 00:24:02 +08:00
parent 0056f6f904
commit 874bf9b215
6 changed files with 486 additions and 66 deletions
+13
View File
@@ -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",
+13
View File
@@ -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": "暂无已生成文件",
@@ -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<Pick<CanvasHistoryEntry, "id" | "label" | "createdAt">>;
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<Set<string>>(() => new Set());
const [draggingItem, setDraggingItem] = useState<LayerReorderItem | null>(null);
const [dropTarget, setDropTarget] = useState<{ item: LayerReorderItem; placement: LayerReorderPlacement } | null>(null);
@@ -213,15 +225,48 @@ export function CanvasLayerPanel({
<X size={17} />
</button>
</header>
<section className="layer-history-section">
<button type="button">
<section className={`layer-history-section ${historyExpanded ? "is-expanded" : ""}`}>
<button type="button" aria-expanded={historyExpanded} onClick={() => setHistoryExpanded((expanded) => !expanded)}>
<span>{t("layerHistory")}</span>
<ChevronUp size={14} />
<span className="layer-history-heading-meta">
{historyEntries.length > 0 && <small>{historyEntries.length}/5</small>}
{historyExpanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</span>
</button>
<div className="layer-history-empty">
<ImageIcon size={58} />
<span>{t("layerNoHistory")}</span>
</div>
{historyExpanded &&
(historyEntries.length === 0 ? (
<div className="layer-history-empty">
<History size={30} />
<span>{t("layerNoHistory")}</span>
</div>
) : (
<ol className="layer-history-list">
{historyEntries.map((entry) => {
const active = entry.id === activeHistoryId;
const previewing = entry.id === previewHistoryId;
return (
<li key={entry.id}>
<div className={`layer-history-entry ${active ? "active" : ""} ${previewing ? "previewing" : ""}`.trim()}>
<button type="button" className="layer-history-preview" aria-pressed={previewing} onClick={() => onPreviewHistory(entry.id)}>
<span className="layer-history-marker">{active ? <Check size={12} /> : <History size={12} />}</span>
<span className="layer-history-copy">
<strong>{entry.label}</strong>
<small>{new Intl.DateTimeFormat(locale, { hour: "2-digit", minute: "2-digit", second: "2-digit" }).format(entry.createdAt)}</small>
</span>
</button>
{active ? (
<span className="layer-history-current">{t("historyCurrent")}</span>
) : (
<button type="button" className="layer-history-restore" aria-label={`${t("historyRestore")}: ${entry.label}`} title={t("historyRestore")} onClick={() => onRestoreHistory(entry.id)}>
<RotateCcw size={14} />
</button>
)}
</div>
</li>
);
})}
</ol>
))}
</section>
<div className="layer-list">
{layers.length === 0 ? (
+224 -50
View File
@@ -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<CanvasTool | null>(null);
const pathPenDraftRef = useRef<PathPenDraft | null>(null);
const pathPenDraggingRef = useRef(false);
const canvasHistoryCaptureTimerRef = useRef<number | null>(null);
const canvasHistoryBaselineRef = useRef<CanvasNode[] | null>(null);
const activeCanvasHistoryIdRef = useRef<string | null>(null);
const previewCanvasHistoryIdRef = useRef<string | null>(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<CanvasHistoryEntry[]>([]);
const [activeCanvasHistoryId, setActiveCanvasHistoryId] = useState<string | null>(null);
const [previewCanvasHistoryId, setPreviewCanvasHistoryId] = useState<string | null>(null);
const [messages, setMessages] = useState<AgentMessage[]>([]);
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<NodeScreenBounds | null>(() => {
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 && <div className="workspace-readonly-shield" aria-hidden="true" />}
{previewCanvasHistoryId && <div className="workspace-history-preview-shield" aria-hidden="true" />}
<div className={`canvas-bottom-left ${generatedFilesPanelOpen ? "is-offset-by-files-panel" : layersPanelOpen ? "is-offset-by-layers-panel" : ""}`}>
{showMinimap && (
@@ -4713,14 +4837,18 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
/>
{canEdit && (activeTool === "pen" || activeTool === "path") && (
<PenToolControls
settings={activeTool === "path" ? pathPenSettings : penSettings}
settings={activeTool === "path" ? pathPenControlSettings : penSettings}
variant={activeTool === "path" ? "path" : "brush"}
offsetBy={generatedFilesPanelOpen ? "files" : layersPanelOpen ? "layers" : null}
onChange={(settings) => {
if (activeTool === "path") {
setPathPenSettings(settings);
const draft = pathPenDraftRef.current;
if (draft) updatePathPenDraft({ ...draft, ...settings });
if (draft) {
updatePathPenDraft({ ...draft, ...settings });
return;
}
if (pathPenControlNode) updateShapeStyle(pathPenControlNode.id, pathPenSettingsToShapeStyle(settings, viewportRef.current.k));
return;
}
setPenSettings(settings);
@@ -6089,12 +6217,12 @@ function canvasBackgroundStorageKey(projectId: string) {
async function writeCanvasNodeToSystemClipboard(node: CanvasNode) {
if (node.type === "text") {
await writeClipboardText(node.content || node.title);
await writeClipboardTextNode(node);
return;
}
const blob = await clipboardImageBlobForNode(node);
await writeClipboardBlob(blob);
await writeClipboardBlob(blob, node);
}
async function clipboardImageBlobForNode(node: CanvasNode) {
@@ -6106,20 +6234,46 @@ async function clipboardImageBlobForNode(node: CanvasNode) {
return svgToRasterBlob(canvasNodeSvg(node), width, height, "image/png");
}
async function writeClipboardBlob(blob: Blob) {
async function writeClipboardBlob(blob: Blob, node?: CanvasNode) {
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) {
const imageType = blob.type || "image/png";
if (node) {
const serializedNode = serializeCanvasNodeClipboard(node);
const serializedSvg = serializeCanvasNodeClipboardSvg(node, canvasNodeSvg(node));
const customData = new Blob([serializedNode], { type: canvasNodeClipboardBaseMime });
const htmlData = new Blob([serializedSvg], { type: "text/html" });
const textData = new Blob([serializedSvg], { type: "text/plain" });
try {
await navigator.clipboard.writeText(text);
await navigator.clipboard.write([new ClipboardItem({ [imageType]: blob, [canvasNodeClipboardMime]: customData, "text/html": htmlData, "text/plain": textData })]);
return;
} catch {
// Fall back for browsers that expose Clipboard API but block the write.
await navigator.clipboard.write([new ClipboardItem({ [imageType]: blob, "text/html": htmlData, "text/plain": textData })]);
return;
}
}
await navigator.clipboard.write([new ClipboardItem({ [imageType]: blob })]);
}
async function writeClipboardTextNode(node: CanvasNode) {
const text = node.content || node.title;
const serializedNode = serializeCanvasNodeClipboard(node);
const serializedSvg = serializeCanvasNodeClipboardSvg(node, canvasNodeSvg(node));
if (navigator.clipboard?.write && typeof ClipboardItem !== "undefined") {
const customData = new Blob([serializedNode], { type: canvasNodeClipboardBaseMime });
const htmlData = new Blob([serializedSvg], { type: "text/html" });
const textData = new Blob([text], { type: "text/plain" });
try {
await navigator.clipboard.write([new ClipboardItem({ [canvasNodeClipboardMime]: customData, "text/html": htmlData, "text/plain": textData })]);
return;
} catch {
try {
await navigator.clipboard.write([new ClipboardItem({ "text/html": htmlData, "text/plain": textData })]);
return;
} catch {
// Fall back to the synchronous copy event below.
}
}
}
@@ -6129,7 +6283,15 @@ async function writeClipboardText(text: string) {
textarea.style.opacity = "0";
document.body.append(textarea);
textarea.select();
const handleCopy = (event: ClipboardEvent) => {
event.preventDefault();
event.clipboardData?.setData(canvasNodeClipboardBaseMime, serializedNode);
event.clipboardData?.setData("text/html", serializedSvg);
event.clipboardData?.setData("text/plain", text);
};
document.addEventListener("copy", handleCopy, { once: true });
const copied = document.execCommand("copy");
document.removeEventListener("copy", handleCopy);
textarea.remove();
if (!copied) throw new Error("System clipboard text write failed");
}
@@ -6183,17 +6345,29 @@ function latestDataTransferImageFile(dataTransfer: DataTransfer, fallbackBaseNam
return dataTransferImageFiles(dataTransfer, fallbackBaseName).at(-1) ?? null;
}
async function latestNavigatorClipboardImageFile(fallbackBaseName = "clipboard-image") {
if (!navigator.clipboard?.read) return null;
async function latestNavigatorClipboardContent(fallbackBaseName = "clipboard-image") {
if (!navigator.clipboard?.read) {
const text = await navigator.clipboard?.readText?.().catch(() => "");
const canvasNode = text ? canvasNodeFromClipboardData({ getData: () => text }) : null;
return canvasNode ? { canvasNode, imageFile: null, text: "" } : { canvasNode: null, imageFile: null, text: text || "" };
}
const items = await navigator.clipboard.read();
const canvasNode = await canvasNodeFromClipboardItems(items);
if (canvasNode) return { canvasNode, imageFile: null, text: "" };
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}`);
if (type) {
const blob = await item.getType(type);
const imageFile = ensureImageFileName(new File([blob], "", { type: blob.type || type, lastModified: Date.now() }), `${fallbackBaseName}-${Date.now()}-${itemIndex + 1}`);
return { canvasNode: null, imageFile, text: "" };
}
if (item.types.includes("text/plain")) {
const text = await item.getType("text/plain").then((blob) => blob.text());
if (text) return { canvasNode: null, imageFile: null, text };
}
}
return null;
return { canvasNode: null, imageFile: null, text: "" };
}
function dataTransferHasImage(dataTransfer: DataTransfer) {
+181 -6
View File
@@ -1625,6 +1625,18 @@ button:disabled {
cursor: url("/cursors/pen-tool.png?v=4") 4 4, default !important;
}
.canvas-stage.is-path-pen-tool .pen-tool-controls {
cursor: default !important;
}
.canvas-stage.is-path-pen-tool .pen-tool-controls button {
cursor: pointer !important;
}
.canvas-stage.is-path-pen-tool .pen-tool-controls input {
cursor: text !important;
}
.canvas-stage.is-text-tool,
.canvas-stage.is-text-tool .canvas-world,
.canvas-stage.is-text-tool .canvas-node,
@@ -1745,6 +1757,14 @@ button:disabled {
background: transparent;
}
.workspace-history-preview-shield {
position: absolute;
inset: 0;
z-index: 43;
cursor: default;
background: transparent;
}
.canvas-node {
position: absolute;
overflow: hidden;
@@ -2717,7 +2737,7 @@ button:disabled {
bottom: 0;
width: var(--layer-panel-width);
display: grid;
grid-template-rows: 56px 158px minmax(0, 1fr);
grid-template-rows: 56px auto minmax(0, 1fr);
border-right: 1px solid #eceef2;
background: rgba(255, 255, 255, 0.97);
box-shadow: 8px 0 28px rgba(17, 24, 39, 0.06);
@@ -2761,8 +2781,14 @@ button:disabled {
.layer-history-section {
display: grid;
grid-template-rows: 42px 1fr;
grid-template-rows: 42px;
max-height: 274px;
border-bottom: 1px solid #eceef2;
overflow: hidden;
}
.layer-history-section.is-expanded {
grid-template-rows: 42px minmax(0, 1fr);
}
.layer-history-section > button {
@@ -2780,19 +2806,168 @@ button:disabled {
min-width: 0;
}
.layer-history-heading-meta {
display: inline-flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
}
.layer-history-heading-meta small {
color: #a0a6b0;
font-size: 10px;
font-variant-numeric: tabular-nums;
font-weight: 700;
}
.layer-history-empty {
display: grid;
place-items: center;
align-content: center;
gap: 6px;
min-height: 96px;
gap: 8px;
color: #c7cbd3;
font-size: 13px;
font-weight: 560;
}
.layer-history-empty svg {
fill: #e1e4ea;
stroke: #e1e4ea;
color: #d3d7de;
}
.layer-history-list {
min-height: 0;
margin: 0;
padding: 0 8px 10px 14px;
list-style: none;
overflow-y: auto;
}
.layer-history-list li {
position: relative;
}
.layer-history-list li:not(:last-child)::after {
content: "";
position: absolute;
z-index: 0;
left: 11px;
top: 31px;
bottom: -9px;
width: 1px;
background: #e1e4e9;
}
.layer-history-entry {
position: relative;
z-index: 1;
width: 100%;
min-height: 39px;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
padding: 4px 7px 4px 0;
border: 0;
border-radius: 6px;
color: #4c535d;
background: transparent;
text-align: left;
}
.layer-history-preview {
min-width: 0;
display: grid;
grid-template-columns: 23px minmax(0, 1fr);
align-items: center;
gap: 8px;
padding: 0;
color: inherit;
background: transparent;
text-align: left;
}
.layer-history-entry:hover:not(.active) {
color: #20252c;
background: #f3f4f6;
}
.layer-history-entry.active {
color: #1559bd;
background: #edf4ff;
}
.layer-history-entry.previewing:not(.active) {
color: #303b4a;
background: #f0f3f7;
box-shadow: inset 0 0 0 1px #dfe4eb;
}
.layer-history-marker {
width: 23px;
height: 23px;
display: grid;
place-items: center;
border: 1px solid #d7dbe1;
border-radius: 50%;
color: #8b929d;
background: #ffffff;
}
.layer-history-entry.active .layer-history-marker {
border-color: #2f80ed;
color: #ffffff;
background: #2f80ed;
}
.layer-history-copy {
min-width: 0;
display: grid;
gap: 1px;
}
.layer-history-copy strong {
overflow: hidden;
font-size: 12px;
font-weight: 680;
line-height: 1.25;
text-overflow: ellipsis;
white-space: nowrap;
}
.layer-history-copy small {
color: #a0a6b0;
font-size: 9px;
font-variant-numeric: tabular-nums;
}
.layer-history-current {
color: #2f6fc3;
font-size: 9px;
font-weight: 760;
}
.layer-history-restore {
width: 26px;
height: 26px;
display: grid;
place-items: center;
padding: 0;
border-radius: 6px;
color: #9ca3ad;
background: transparent;
opacity: 0;
transition: color 120ms ease, background 120ms ease, opacity 120ms ease;
}
.layer-history-entry:hover .layer-history-restore,
.layer-history-restore:focus-visible {
opacity: 1;
}
.layer-history-restore:hover {
color: #4e5968;
background: #e3e7ed;
}
.layer-list {
@@ -3356,7 +3531,7 @@ button:disabled {
position: absolute;
left: 50%;
bottom: 66px;
z-index: 34;
z-index: 42;
height: 52px;
display: flex;
align-items: center;
File diff suppressed because one or more lines are too long