feat(web): improve diagram canvas navigation
This commit is contained in:
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>');
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -347,6 +347,10 @@ export const zhCN = {
|
||||
edgeText: "连线说明",
|
||||
editNode: "编辑节点文字",
|
||||
deleteSelection: "删除所选内容",
|
||||
selectMode: "选择模式",
|
||||
selectModeHint: "当前为选择模式;点击切换到抓手模式(V / H)",
|
||||
panMode: "抓手模式",
|
||||
panModeHint: "当前为抓手模式;点击切换到选择模式(H / V)",
|
||||
undo: "撤销",
|
||||
redo: "重做",
|
||||
zoomIn: "放大",
|
||||
|
||||
Reference in new issue
Block a user