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", "layers": "Layers",
"layerHistory": "History", "layerHistory": "History",
"layerNoHistory": "No history yet", "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", "layerNoLayers": "No layers",
"generatedFiles": "Generated files", "generatedFiles": "Generated files",
"generatedFilesEmpty": "No generated files yet", "generatedFilesEmpty": "No generated files yet",
+13
View File
@@ -460,6 +460,19 @@
"layers": "图层", "layers": "图层",
"layerHistory": "历史记录", "layerHistory": "历史记录",
"layerNoHistory": "暂无历史记录", "layerNoHistory": "暂无历史记录",
"historyCurrent": "当前",
"historyRestore": "恢复到此状态",
"historyRestored": "已恢复到所选历史状态",
"historyBooleanOperation": "布尔运算 · {operation}",
"historyAdd": "添加 · {name}",
"historyDelete": "删除 · {name}",
"historyEditPath": "编辑路径 · {name}",
"historyMove": "移动 · {name}",
"historyResize": "调整尺寸 · {name}",
"historyStyle": "调整样式 · {name}",
"historyRename": "重命名 · {name}",
"historyReorder": "调整图层顺序",
"historyEdit": "编辑画布 · {name}",
"layerNoLayers": "暂无图层", "layerNoLayers": "暂无图层",
"generatedFiles": "已生成文件列表", "generatedFiles": "已生成文件列表",
"generatedFilesEmpty": "暂无已生成文件", "generatedFilesEmpty": "暂无已生成文件",
@@ -1,11 +1,12 @@
"use client"; "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 { Fragment, useEffect, useMemo, useRef, useState, type DragEvent as ReactDragEvent } from "react";
import type { AgentMessage, CanvasNode, CanvasViewport } from "@/domain/design"; import type { AgentMessage, CanvasNode, CanvasViewport } from "@/domain/design";
import { useI18n } from "@/i18n/i18n"; import { useI18n } from "@/i18n/i18n";
import { canvasNodeRenderContent, thumbnailImageUrl } from "@/ui/lib/imageDelivery"; import { canvasNodeRenderContent, thumbnailImageUrl } from "@/ui/lib/imageDelivery";
import { isFrameContainerNode, type LayerReorderItem, type LayerReorderPlacement, type LayerReorderRequest } from "@/ui/pages/CanvasWorkspace/canvas/canvasNodeOps"; 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 { ShapeNodePreview } from "@/ui/pages/CanvasWorkspace/canvas/ShapeNodePreview";
import { isBooleanShapeGroupNode, isBooleanShapeOperandNode, isShapeNode } from "@/ui/pages/CanvasWorkspace/canvas/shapeNode"; import { isBooleanShapeGroupNode, isBooleanShapeOperandNode, isShapeNode } from "@/ui/pages/CanvasWorkspace/canvas/shapeNode";
@@ -132,6 +133,11 @@ export function CanvasLayerPanel({
onToggleHiddenMany, onToggleHiddenMany,
onToggleLockedMany, onToggleLockedMany,
onReorderLayer, onReorderLayer,
historyEntries,
activeHistoryId,
previewHistoryId,
onPreviewHistory,
onRestoreHistory,
onClose onClose
}: { }: {
nodes: CanvasNode[]; nodes: CanvasNode[];
@@ -146,10 +152,16 @@ export function CanvasLayerPanel({
onToggleHiddenMany: (nodeIds: string[]) => void; onToggleHiddenMany: (nodeIds: string[]) => void;
onToggleLockedMany: (nodeIds: string[]) => void; onToggleLockedMany: (nodeIds: string[]) => void;
onReorderLayer: (request: LayerReorderRequest) => 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; onClose: () => void;
}) { }) {
const { t } = useI18n(); const { locale, t } = useI18n();
const layers = useMemo(() => buildLayerPanelItems(nodes), [nodes]); const layers = useMemo(() => buildLayerPanelItems(nodes), [nodes]);
const [historyExpanded, setHistoryExpanded] = useState(true);
const [collapsedLayerIds, setCollapsedLayerIds] = useState<Set<string>>(() => new Set()); const [collapsedLayerIds, setCollapsedLayerIds] = useState<Set<string>>(() => new Set());
const [draggingItem, setDraggingItem] = useState<LayerReorderItem | null>(null); const [draggingItem, setDraggingItem] = useState<LayerReorderItem | null>(null);
const [dropTarget, setDropTarget] = useState<{ item: LayerReorderItem; placement: LayerReorderPlacement } | null>(null); const [dropTarget, setDropTarget] = useState<{ item: LayerReorderItem; placement: LayerReorderPlacement } | null>(null);
@@ -213,15 +225,48 @@ export function CanvasLayerPanel({
<X size={17} /> <X size={17} />
</button> </button>
</header> </header>
<section className="layer-history-section"> <section className={`layer-history-section ${historyExpanded ? "is-expanded" : ""}`}>
<button type="button"> <button type="button" aria-expanded={historyExpanded} onClick={() => setHistoryExpanded((expanded) => !expanded)}>
<span>{t("layerHistory")}</span> <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> </button>
<div className="layer-history-empty"> {historyExpanded &&
<ImageIcon size={58} /> (historyEntries.length === 0 ? (
<span>{t("layerNoHistory")}</span> <div className="layer-history-empty">
</div> <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> </section>
<div className="layer-list"> <div className="layer-list">
{layers.length === 0 ? ( {layers.length === 0 ? (
+224 -50
View File
@@ -50,6 +50,8 @@ import { isImageGeneratorThreadMessage, visibleAgentMessages } from "./canvas/ag
import { CanvasColorPopover } from "./canvas/CanvasColorPopover"; 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 { 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 { 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 { CanvasSelectionFrame } from "@/ui/pages/CanvasWorkspace/canvas/CanvasSelectionFrame";
import { CanvasPathEditor, type CanvasPathEditorControl } from "@/ui/pages/CanvasWorkspace/canvas/CanvasPathEditor"; import { CanvasPathEditor, type CanvasPathEditorControl } from "@/ui/pages/CanvasWorkspace/canvas/CanvasPathEditor";
import { createCroppedSvgRasterSource, createVectorCropSiblingNode } from "./canvas/cadCrop"; import { createCroppedSvgRasterSource, createVectorCropSiblingNode } from "./canvas/cadCrop";
@@ -61,7 +63,7 @@ import { beginFrameDrawing, FrameDraftOverlay, type FrameDraft, type FrameWorldR
import { FrameStyleToolbar } from "./canvas/FrameStyleToolbar"; import { FrameStyleToolbar } from "./canvas/FrameStyleToolbar";
import { defaultFrameStrokeColor, defaultFrameStrokeStyle, defaultFrameStrokeWidth, normalizeHexColor } from "./canvas/frameStyle"; import { defaultFrameStrokeColor, defaultFrameStrokeStyle, defaultFrameStrokeWidth, normalizeHexColor } from "./canvas/frameStyle";
import { generatorTaskPollIntervalMs } from "@/ui/pages/CanvasWorkspace/canvas/generatorTaskPolling"; 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 { useCanvasDocument } from "./canvas/hooks/useCanvasDocument";
import { useCanvasPersistence } from "./canvas/hooks/useCanvasPersistence"; import { useCanvasPersistence } from "./canvas/hooks/useCanvasPersistence";
import { useCanvasSelection } from "./canvas/hooks/useCanvasSelection"; import { useCanvasSelection } from "./canvas/hooks/useCanvasSelection";
@@ -70,13 +72,14 @@ import { imageGeneratorPlaceholderSize, imageGeneratorTargetSizeFromNode, imageG
import { canvasNodeToReferenceImage, isAssetReferencedByCanvas, isCanvasNodeReferenceImage, withLastCanvasReferenceAnchor } from "./canvas/nodeReferenceImages"; import { canvasNodeToReferenceImage, isAssetReferencedByCanvas, isCanvasNodeReferenceImage, withLastCanvasReferenceAnchor } from "./canvas/nodeReferenceImages";
import { canvasNodeSvg, exportCanvasNode, exportImageNode, exportTextNode, imageNodeRasterBlob, svgToRasterBlob } from "./canvas/nodeExport"; 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 { 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 { PenStrokeToolbar, updatePenStrokeNode, type PenStrokePatch } from "./canvas/PenStrokeToolbar";
import { ShapeNodePreview } from "@/ui/pages/CanvasWorkspace/canvas/ShapeNodePreview"; import { ShapeNodePreview } from "@/ui/pages/CanvasWorkspace/canvas/ShapeNodePreview";
import { ShapeStyleToolbar } from "@/ui/pages/CanvasWorkspace/canvas/ShapeStyleToolbar"; import { ShapeStyleToolbar } from "@/ui/pages/CanvasWorkspace/canvas/ShapeStyleToolbar";
import { beginShapeDrawing, ShapeDraftOverlay, type ShapeDraft } from "@/ui/pages/CanvasWorkspace/canvas/ShapeToolOverlay"; import { beginShapeDrawing, ShapeDraftOverlay, type ShapeDraft } from "@/ui/pages/CanvasWorkspace/canvas/ShapeToolOverlay";
import { import {
createBooleanShapeGroupNode, booleanShapeGroupToPathNode,
createBooleanShapeResultNode,
createShapeNode, createShapeNode,
isBooleanCapableShapeNode, isBooleanCapableShapeNode,
isBooleanShapeGroupNode, isBooleanShapeGroupNode,
@@ -86,7 +89,6 @@ import {
patchShapeStyleNode, patchShapeStyleNode,
resizeLineShapeEndpoint, resizeLineShapeEndpoint,
shapeTitle, shapeTitle,
updateBooleanShapeOperation,
type BooleanShapeOperation, type BooleanShapeOperation,
type ShapeEndpointKey, type ShapeEndpointKey,
type ShapeKind, type ShapeKind,
@@ -627,8 +629,15 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
const pathEditorExitToolRef = useRef<CanvasTool | null>(null); const pathEditorExitToolRef = useRef<CanvasTool | null>(null);
const pathPenDraftRef = useRef<PathPenDraft | null>(null); const pathPenDraftRef = useRef<PathPenDraft | null>(null);
const pathPenDraggingRef = useRef(false); 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 { project, setProject, projectRef, viewport, setViewport, viewportRef, nodes, setNodes, nodesRef, applyProjectDocument } = useCanvasDocument();
const { selectedId, selectedIds, setSelectedIds, selectedNodes, selectedNode, selectOnlyNode, selectNodes, toggleNodeSelection } = useCanvasSelection(nodes); 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 [messages, setMessages] = useState<AgentMessage[]>([]);
const [agentHistoryLoading, setAgentHistoryLoading] = useState(canViewChat); const [agentHistoryLoading, setAgentHistoryLoading] = useState(canViewChat);
const [prompt, setPrompt] = useState(""); const [prompt, setPrompt] = useState("");
@@ -911,7 +920,7 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
nodesRef, nodesRef,
dirty, dirty,
anyGenerating, anyGenerating,
disabled: !canEdit, disabled: !canEdit || Boolean(previewCanvasHistoryId),
setProject, setProject,
setDirty, setDirty,
setSaving, 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(() => { useEffect(() => {
if (!agentGenerating) { if (!agentGenerating) {
activeAgentThreadIdRef.current = null; activeAgentThreadIdRef.current = null;
@@ -1144,6 +1242,12 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
const selectedBooleanShapeGroupNode = selectedControlNode && isBooleanShapeGroupNode(selectedControlNode) ? selectedControlNode : null; const selectedBooleanShapeGroupNode = selectedControlNode && isBooleanShapeGroupNode(selectedControlNode) ? selectedControlNode : null;
const selectedTextNode = selectedControlNode?.type === "text" ? selectedControlNode : null; const selectedTextNode = selectedControlNode?.type === "text" ? selectedControlNode : null;
const pathEditingNode = pathEditingNodeId ? nodes.find((node) => node.id === pathEditingNodeId && isEditablePathNode(node)) ?? null : 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 hasCurrentConversation = messages.some((message) => message.content.trim() || message.title.trim());
const selectedNodeBounds = useMemo<NodeScreenBounds | null>(() => { const selectedNodeBounds = useMemo<NodeScreenBounds | null>(() => {
if (!selectedImageNode) return null; if (!selectedImageNode) return null;
@@ -1638,7 +1742,7 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
}, []); }, []);
const commitPathPenDrawing = useCallback( const commitPathPenDrawing = useCallback(
(draft: PathPenDraft, closed = false, editAfterCommit = true) => { (draft: PathPenDraft, closed = false) => {
const completed = closed ? closePathPenDraft(draft) : draft; const completed = closed ? closePathPenDraft(draft) : draft;
if (!canCommitPathPenDraft(completed)) return false; if (!canCommitPathPenDraft(completed)) return false;
const node = createPathPenNode(completed, t("pathPen")); const node = createPathPenNode(completed, t("pathPen"));
@@ -1648,12 +1752,8 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
})); }));
updatePathPenDraft(null); updatePathPenDraft(null);
selectOnlyNode(node.id); selectOnlyNode(node.id);
setActiveTool(editAfterCommit ? "path" : "select"); setActiveTool("path");
setMode("select"); setMode("select");
if (editAfterCommit) {
pathEditorExitToolRef.current = null;
setPathEditingNodeId(node.id);
}
setDirty(true); setDirty(true);
return true; return true;
}, },
@@ -1661,9 +1761,9 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
); );
const finishPathPenDrawing = useCallback( const finishPathPenDrawing = useCallback(
(closed = false, editAfterCommit = true) => { (closed = false) => {
const draft = pathPenDraftRef.current; const draft = pathPenDraftRef.current;
if (!draft || !commitPathPenDrawing(draft, closed, editAfterCommit)) updatePathPenDraft(null); if (!draft || !commitPathPenDrawing(draft, closed)) updatePathPenDraft(null);
}, },
[commitPathPenDrawing, updatePathPenDraft] [commitPathPenDrawing, updatePathPenDraft]
); );
@@ -2053,7 +2153,7 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
const currentPathPenDraft = pathPenDraftRef.current; const currentPathPenDraft = pathPenDraftRef.current;
if (currentPathPenDraft) { if (currentPathPenDraft) {
if (canCommitPathPenDraft(currentPathPenDraft)) { if (canCommitPathPenDraft(currentPathPenDraft)) {
commitPathPenDrawing(currentPathPenDraft, false, false); commitPathPenDrawing(currentPathPenDraft);
} else { } else {
updatePathPenDraft(null); updatePathPenDraft(null);
} }
@@ -2188,7 +2288,7 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
}; };
const commitPathEditing = (nodeId: string, pathData: EditablePathData) => { 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; nodesRef.current = nextNodes;
setNodes(nextNodes); setNodes(nextNodes);
setDirty(true); setDirty(true);
@@ -2594,31 +2694,36 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
const applyBooleanShapeOperation = (operation: BooleanShapeOperation) => { const applyBooleanShapeOperation = (operation: BooleanShapeOperation) => {
const selectedBooleanGroup = nodesRef.current.find((node) => selectedIds.has(node.id) && isBooleanShapeGroupNode(node)); const selectedBooleanGroup = nodesRef.current.find((node) => selectedIds.has(node.id) && isBooleanShapeGroupNode(node));
if (selectedBooleanGroup) { 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; nodesRef.current = nextNodes;
setNodes(nextNodes); setNodes(nextNodes);
selectOnlyNode(selectedBooleanGroup.id); selectOnlyNode(resultNode.id);
setDirty(true); setDirty(true);
return; return;
} }
const selected = nodesRef.current.filter((node) => selectedIds.has(node.id)); const selected = nodesRef.current.filter((node) => selectedIds.has(node.id));
if (selected.length < 2 || !selected.every(isBooleanCapableShapeNode)) return; if (selected.length < 2 || !selected.every(isBooleanCapableShapeNode)) return;
const groupId = `boolean-${cryptoFallbackId()}`; const resultNode = createBooleanShapeResultNode({
const groupNode = createBooleanShapeGroupNode({ id: `boolean-${cryptoFallbackId()}`,
id: groupId,
title: operation.toUpperCase(), title: operation.toUpperCase(),
operation, operation,
nodes: selected nodes: selected
}); });
const selectedSet = new Set(selected.map((node) => node.id)); 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 highestSelectedIndex = Math.max(...nodesRef.current.map((node, index) => (selectedSet.has(node.id) ? index : -1)));
const nextNodes = nodesRef.current const nextNodes = nodesRef.current.flatMap((node, index) => {
.map((node) => (selectedSet.has(node.id) ? { ...node, parentId: groupId } : node)) if (index === highestSelectedIndex) return [resultNode];
.flatMap((node, index) => (index === highestSelectedIndex ? [node, groupNode] : [node])); return selectedSet.has(node.id) ? [] : [node];
});
nodesRef.current = nextNodes; nodesRef.current = nextNodes;
setNodes(nextNodes); setNodes(nextNodes);
selectOnlyNode(groupId); selectOnlyNode(resultNode.id);
setDirty(true); setDirty(true);
}; };
@@ -2752,8 +2857,23 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
return true; 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 pasteFromClipboardData = (dataTransfer: DataTransfer) => {
const anchor = clipboardPasteAnchor(); const anchor = clipboardPasteAnchor();
const canvasNode = canvasNodeFromClipboardData(dataTransfer);
if (canvasNode) {
pasteClipboardNode(canvasNode, anchor);
return true;
}
const imageFile = latestDataTransferImageFile(dataTransfer, "clipboard-image"); const imageFile = latestDataTransferImageFile(dataTransfer, "clipboard-image");
if (imageFile) { if (imageFile) {
void uploadImagesToCanvas([imageFile], anchor); void uploadImagesToCanvas([imageFile], anchor);
@@ -2764,13 +2884,11 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
const pasteFromSystemClipboard = async () => { const pasteFromSystemClipboard = async () => {
const anchor = clipboardPasteAnchor(); const anchor = clipboardPasteAnchor();
const imageFile = await latestNavigatorClipboardImageFile("clipboard-image").catch(() => null); const clipboard = await latestNavigatorClipboardContent("clipboard-image").catch(() => null);
if (imageFile) { if (!clipboard) return;
await uploadImagesToCanvas([imageFile], anchor); if (clipboard.canvasNode) return void pasteClipboardNode(clipboard.canvasNode, anchor);
return; if (clipboard.imageFile) return void (await uploadImagesToCanvas([clipboard.imageFile], anchor));
} if (clipboard.text) pasteClipboardText(clipboard.text, anchor);
const text = await navigator.clipboard?.readText?.().catch(() => "");
if (text) pasteClipboardText(text, anchor);
}; };
const sendNodeToAgentComposer = (node: CanvasNode) => { const sendNodeToAgentComposer = (node: CanvasNode) => {
@@ -3056,7 +3174,7 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
pathEditorExitToolRef.current = tool; pathEditorExitToolRef.current = tool;
pathEditorControlRef.current?.close(); pathEditorControlRef.current?.close();
} }
if (tool !== "path" && pathPenDraftRef.current) finishPathPenDrawing(false, false); if (tool !== "path" && pathPenDraftRef.current) finishPathPenDrawing();
if (tool !== "crop") { if (tool !== "crop") {
workspaceCropDetectionRequestRef.current += 1; workspaceCropDetectionRequestRef.current += 1;
setWorkspaceCropDetecting(false); setWorkspaceCropDetecting(false);
@@ -4198,6 +4316,11 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
onToggleHiddenMany={toggleLayersHidden} onToggleHiddenMany={toggleLayersHidden}
onToggleLockedMany={toggleLayersLocked} onToggleLockedMany={toggleLayersLocked}
onReorderLayer={reorderLayer} onReorderLayer={reorderLayer}
historyEntries={canvasHistoryEntries}
activeHistoryId={activeCanvasHistoryId}
previewHistoryId={previewCanvasHistoryId}
onPreviewHistory={previewCanvasHistory}
onRestoreHistory={restoreCanvasHistory}
onClose={() => setLayersPanelOpen(false)} onClose={() => setLayersPanelOpen(false)}
/> />
)} )}
@@ -4623,6 +4746,7 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
)} )}
{!canEdit && <div className="workspace-readonly-shield" aria-hidden="true" />} {!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" : ""}`}> <div className={`canvas-bottom-left ${generatedFilesPanelOpen ? "is-offset-by-files-panel" : layersPanelOpen ? "is-offset-by-layers-panel" : ""}`}>
{showMinimap && ( {showMinimap && (
@@ -4713,14 +4837,18 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
/> />
{canEdit && (activeTool === "pen" || activeTool === "path") && ( {canEdit && (activeTool === "pen" || activeTool === "path") && (
<PenToolControls <PenToolControls
settings={activeTool === "path" ? pathPenSettings : penSettings} settings={activeTool === "path" ? pathPenControlSettings : penSettings}
variant={activeTool === "path" ? "path" : "brush"} variant={activeTool === "path" ? "path" : "brush"}
offsetBy={generatedFilesPanelOpen ? "files" : layersPanelOpen ? "layers" : null} offsetBy={generatedFilesPanelOpen ? "files" : layersPanelOpen ? "layers" : null}
onChange={(settings) => { onChange={(settings) => {
if (activeTool === "path") { if (activeTool === "path") {
setPathPenSettings(settings); setPathPenSettings(settings);
const draft = pathPenDraftRef.current; 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; return;
} }
setPenSettings(settings); setPenSettings(settings);
@@ -6089,12 +6217,12 @@ function canvasBackgroundStorageKey(projectId: string) {
async function writeCanvasNodeToSystemClipboard(node: CanvasNode) { async function writeCanvasNodeToSystemClipboard(node: CanvasNode) {
if (node.type === "text") { if (node.type === "text") {
await writeClipboardText(node.content || node.title); await writeClipboardTextNode(node);
return; return;
} }
const blob = await clipboardImageBlobForNode(node); const blob = await clipboardImageBlobForNode(node);
await writeClipboardBlob(blob); await writeClipboardBlob(blob, node);
} }
async function clipboardImageBlobForNode(node: CanvasNode) { async function clipboardImageBlobForNode(node: CanvasNode) {
@@ -6106,20 +6234,46 @@ async function clipboardImageBlobForNode(node: CanvasNode) {
return svgToRasterBlob(canvasNodeSvg(node), width, height, "image/png"); 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") { if (!navigator.clipboard?.write || typeof ClipboardItem === "undefined") {
throw new Error("System clipboard image writes are not available"); throw new Error("System clipboard image writes are not available");
} }
await navigator.clipboard.write([new ClipboardItem({ [blob.type || "image/png"]: blob })]); const imageType = blob.type || "image/png";
} if (node) {
const serializedNode = serializeCanvasNodeClipboard(node);
async function writeClipboardText(text: string) { const serializedSvg = serializeCanvasNodeClipboardSvg(node, canvasNodeSvg(node));
if (navigator.clipboard?.writeText) { const customData = new Blob([serializedNode], { type: canvasNodeClipboardBaseMime });
const htmlData = new Blob([serializedSvg], { type: "text/html" });
const textData = new Blob([serializedSvg], { type: "text/plain" });
try { try {
await navigator.clipboard.writeText(text); await navigator.clipboard.write([new ClipboardItem({ [imageType]: blob, [canvasNodeClipboardMime]: customData, "text/html": htmlData, "text/plain": textData })]);
return; return;
} catch { } 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"; textarea.style.opacity = "0";
document.body.append(textarea); document.body.append(textarea);
textarea.select(); 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"); const copied = document.execCommand("copy");
document.removeEventListener("copy", handleCopy);
textarea.remove(); textarea.remove();
if (!copied) throw new Error("System clipboard text write failed"); 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; return dataTransferImageFiles(dataTransfer, fallbackBaseName).at(-1) ?? null;
} }
async function latestNavigatorClipboardImageFile(fallbackBaseName = "clipboard-image") { async function latestNavigatorClipboardContent(fallbackBaseName = "clipboard-image") {
if (!navigator.clipboard?.read) return null; 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 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) { for (let itemIndex = items.length - 1; itemIndex >= 0; itemIndex -= 1) {
const item = items[itemIndex]; const item = items[itemIndex];
const type = item.types.filter((itemType) => itemType.startsWith("image/")).at(-1); const type = item.types.filter((itemType) => itemType.startsWith("image/")).at(-1);
if (!type) continue; if (type) {
const blob = await item.getType(type); const blob = await item.getType(type);
return ensureImageFileName(new File([blob], "", { type: blob.type || type, lastModified: Date.now() }), `${fallbackBaseName}-${Date.now()}-${itemIndex + 1}`); 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) { 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; 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-stage.is-text-tool .canvas-world, .canvas-stage.is-text-tool .canvas-world,
.canvas-stage.is-text-tool .canvas-node, .canvas-stage.is-text-tool .canvas-node,
@@ -1745,6 +1757,14 @@ button:disabled {
background: transparent; background: transparent;
} }
.workspace-history-preview-shield {
position: absolute;
inset: 0;
z-index: 43;
cursor: default;
background: transparent;
}
.canvas-node { .canvas-node {
position: absolute; position: absolute;
overflow: hidden; overflow: hidden;
@@ -2717,7 +2737,7 @@ button:disabled {
bottom: 0; bottom: 0;
width: var(--layer-panel-width); width: var(--layer-panel-width);
display: grid; display: grid;
grid-template-rows: 56px 158px minmax(0, 1fr); grid-template-rows: 56px auto minmax(0, 1fr);
border-right: 1px solid #eceef2; border-right: 1px solid #eceef2;
background: rgba(255, 255, 255, 0.97); background: rgba(255, 255, 255, 0.97);
box-shadow: 8px 0 28px rgba(17, 24, 39, 0.06); box-shadow: 8px 0 28px rgba(17, 24, 39, 0.06);
@@ -2761,8 +2781,14 @@ button:disabled {
.layer-history-section { .layer-history-section {
display: grid; display: grid;
grid-template-rows: 42px 1fr; grid-template-rows: 42px;
max-height: 274px;
border-bottom: 1px solid #eceef2; border-bottom: 1px solid #eceef2;
overflow: hidden;
}
.layer-history-section.is-expanded {
grid-template-rows: 42px minmax(0, 1fr);
} }
.layer-history-section > button { .layer-history-section > button {
@@ -2780,19 +2806,168 @@ button:disabled {
min-width: 0; 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 { .layer-history-empty {
display: grid; display: grid;
place-items: center; place-items: center;
align-content: center; align-content: center;
gap: 6px; min-height: 96px;
gap: 8px;
color: #c7cbd3; color: #c7cbd3;
font-size: 13px; font-size: 13px;
font-weight: 560; font-weight: 560;
} }
.layer-history-empty svg { .layer-history-empty svg {
fill: #e1e4ea; color: #d3d7de;
stroke: #e1e4ea; }
.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 { .layer-list {
@@ -3356,7 +3531,7 @@ button:disabled {
position: absolute; position: absolute;
left: 50%; left: 50%;
bottom: 66px; bottom: 66px;
z-index: 34; z-index: 42;
height: 52px; height: 52px;
display: flex; display: flex;
align-items: center; align-items: center;
File diff suppressed because one or more lines are too long