refactor(web): adopt modeless canvas navigation for diagrams

This commit is contained in:
tianma-if committed 2026-09-08 00:46:02 +08:00
1 parent 61b4844f45
commit dd5305da39
6 files changed
+33 -125

No files matched your search

@@ -43,35 +43,30 @@ describe("diagram editor canvas surface", () => {
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).toContain('pannable: { enabled: true, eventTypes: ["leftMouseDown", "rightMouseDown"] }');
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");
expect(globalStyles).toContain("cursor: grabbing !important");
});
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("supports modeless canvas navigation with blank-drag pan and shift rubberband selection", () => {
expect(source).toContain('modifiers: "shift"');
expect(source).toContain('multipleSelectionModifiers: ["ctrl", "meta", "shift"]');
expect(source).toContain("interacting: () => !readOnly && !spacePanActiveRef.current");
expect(source).toContain('data-space-pan={spacePanActive ? "active" : undefined}');
expect(source).not.toContain("activeCanvasMode");
expect(source).not.toContain("data-canvas-mode");
});
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"');
test("keeps toolbar clean without mode toggles while retaining spacebar pan", () => {
expect(toolbarSource).not.toContain("canvasMode");
expect(toolbarSource).not.toContain("onCanvasModeChange");
expect(source).not.toContain('key === "v" || key === "h"');
expect(source).toContain('event.code !== "Space"');
expect(source).toContain('setSpacePanActive(true)');
expect(source).toContain('setSpacePanActive(false)');
expect(source).toContain("setSpacePanActive(true)");
expect(source).toContain("setSpacePanActive(false)");
});
test("uses the common note header and capability-aware more menu", () => {
+10 -63
View File
@@ -135,27 +135,6 @@ 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";
@@ -1089,10 +1068,8 @@ 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 spacePanActiveRef = useRef(false);
const [historyState, setHistoryState] = useState({ undo: false, redo: false });
const [nodeEditor, setNodeEditor] = useState<NodeEditorState | null>(null);
const [flowQuickCreate, setFlowQuickCreate] = useState<FlowQuickCreateState | null>(null);
@@ -1103,46 +1080,17 @@ export const DiagramEditorPane = ({
const nodeEditorRef = useRef<NodeEditorState | null>(null);
const editorDirty = dirty || tagsDirty;
activeCanvasModeRef.current = activeCanvasMode;
spacePanActiveRef.current = spacePanActive;
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);
@@ -1159,7 +1107,7 @@ export const DiagramEditorPane = ({
window.removeEventListener("keyup", handleCanvasKeyUp);
window.removeEventListener("blur", releaseTemporaryPan);
};
}, [changeCanvasMode]);
}, []);
useEffect(() => {
if (!pendingArchitectureItem) return;
@@ -1303,7 +1251,7 @@ export const DiagramEditorPane = ({
grid: false,
panning: false,
mousewheel: { enabled: true, modifiers: ["ctrl", "meta"], minScale: 0.3, maxScale: 2.5 },
interacting: () => !readOnly && activeCanvasModeRef.current === "select",
interacting: () => !readOnly && !spacePanActiveRef.current,
connecting: {
allowBlank: document.kind === "flowchart",
allowLoop: false,
@@ -1340,7 +1288,7 @@ export const DiagramEditorPane = ({
enabled: true,
autoResize: true,
padding: 32,
pannable: { enabled: activeCanvasModeRef.current === "pan", eventTypes: ["leftMouseDown"] },
pannable: { enabled: true, eventTypes: ["leftMouseDown", "rightMouseDown"] },
className: "edgeever-diagram-scroller",
}));
graph.use(new History({ enabled: !readOnly }));
@@ -1354,10 +1302,11 @@ export const DiagramEditorPane = ({
},
}));
graph.use(new Selection({
enabled: activeCanvasModeRef.current === "select",
enabled: true,
multiple: true,
rubberband: activeCanvasModeRef.current === "select",
modifiers: null,
multipleSelectionModifiers: ["ctrl", "meta", "shift"],
rubberband: true,
modifiers: "shift",
movable: !readOnly,
showNodeSelectionBox: true,
showEdgeSelectionBox: true,
@@ -2493,7 +2442,6 @@ 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}
@@ -2521,7 +2469,6 @@ export const DiagramEditorPane = ({
)
) : undefined}
onAutoLayout={applyAutoLayout}
onCanvasModeChange={changeCanvasMode}
onDeleteSelection={removeSelected}
onExport={exportDiagram}
onRedo={() => runHistoryAction("redo")}
@@ -2563,10 +2510,10 @@ 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}
data-space-pan={spacePanActive ? "active" : undefined}
tabIndex={0}
aria-label={t("diagram.canvas", { type: kindLabel })}
onDragOver={handleArchitectureDragOver}
@@ -6,8 +6,6 @@ import {
Download,
FileCode2,
FileImage,
Hand,
MousePointer2,
Scan,
Redo2,
Trash2,
@@ -32,12 +30,10 @@ 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;
@@ -74,12 +70,10 @@ export const DiagramToolbarAddTrigger = ({
export const DiagramToolbar = ({
appearance,
canvasMode,
canRedo,
canUndo,
hasSelection,
leading,
onCanvasModeChange,
onAutoLayout,
onDeleteSelection,
onExport,
@@ -100,21 +94,6 @@ 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>}
+8 -13
View File
@@ -493,22 +493,17 @@ html[data-edgeever-extension-theme] .edgeever-memo-divider {
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"] {
.edgeever-diagram-scroller .edgeever-diagram-canvas {
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[data-space-pan="active"]:not([data-architecture-placement="active"]) {
cursor: grab;
}
.edgeever-diagram-scroller[data-panning="true"] .edgeever-diagram-canvas,
.edgeever-diagram-canvas[data-space-pan="active"][data-panning="true"] {
cursor: grabbing !important;
}
.edgeever-diagram-canvas .x6-widget-selection-rubberband {
-4
View File
@@ -347,10 +347,6 @@ 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,10 +347,6 @@ export const zhCN = {
edgeText: "连线说明",
editNode: "编辑节点文字",
deleteSelection: "删除所选内容",
selectMode: "选择模式",
selectModeHint: "当前为选择模式;点击切换到抓手模式(V / H)",
panMode: "抓手模式",
panModeHint: "当前为抓手模式;点击切换到选择模式(H / V)",
undo: "撤销",
redo: "重做",
zoomIn: "放大",