diff --git a/apps/web/src/components/DiagramEditorPane.test.mjs b/apps/web/src/components/DiagramEditorPane.test.mjs index f46943b9..3b52d71d 100644 --- a/apps/web/src/components/DiagramEditorPane.test.mjs +++ b/apps/web/src/components/DiagramEditorPane.test.mjs @@ -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", () => { diff --git a/apps/web/src/components/DiagramEditorPane.tsx b/apps/web/src/components/DiagramEditorPane.tsx index 8a94ec7c..c2c0ad3d 100644 --- a/apps/web/src/components/DiagramEditorPane.tsx +++ b/apps/web/src/components/DiagramEditorPane.tsx @@ -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(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 spacePanActiveRef = useRef(false); const [historyState, setHistoryState] = useState({ undo: false, redo: false }); const [nodeEditor, setNodeEditor] = useState(null); const [flowQuickCreate, setFlowQuickCreate] = useState(null); @@ -1103,46 +1080,17 @@ export const DiagramEditorPane = ({ const nodeEditorRef = useRef(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"); - 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); @@ -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 = ({
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} diff --git a/apps/web/src/components/DiagramToolbar.tsx b/apps/web/src/components/DiagramToolbar.tsx index a36a94ca..3f73eb49 100644 --- a/apps/web/src/components/DiagramToolbar.tsx +++ b/apps/web/src/components/DiagramToolbar.tsx @@ -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 ( {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 47b6c9d6..3a9aba23 100644 --- a/apps/web/src/styles/globals.css +++ b/apps/web/src/styles/globals.css @@ -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 { diff --git a/packages/shared/src/i18n/en-US.ts b/packages/shared/src/i18n/en-US.ts index 62332070..efd37f86 100644 --- a/packages/shared/src/i18n/en-US.ts +++ b/packages/shared/src/i18n/en-US.ts @@ -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", diff --git a/packages/shared/src/i18n/zh-CN.ts b/packages/shared/src/i18n/zh-CN.ts index 2aa5a25f..a1452c1d 100644 --- a/packages/shared/src/i18n/zh-CN.ts +++ b/packages/shared/src/i18n/zh-CN.ts @@ -347,10 +347,6 @@ export const zhCN = { edgeText: "连线说明", editNode: "编辑节点文字", deleteSelection: "删除所选内容", - selectMode: "选择模式", - selectModeHint: "当前为选择模式;点击切换到抓手模式(V / H)", - panMode: "抓手模式", - panModeHint: "当前为抓手模式;点击切换到选择模式(H / V)", undo: "撤销", redo: "重做", zoomIn: "放大",