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