mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-10-01 09:19:34 +05:30
feat(flowchart): wire zoom/pan/snap into canvas (C5)
- All canvas content (nodes, edges, preview line) now lives inside a
single <g class='flowchart-content' transform='...'> so a single
attribute change moves/zooms everything together. The fixed viewBox
(1000x700) stays — the transform handles everything.
- Ctrl/Cmd+wheel zooms around the cursor (calls vpZoomAt, the pure
helper committed in C4a).
- Shift-drag (or middle-click) on empty canvas pans the view.
- Snap-to-grid (10-unit) is opt-in via api.setSnapEnabled(true).
When on, moveNode and addNode clamp coords to the grid.
- Plain click on empty canvas still hands off to opts.onEmptyClick
so the controller can prompt for shape kind + label.
destroy() now also removes the wheel listener.
Amit Haridas
This commit is contained in:
@@ -18,7 +18,15 @@
|
|||||||
// a <script> tag in the standalone window (no nodeIntegration).
|
// a <script> tag in the standalone window (no nodeIntegration).
|
||||||
const shapesModule =
|
const shapesModule =
|
||||||
(typeof window !== 'undefined' && window.FlowchartShapes) || require('./flowchart-shapes');
|
(typeof window !== 'undefined' && window.FlowchartShapes) || require('./flowchart-shapes');
|
||||||
|
const viewportModule =
|
||||||
|
(typeof window !== 'undefined' && window.FlowchartViewport) || require('./flowchart-viewport');
|
||||||
const { DEFAULT_WIDTH, DEFAULT_HEIGHT, shapeSvg, SHAPE_KINDS } = shapesModule;
|
const { DEFAULT_WIDTH, DEFAULT_HEIGHT, shapeSvg, SHAPE_KINDS } = shapesModule;
|
||||||
|
const {
|
||||||
|
zoomAt: vpZoomAt,
|
||||||
|
panBy: vpPanBy,
|
||||||
|
wheelFactor: vpWheelFactor,
|
||||||
|
snap: vpSnap,
|
||||||
|
} = viewportModule;
|
||||||
|
|
||||||
const SVG_NS = 'http://www.w3.org/2000/svg';
|
const SVG_NS = 'http://www.w3.org/2000/svg';
|
||||||
|
|
||||||
@@ -88,16 +96,36 @@ function createCanvas(container, store, opts = {}) {
|
|||||||
});
|
});
|
||||||
container.appendChild(svg);
|
container.appendChild(svg);
|
||||||
|
|
||||||
|
// v4.13.0 — viewport wrapper. Edges/nodes live inside a single <g> whose
|
||||||
|
// transform reflects the current zoom + pan. SVG mouse coordinates are
|
||||||
|
// already in viewBox space (1000×700) so this stays in viewport units.
|
||||||
|
const view = viewportModule.reset();
|
||||||
|
const content = svgEl('g', {
|
||||||
|
class: 'flowchart-content',
|
||||||
|
transform: `translate(${view.tx},${view.ty}) scale(${view.scale})`,
|
||||||
|
});
|
||||||
|
svg.appendChild(content);
|
||||||
|
|
||||||
// Layer order: edges first (under nodes), then nodes.
|
// Layer order: edges first (under nodes), then nodes.
|
||||||
const edgesLayer = svgEl('g', { class: 'flowchart-edges' });
|
const edgesLayer = svgEl('g', { class: 'flowchart-edges' });
|
||||||
const nodesLayer = svgEl('g', { class: 'flowchart-nodes' });
|
const nodesLayer = svgEl('g', { class: 'flowchart-nodes' });
|
||||||
svg.appendChild(edgesLayer);
|
content.appendChild(edgesLayer);
|
||||||
svg.appendChild(nodesLayer);
|
content.appendChild(nodesLayer);
|
||||||
|
|
||||||
|
// v4.13.0 — snap-to-grid toggle. Off by default; controller can flip via
|
||||||
|
// setSnapEnabled(). When enabled, moveNode + addNode clamp coords to a
|
||||||
|
// 10-unit grid.
|
||||||
|
let snapEnabled = false;
|
||||||
|
const GRID_SIZE = 10;
|
||||||
|
function setSnapEnabled(enabled) {
|
||||||
|
snapEnabled = !!enabled;
|
||||||
|
}
|
||||||
|
|
||||||
let selectedNodeId = null;
|
let selectedNodeId = null;
|
||||||
let selectedEdgeId = null;
|
let selectedEdgeId = null;
|
||||||
let unsubscribe = null;
|
let unsubscribe = null;
|
||||||
let destroyed = false;
|
let destroyed = false;
|
||||||
|
let panState = null; // v4.13.0 — drag-to-pan state on empty canvas
|
||||||
|
|
||||||
// v4.13.0 — selection lives on the canvas, not in the DOM. The controller
|
// v4.13.0 — selection lives on the canvas, not in the DOM. The controller
|
||||||
// asks via getSelection() rather than reading .selected class names.
|
// asks via getSelection() rather than reading .selected class names.
|
||||||
@@ -112,6 +140,11 @@ function createCanvas(container, store, opts = {}) {
|
|||||||
return { nodeId: selectedNodeId, edgeId: selectedEdgeId };
|
return { nodeId: selectedNodeId, edgeId: selectedEdgeId };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v4.13.0 — apply the current viewport to the content <g>'s transform.
|
||||||
|
function applyView() {
|
||||||
|
content.setAttribute('transform', `translate(${view.tx},${view.ty}) scale(${view.scale})`);
|
||||||
|
}
|
||||||
|
|
||||||
function render() {
|
function render() {
|
||||||
if (destroyed) return;
|
if (destroyed) return;
|
||||||
const graph = store.getGraph();
|
const graph = store.getGraph();
|
||||||
@@ -281,15 +314,25 @@ function createCanvas(container, store, opts = {}) {
|
|||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Click on empty canvas: hand off to the controller via onEmptyClick so it
|
// Click on empty canvas: middle-mouse OR Space-held = pan; otherwise
|
||||||
// can prompt the user for the shape kind + label. v4.13.0 — the canvas
|
// prompt for a new node. v4.13.0 — empty-canvas drag-pan replaces the
|
||||||
// no longer auto-creates a process node with placeholder label "Node".
|
// old auto-add-process behaviour.
|
||||||
if (ev.target === svg || ev.target === nodesLayer || ev.target === edgesLayer) {
|
if (ev.target === svg || ev.target === nodesLayer || ev.target === edgesLayer) {
|
||||||
|
const isPan = ev.button === 1 || ev.shiftKey; // middle OR shift-drag
|
||||||
|
if (isPan) {
|
||||||
|
const p = getSvgPoint(ev.clientX, ev.clientY);
|
||||||
|
panState = { startX: p.x, startY: p.y, viewTx: view.tx, viewTy: view.ty };
|
||||||
|
setSelection({});
|
||||||
|
ev.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
const p = getSvgPoint(ev.clientX, ev.clientY);
|
const p = getSvgPoint(ev.clientX, ev.clientY);
|
||||||
const x = Math.max(0, p.x - DEFAULT_WIDTH / 2);
|
const x = Math.max(0, p.x - DEFAULT_WIDTH / 2);
|
||||||
const y = Math.max(0, p.y - DEFAULT_HEIGHT / 2);
|
const y = Math.max(0, p.y - DEFAULT_HEIGHT / 2);
|
||||||
|
const finalX = snapEnabled ? vpSnap(x, GRID_SIZE) : x;
|
||||||
|
const finalY = snapEnabled ? vpSnap(y, GRID_SIZE) : y;
|
||||||
if (typeof opts.onEmptyClick === 'function') {
|
if (typeof opts.onEmptyClick === 'function') {
|
||||||
opts.onEmptyClick(x, y, ev);
|
opts.onEmptyClick(finalX, finalY, ev);
|
||||||
}
|
}
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
}
|
}
|
||||||
@@ -318,12 +361,31 @@ function createCanvas(container, store, opts = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onPointerMove(ev) {
|
function onPointerMove(ev) {
|
||||||
|
if (panState) {
|
||||||
|
const p = getSvgPoint(ev.clientX, ev.clientY);
|
||||||
|
const dx = p.x - panState.startX;
|
||||||
|
const dy = p.y - panState.startY;
|
||||||
|
const next = vpPanBy(view, dx, dy);
|
||||||
|
view.tx = next.tx - panState.viewTx + view.tx; // accumulate deltas
|
||||||
|
view.ty = next.ty - panState.viewTy + view.ty;
|
||||||
|
// Reset view.tx/ty based on absolute computation from panState
|
||||||
|
view.tx = panState.viewTx + dx;
|
||||||
|
view.ty = panState.viewTy + dy;
|
||||||
|
applyView();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!dragState) return;
|
if (!dragState) return;
|
||||||
if (dragState.mode === 'move') {
|
if (dragState.mode === 'move') {
|
||||||
const p = getSvgPoint(ev.clientX, ev.clientY);
|
const p = getSvgPoint(ev.clientX, ev.clientY);
|
||||||
const dx = p.x - dragState.pointerX;
|
const dx = p.x - dragState.pointerX;
|
||||||
const dy = p.y - dragState.pointerY;
|
const dy = p.y - dragState.pointerY;
|
||||||
store.moveNode(dragState.nodeId, dragState.startX + dx, dragState.startY + dy);
|
let nx = dragState.startX + dx;
|
||||||
|
let ny = dragState.startY + dy;
|
||||||
|
if (snapEnabled) {
|
||||||
|
nx = vpSnap(nx, GRID_SIZE);
|
||||||
|
ny = vpSnap(ny, GRID_SIZE);
|
||||||
|
}
|
||||||
|
store.moveNode(dragState.nodeId, nx, ny);
|
||||||
} else if (dragState.mode === 'connect' && previewLine) {
|
} else if (dragState.mode === 'connect' && previewLine) {
|
||||||
const p = getSvgPoint(ev.clientX, ev.clientY);
|
const p = getSvgPoint(ev.clientX, ev.clientY);
|
||||||
const sourceNode = store.getGraph().nodes.find((n) => n.id === dragState.sourceNodeId);
|
const sourceNode = store.getGraph().nodes.find((n) => n.id === dragState.sourceNodeId);
|
||||||
@@ -331,7 +393,24 @@ function createCanvas(container, store, opts = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v4.13.0 — Ctrl+wheel zooms around the cursor position.
|
||||||
|
function onWheel(ev) {
|
||||||
|
if (!ev.ctrlKey && !ev.metaKey) return;
|
||||||
|
ev.preventDefault();
|
||||||
|
const p = getSvgPoint(ev.clientX, ev.clientY);
|
||||||
|
const factor = vpWheelFactor(ev.deltaY);
|
||||||
|
const next = vpZoomAt(view, p.x, p.y, factor);
|
||||||
|
view.tx = next.tx;
|
||||||
|
view.ty = next.ty;
|
||||||
|
view.scale = next.scale;
|
||||||
|
applyView();
|
||||||
|
}
|
||||||
|
|
||||||
function onPointerUp(ev) {
|
function onPointerUp(ev) {
|
||||||
|
if (panState) {
|
||||||
|
panState = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (dragState && dragState.mode === 'connect') {
|
if (dragState && dragState.mode === 'connect') {
|
||||||
const targetG = ev.target && ev.target.closest && ev.target.closest('g[data-node-id]');
|
const targetG = ev.target && ev.target.closest && ev.target.closest('g[data-node-id]');
|
||||||
if (targetG) {
|
if (targetG) {
|
||||||
@@ -396,6 +475,7 @@ function createCanvas(container, store, opts = {}) {
|
|||||||
window.addEventListener('pointerup', onPointerUp);
|
window.addEventListener('pointerup', onPointerUp);
|
||||||
svg.addEventListener('dblclick', onDblClick);
|
svg.addEventListener('dblclick', onDblClick);
|
||||||
svg.addEventListener('contextmenu', onContextMenu);
|
svg.addEventListener('contextmenu', onContextMenu);
|
||||||
|
svg.addEventListener('wheel', onWheel, { passive: false });
|
||||||
|
|
||||||
unsubscribe = store.subscribe(render);
|
unsubscribe = store.subscribe(render);
|
||||||
render();
|
render();
|
||||||
@@ -410,10 +490,11 @@ function createCanvas(container, store, opts = {}) {
|
|||||||
window.removeEventListener('pointerup', onPointerUp);
|
window.removeEventListener('pointerup', onPointerUp);
|
||||||
svg.removeEventListener('dblclick', onDblClick);
|
svg.removeEventListener('dblclick', onDblClick);
|
||||||
svg.removeEventListener('contextmenu', onContextMenu);
|
svg.removeEventListener('contextmenu', onContextMenu);
|
||||||
|
svg.removeEventListener('wheel', onWheel);
|
||||||
svg.remove();
|
svg.remove();
|
||||||
}
|
}
|
||||||
|
|
||||||
return { destroy, getSvg: () => svg, getSelection };
|
return { destroy, getSvg: () => svg, getSelection, setSnapEnabled };
|
||||||
}
|
}
|
||||||
|
|
||||||
// v4.9.6 UMD wrapper — same CommonJS export shape + browser global
|
// v4.9.6 UMD wrapper — same CommonJS export shape + browser global
|
||||||
|
|||||||
Reference in New Issue
Block a user