diff --git a/apps/web/src/components/DiagramEditorPane.test.mjs b/apps/web/src/components/DiagramEditorPane.test.mjs index 22ea52da..f46943b9 100644 --- a/apps/web/src/components/DiagramEditorPane.test.mjs +++ b/apps/web/src/components/DiagramEditorPane.test.mjs @@ -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("{updatedLabel}'); diff --git a/apps/web/src/components/DiagramEditorPane.tsx b/apps/web/src/components/DiagramEditorPane.tsx index 602fb680..8a94ec7c 100644 --- a/apps/web/src/components/DiagramEditorPane.tsx +++ b/apps/web/src/components/DiagramEditorPane.tsx @@ -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(null); const [zoomPercent, setZoomPercent] = useState(100); + const [canvasMode, setCanvasMode] = useState(() => readOnly ? "pan" : readDiagramCanvasMode()); + const [spacePanActive, setSpacePanActive] = useState(false); + const activeCanvasMode: DiagramCanvasMode = spacePanActive ? "pan" : canvasMode; + const activeCanvasModeRef = useRef(activeCanvasMode); const [historyState, setHistoryState] = useState({ undo: false, redo: false }); const [nodeEditor, setNodeEditor] = useState(null); const [flowQuickCreate, setFlowQuickCreate] = useState(null); @@ -1079,10 +1103,64 @@ export const DiagramEditorPane = ({ const nodeEditorRef = useRef(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"); + const selection = graph.getPlugin("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 = ({
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} diff --git a/apps/web/src/components/DiagramToolbar.tsx b/apps/web/src/components/DiagramToolbar.tsx index 3f73eb49..a36a94ca 100644 --- a/apps/web/src/components/DiagramToolbar.tsx +++ b/apps/web/src/components/DiagramToolbar.tsx @@ -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 ( {leading ? <>{leading} : null} + + + + + {canvasMode === "pan" ? t("diagram.panModeHint") : t("diagram.selectModeHint")} + + {t("diagram.undo")} {t("diagram.redo")} {!readOnly && {t("diagram.deleteSelection")}} diff --git a/apps/web/src/styles/globals.css b/apps/web/src/styles/globals.css index bcaac418..47b6c9d6 100644 --- a/apps/web/src/styles/globals.css +++ b/apps/web/src/styles/globals.css @@ -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; diff --git a/packages/shared/src/i18n/en-US.ts b/packages/shared/src/i18n/en-US.ts index efd37f86..62332070 100644 --- a/packages/shared/src/i18n/en-US.ts +++ b/packages/shared/src/i18n/en-US.ts @@ -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", diff --git a/packages/shared/src/i18n/zh-CN.ts b/packages/shared/src/i18n/zh-CN.ts index a1452c1d..2aa5a25f 100644 --- a/packages/shared/src/i18n/zh-CN.ts +++ b/packages/shared/src/i18n/zh-CN.ts @@ -347,6 +347,10 @@ export const zhCN = { edgeText: "连线说明", editNode: "编辑节点文字", deleteSelection: "删除所选内容", + selectMode: "选择模式", + selectModeHint: "当前为选择模式;点击切换到抓手模式(V / H)", + panMode: "抓手模式", + panModeHint: "当前为抓手模式;点击切换到选择模式(H / V)", undo: "撤销", redo: "重做", zoomIn: "放大",