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:
@@ -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",
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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
@@ -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
Reference in New Issue
Block a user