feat(web): improve diagram canvas navigation

This commit is contained in:
tianma-if committed 2026-09-07 18:11:30 +08:00
1 parent b70a88703c
commit f6f0412fdf
6 files changed
+195 -15

No files matched your search

@@ -39,6 +39,41 @@ describe("diagram editor keyboard workflow", () => {
});
describe("diagram editor canvas surface", () => {
test("shows native horizontal and vertical scrollbars for oversized diagrams", () => {
expect(source).toContain("new Scroller({");
expect(source).toContain("panning: false");
expect(source).toContain('className: "edgeever-diagram-scroller"');
expect(source).toContain('pannable: { enabled: activeCanvasModeRef.current === "pan", eventTypes: ["leftMouseDown"] }');
expect(source).not.toContain("attachDiagramScroll");
expect(globalStyles).toContain(".edgeever-diagram-scroller");
expect(globalStyles).toContain("scrollbar-gutter: stable");
expect(globalStyles).toContain('data-panning="false"');
expect(globalStyles).toContain("cursor: grab");
expect(globalStyles).toContain('data-panning="true"');
expect(globalStyles).toContain("cursor: grabbing");
});
test("keeps grab-to-pan and rubberband selection mutually exclusive", () => {
expect(source).toContain('scroller?.togglePanning(activeCanvasMode === "pan")');
expect(source).toContain('selection?.toggleEnabled(activeCanvasMode === "select")');
expect(source).toContain('selection?.toggleRubberband(activeCanvasMode === "select")');
expect(source).toContain('interacting: () => !readOnly && activeCanvasModeRef.current === "select"');
expect(source).toContain('data-canvas-mode={activeCanvasMode}');
});
test("offers explicit select and hand modes with keyboard and temporary pan shortcuts", () => {
expect(toolbarSource).toContain('canvasMode: "select" | "pan"');
expect(toolbarSource).toContain('aria-label={canvasMode === "pan" ? t("diagram.panMode") : t("diagram.selectMode")}');
expect(toolbarSource).toContain('onClick={() => onCanvasModeChange(canvasMode === "pan" ? "select" : "pan")}');
expect(toolbarSource.match(/onCanvasModeChange\(/g)).toHaveLength(1);
expect(source).toContain('if (typeof window === "undefined") return "pan"');
expect(source).toContain('=== "select" ? "select" : "pan"');
expect(source).toContain('key === "v" || key === "h"');
expect(source).toContain('event.code !== "Space"');
expect(source).toContain('setSpacePanActive(true)');
expect(source).toContain('setSpacePanActive(false)');
});
test("uses the common note header and capability-aware more menu", () => {
expect(source).toContain("<MemoEditorTopRowLeading");
expect(topRowLeadingSource).toContain('<span className="hidden truncate text-xs text-slate-400 sm:inline">{updatedLabel}</span>');
+102 -15
View File
@@ -1,7 +1,7 @@
import { diagramEditorSnapshot } from "@/lib/diagram-editor-snapshot";
import { MemoTitleInput } from "@/components/MemoTitleInput";
import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent as ReactDragEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from "react";
import { Dom, Export, Graph, History, Keyboard, Selection, type Edge, type Node } from "@antv/x6";
import { Dom, Export, Graph, History, Keyboard, Scroller, Selection, type Edge, type Node } from "@antv/x6";
import * as m from "motion/react-m";
import {
Activity,
@@ -64,7 +64,6 @@ import {
} from "lucide-react";
import { useTranslation } from "react-i18next";
import {
attachDiagramScroll,
ARCHITECTURE_DIAGRAM_SCHEMA_VERSION,
DIAGRAM_SCHEMA_VERSION,
diagramFallbackMarkdown,
@@ -136,6 +135,27 @@ type DiagramEditorPaneProps = {
companionDiscoveryHub?: ReactNode;
};
type DiagramCanvasMode = "select" | "pan";
const DIAGRAM_CANVAS_MODE_STORAGE_KEY = "edgeever.diagram.canvas-mode";
const readDiagramCanvasMode = (): DiagramCanvasMode => {
if (typeof window === "undefined") return "pan";
try {
return window.localStorage.getItem(DIAGRAM_CANVAS_MODE_STORAGE_KEY) === "select" ? "select" : "pan";
} catch {
return "pan";
}
};
const saveDiagramCanvasMode = (mode: DiagramCanvasMode) => {
try {
window.localStorage.setItem(DIAGRAM_CANVAS_MODE_STORAGE_KEY, mode);
} catch {
// Local storage can be unavailable in private or restricted browser contexts.
}
};
type NodeData = { label: string; shape: DiagramNodeShape; parentId?: string; resourceIcon?: ArchitectureResourceIcon };
type EdgeData = { kind?: DiagramEdgeKind; bidirectional?: boolean };
type MindMapInsertRelation = "child" | "sibling";
@@ -1069,6 +1089,10 @@ export const DiagramEditorPane = ({
const [notebookUpdatePending, setNotebookUpdatePending] = useState(false);
const searchInputRef = useRef<HTMLInputElement | null>(null);
const [zoomPercent, setZoomPercent] = useState(100);
const [canvasMode, setCanvasMode] = useState<DiagramCanvasMode>(() => readOnly ? "pan" : readDiagramCanvasMode());
const [spacePanActive, setSpacePanActive] = useState(false);
const activeCanvasMode: DiagramCanvasMode = spacePanActive ? "pan" : canvasMode;
const activeCanvasModeRef = useRef<DiagramCanvasMode>(activeCanvasMode);
const [historyState, setHistoryState] = useState({ undo: false, redo: false });
const [nodeEditor, setNodeEditor] = useState<NodeEditorState | null>(null);
const [flowQuickCreate, setFlowQuickCreate] = useState<FlowQuickCreateState | null>(null);
@@ -1079,10 +1103,64 @@ export const DiagramEditorPane = ({
const nodeEditorRef = useRef<NodeEditorState | null>(null);
const editorDirty = dirty || tagsDirty;
activeCanvasModeRef.current = activeCanvasMode;
useEffect(() => {
setPendingArchitectureItem(null);
}, [memo.id]);
const changeCanvasMode = useCallback((mode: DiagramCanvasMode) => {
setSpacePanActive(false);
if (mode === "pan") setPendingArchitectureItem(null);
setCanvasMode(mode);
saveDiagramCanvasMode(mode);
containerRef.current?.focus({ preventScroll: true });
}, []);
useEffect(() => {
setSpacePanActive(false);
setCanvasMode(readOnly ? "pan" : readDiagramCanvasMode());
}, [readOnly]);
useEffect(() => {
const graph = graphRef.current;
if (!graph) return;
const scroller = graph.getPlugin<Scroller>("scroller");
const selection = graph.getPlugin<Selection>("selection");
scroller?.togglePanning(activeCanvasMode === "pan");
selection?.toggleEnabled(activeCanvasMode === "select");
selection?.toggleRubberband(activeCanvasMode === "select");
}, [activeCanvasMode]);
useEffect(() => {
const isTextInput = (target: EventTarget | null) => target instanceof HTMLElement
&& (target.isContentEditable || ["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName));
const handleCanvasKeyDown = (event: KeyboardEvent) => {
if (event.metaKey || event.ctrlKey || event.altKey || isTextInput(event.target)) return;
const key = event.key.toLowerCase();
if (key === "v" || key === "h") {
event.preventDefault();
changeCanvasMode(key === "v" ? "select" : "pan");
return;
}
if (event.code !== "Space" || !(event.target instanceof globalThis.Node) || !containerRef.current?.contains(event.target)) return;
event.preventDefault();
setSpacePanActive(true);
};
const handleCanvasKeyUp = (event: KeyboardEvent) => {
if (event.code === "Space") setSpacePanActive(false);
};
const releaseTemporaryPan = () => setSpacePanActive(false);
window.addEventListener("keydown", handleCanvasKeyDown);
window.addEventListener("keyup", handleCanvasKeyUp);
window.addEventListener("blur", releaseTemporaryPan);
return () => {
window.removeEventListener("keydown", handleCanvasKeyDown);
window.removeEventListener("keyup", handleCanvasKeyUp);
window.removeEventListener("blur", releaseTemporaryPan);
};
}, [changeCanvasMode]);
useEffect(() => {
if (!pendingArchitectureItem) return;
const cancelPlacement = (event: KeyboardEvent) => {
@@ -1223,9 +1301,9 @@ export const DiagramEditorPane = ({
async: true,
background: { color: palette.canvas },
grid: false,
panning: { enabled: true, eventTypes: ["leftMouseDown"] },
panning: false,
mousewheel: { enabled: true, modifiers: ["ctrl", "meta"], minScale: 0.3, maxScale: 2.5 },
interacting: !readOnly,
interacting: () => !readOnly && activeCanvasModeRef.current === "select",
connecting: {
allowBlank: document.kind === "flowchart",
allowLoop: false,
@@ -1258,6 +1336,13 @@ export const DiagramEditorPane = ({
},
},
});
graph.use(new Scroller({
enabled: true,
autoResize: true,
padding: 32,
pannable: { enabled: activeCanvasModeRef.current === "pan", eventTypes: ["leftMouseDown"] },
className: "edgeever-diagram-scroller",
}));
graph.use(new History({ enabled: !readOnly }));
graph.use(new Export());
graph.use(new Keyboard({
@@ -1268,8 +1353,15 @@ export const DiagramEditorPane = ({
return !(target instanceof HTMLElement && (target.isContentEditable || ["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName)));
},
}));
graph.use(new Selection({ enabled: true, multiple: true, rubberband: true, movable: !readOnly, showNodeSelectionBox: true, showEdgeSelectionBox: true }));
const detachScroll = attachDiagramScroll(graph.container, graph);
graph.use(new Selection({
enabled: activeCanvasModeRef.current === "select",
multiple: true,
rubberband: activeCanvasModeRef.current === "select",
modifiers: null,
movable: !readOnly,
showNodeSelectionBox: true,
showEdgeSelectionBox: true,
}));
graph.addNodes(document.nodes.map((node) => {
const inferredResourceIcon = document.kind === "architecture" && !node.resourceIcon
? inferArchitectureResourceIcon(node.label, t)
@@ -1287,14 +1379,6 @@ export const DiagramEditorPane = ({
}
}
graph.addEdges(document.edges.map((edge) => edgeMetadata(edge, document.kind, documentTheme, appearance)));
let viewportWidth = containerRef.current?.clientWidth ?? 0;
let viewportHeight = containerRef.current?.clientHeight ?? 0;
graph.on("resize", ({ width, height }) => {
const translation = graph.translate();
graph.translate(translation.tx + (width - viewportWidth) / 2, translation.ty + (height - viewportHeight) / 2);
viewportWidth = width;
viewportHeight = height;
});
graph.on("scale", () => setZoomPercent(Math.round(graph.scale().sx * 100)));
graph.cleanHistory();
fitDiagramContent(graph, document, containerRef.current);
@@ -1649,7 +1733,7 @@ export const DiagramEditorPane = ({
openFlowQuickCreateRef.current = () => undefined;
nodeEditorRef.current = null;
graphRef.current = null;
detachScroll(); graph.dispose();
graph.dispose();
};
}, [beginNodeEdit, dismissFlowQuickCreate, memo.contentHash, memo.id, readOnly]);
@@ -2409,6 +2493,7 @@ export const DiagramEditorPane = ({
<div className="flex min-h-0 flex-1 flex-col">
<DiagramToolbar
appearance={resolvedTheme}
canvasMode={activeCanvasMode}
canRedo={historyState.redo}
canUndo={historyState.undo}
hasSelection={hasSelection}
@@ -2436,6 +2521,7 @@ export const DiagramEditorPane = ({
)
) : undefined}
onAutoLayout={applyAutoLayout}
onCanvasModeChange={changeCanvasMode}
onDeleteSelection={removeSelected}
onExport={exportDiagram}
onRedo={() => runHistoryAction("redo")}
@@ -2477,6 +2563,7 @@ export const DiagramEditorPane = ({
ref={containerRef}
className={cn("edgeever-diagram-canvas absolute inset-0 touch-none outline-none", pendingArchitectureItem && "cursor-crosshair")}
data-architecture-placement={pendingArchitectureItem ? "active" : undefined}
data-canvas-mode={activeCanvasMode}
data-diagram-appearance={resolvedTheme}
data-diagram-kind={document.kind}
data-diagram-theme={theme}
@@ -6,6 +6,8 @@ import {
Download,
FileCode2,
FileImage,
Hand,
MousePointer2,
Scan,
Redo2,
Trash2,
@@ -30,10 +32,12 @@ import { resolveDiagramPalette, type DiagramAppearance } from "@/lib/diagram-the
type DiagramToolbarProps = {
appearance: DiagramAppearance;
canvasMode: "select" | "pan";
canRedo: boolean;
canUndo: boolean;
hasSelection: boolean;
leading?: ReactNode;
onCanvasModeChange: (mode: "select" | "pan") => void;
onAutoLayout: () => void;
onDeleteSelection: () => void;
onExport: (format: "png" | "svg") => void;
@@ -70,10 +74,12 @@ export const DiagramToolbarAddTrigger = ({
export const DiagramToolbar = ({
appearance,
canvasMode,
canRedo,
canUndo,
hasSelection,
leading,
onCanvasModeChange,
onAutoLayout,
onDeleteSelection,
onExport,
@@ -94,6 +100,21 @@ export const DiagramToolbar = ({
return (
<MemoEditorToolbarRow className="shrink-0 border-b border-slate-200 bg-white" role="toolbar" aria-label={t("diagram.toolbar")}>
{leading ? <>{leading}<MemoEditorToolbarDivider /></> : null}
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="soft"
aria-label={canvasMode === "pan" ? t("diagram.panMode") : t("diagram.selectMode")}
aria-keyshortcuts="V H"
onClick={() => onCanvasModeChange(canvasMode === "pan" ? "select" : "pan")}
>
{canvasMode === "pan" ? <Hand className="h-4 w-4" /> : <MousePointer2 className="h-4 w-4" />}
</Button>
</TooltipTrigger>
<TooltipContent>{canvasMode === "pan" ? t("diagram.panModeHint") : t("diagram.selectModeHint")}</TooltipContent>
</Tooltip>
<MemoEditorToolbarDivider />
<Tooltip><TooltipTrigger asChild><Button size="icon" variant="ghost" aria-label={t("diagram.undo")} disabled={!canUndo || readOnly} onClick={onUndo}><Undo2 className="h-4 w-4" /></Button></TooltipTrigger><TooltipContent>{t("diagram.undo")}</TooltipContent></Tooltip>
<Tooltip><TooltipTrigger asChild><Button size="icon" variant="ghost" aria-label={t("diagram.redo")} disabled={!canRedo || readOnly} onClick={onRedo}><Redo2 className="h-4 w-4" /></Button></TooltipTrigger><TooltipContent>{t("diagram.redo")}</TooltipContent></Tooltip>
{!readOnly && <Tooltip><TooltipTrigger asChild><Button size="icon" variant="ghost" aria-label={t("diagram.deleteSelection")} disabled={!hasSelection} onClick={onDeleteSelection}><Trash2 className="h-4 w-4" /></Button></TooltipTrigger><TooltipContent>{t("diagram.deleteSelection")}</TooltipContent></Tooltip>}
+29
View File
@@ -482,6 +482,35 @@ html[data-edgeever-extension-theme] .edgeever-memo-divider {
box-shadow: inset 0 0 0 2px rgb(var(--brand-green-rgb) / 0.5);
}
/* X6 owns the scroll extent so oversized diagrams remain reachable in both
* directions while the editor chrome stays fixed around the viewport. */
.edgeever-diagram-scroller {
position: absolute !important;
inset: 0;
width: auto !important;
height: auto !important;
overscroll-behavior: contain;
scrollbar-gutter: stable;
}
.edgeever-diagram-scroller[data-panning="false"]
.edgeever-diagram-canvas[data-canvas-mode="pan"]:not([data-architecture-placement="active"]) {
cursor: grab;
}
.edgeever-diagram-scroller[data-panning="true"] .edgeever-diagram-canvas {
cursor: grabbing;
}
.edgeever-diagram-canvas[data-canvas-mode="select"] {
cursor: default;
}
.edgeever-diagram-canvas[data-canvas-mode="pan"] .x6-widget-selection-box,
.edgeever-diagram-canvas[data-canvas-mode="pan"] .x6-widget-selection-inner {
pointer-events: none;
}
.edgeever-diagram-canvas .x6-widget-selection-rubberband {
border-color: var(--brand-green) !important;
background-color: var(--brand-green-muted) !important;
+4
View File
@@ -347,6 +347,10 @@ export const enUS = {
edgeText: "Connection label",
editNode: "Edit node text",
deleteSelection: "Delete selection",
selectMode: "Select mode",
selectModeHint: "Select mode is active; click to switch to Hand mode (V / H)",
panMode: "Hand mode",
panModeHint: "Hand mode is active; click to switch to Select mode (H / V)",
undo: "Undo",
redo: "Redo",
zoomIn: "Zoom in",
+4
View File
@@ -347,6 +347,10 @@ export const zhCN = {
edgeText: "连线说明",
editNode: "编辑节点文字",
deleteSelection: "删除所选内容",
selectMode: "选择模式",
selectModeHint: "当前为选择模式;点击切换到抓手模式(V / H)",
panMode: "抓手模式",
panModeHint: "当前为抓手模式;点击切换到选择模式(H / V)",
undo: "撤销",
redo: "重做",
zoomIn: "放大",