lispgram 0.10.2 → 0.10.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +4 -4
  2. package/dist/index.js +115 -34
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -121,7 +121,7 @@ npm install lispgram
121
121
  startOnLoad: true,
122
122
  showArrowGlyphs: true,
123
123
  clickToFocus: true,
124
- zoom: "both"
124
+ zoom: true
125
125
  });
126
126
  </script>
127
127
  ```
@@ -227,7 +227,7 @@ import { render, renderElement, initialize, createDiagramSvg } from "lispgram/la
227
227
 
228
228
  `render(target, source, options?)` renders Lispgram or NCF into a DOM target. `renderElement(sourceElement, options?)` renders one source element. `initialize(options?)` auto-renders matching elements such as `<pre class="lispgram">`. `createDiagramSvg(doc, options?)` renders an already-normalized NCF document into an SVG element.
229
229
 
230
- `options.zoom` controls built-in shift zoom behavior. The default with no option is `"both"`, which enables Shift-click zoom-to-fit and cursor-centered Shift + wheel zoom. Shift-click fits containers to about 80% of the viewport; Shift-click on ordinary nodes or arrows zooms to that target so its height is about 5% of the view. Use `"shift-wheel"` for cursor-centered Shift + wheel only, `"shift-click-container"` for Shift-click only, or `"none"` to disable zoom. Auto-rendered `<pre>` blocks can set `data-lispgram-zoom="both"`, `data-lispgram-zoom="shift-wheel"`, `data-lispgram-zoom="shift-click-container"`, or `data-lispgram-zoom="none"`.
230
+ `options.zoom` is a boolean that controls the built-in Shift navigation tools. The default is `true`, which enables Shift + A + click zoom-to-target, cursor-centered Shift + wheel zoom, and Shift + drag panning. Shift + A + click fits containers to about 80% of the viewport. Ordinary nodes zoom until the node height is about 8% of the view height. Arrows zoom to their arrow glyph/label area at about 2% of the view height. Use `zoom: false`, or `data-lispgram-zoom="false"` on auto-rendered `<pre>` blocks, to disable all built-in zoom and panning behavior.
231
231
 
232
232
  ### Interaction entry point
233
233
 
@@ -289,7 +289,7 @@ When `clickToFocus` is enabled, clicking a node, compound/container, or arrow fo
289
289
 
290
290
  Click hit-testing uses a transparent interaction layer above the rendered diagram. Ordinary nodes are given priority over arrows, arrows over container interiors, and deeper containers over their ancestors. This keeps large ancestor containers clickable without letting them steal clicks from nested containers such as `BuildSystem`, `ExternalDependencies`, or `GuileBinding`.
291
291
 
292
- The examples use `zoom: "both"`, which is also the default. It enables Shift-click zoom-to-fit plus Shift-wheel cursor zoom. Containers fit to roughly 80% of the viewport. Ordinary nodes and arrows fit so the clicked target is about 5% of the view height. Pass `zoom: "shift-wheel"` for Shift + wheel only, `zoom: "shift-click-container"` for Shift-click only, or `zoom: "none"` to disable built-in zoom.
292
+ The examples use `zoom: true`, which is also the default. It enables Shift + A + click zoom-to-target, Shift-wheel cursor zoom, and Shift-drag panning. Containers fit to roughly 80% of the viewport; ordinary nodes fit to about 8% of the view height; arrows fit to the arrow glyph/label area at about 2% of the view height. Pass `zoom: false` to disable built-in zoom and pan behavior.
293
293
 
294
294
  ## Demo
295
295
 
@@ -317,7 +317,7 @@ navigate to examples/basic.html in browser
317
317
  startOnLoad: true,
318
318
  showArrowGlyphs: true,
319
319
  clickToFocus: true,
320
- zoom: "both",
320
+ zoom: true,
321
321
  arrowGlyphRadius: 5.75
322
322
  });
323
323
  </script>
package/dist/index.js CHANGED
@@ -4772,7 +4772,7 @@ function renderLispgramRoute(svg, route, options) {
4772
4772
  }
4773
4773
 
4774
4774
  function renderLispgramHitLayer(svg, result, options = {}) {
4775
- if (options.clickToFocus === false && normalizeZoomMode(options) === 'none') return;
4775
+ if (options.clickToFocus === false && !zoomEnabled(options)) return;
4776
4776
  const layer = svgEl('g', { class: 'lispgram-hit-layer', 'aria-hidden': 'true' });
4777
4777
 
4778
4778
  // Container hit regions are separate from the visible paint order. They are
@@ -4888,26 +4888,25 @@ function routeTouchesFocus(route, focus) {
4888
4888
  return endpointTouches(route.fromEndpoint, focus.id, focus.type) || endpointTouches(route.toEndpoint, focus.id, focus.type);
4889
4889
  }
4890
4890
 
4891
- function normalizeZoomMode(options = {}) {
4892
- const raw = options.zoom ?? options.zoomMode ?? options.interactionZoom;
4893
- if (raw === false || raw == null && options.noZoom === true) return 'none';
4894
- if (raw == null || raw === true || raw === '') return 'both';
4895
- const value = String(raw).trim().toLowerCase().replace(/_/g, '-');
4896
- if (['none', 'off', 'false', '0', 'no', 'disabled', 'disable'].includes(value)) return 'none';
4897
- if (['wheel', 'scroll', 'cursor', 'cursor-wheel', 'shift-wheel', 'shift-scroll', 'wheel-at-cursor', 'scroll-at-cursor'].includes(value)) return 'shift-wheel';
4898
- if (['container', 'click-container', 'shift-click', 'shift-click-container', 'container-fit', 'fit-container', 'zoom-container', 'container-zoom'].includes(value)) return 'shift-click-container';
4899
- if (['both', 'all', 'shift-both'].includes(value)) return 'both';
4900
- return 'both';
4891
+ function zoomEnabled(options = {}) {
4892
+ return options.zoom !== false;
4901
4893
  }
4902
4894
 
4903
- function shiftClickZoomEnabled(options = {}) {
4904
- const mode = normalizeZoomMode(options);
4905
- return mode === 'shift-click-container' || mode === 'both';
4895
+ const lispgramZoomKeyState = new WeakMap();
4896
+
4897
+ function normalizeKeyboardKey(event) {
4898
+ return String(event?.key || event?.code || '').toLowerCase();
4899
+ }
4900
+
4901
+ function eventHasZoomClickKey(svg, event) {
4902
+ if (event?.aKey === true) return true;
4903
+ const key = normalizeKeyboardKey(event);
4904
+ if (key === 'a' || key === 'keya') return true;
4905
+ return !!lispgramZoomKeyState.get(svg)?.aDown;
4906
4906
  }
4907
4907
 
4908
- function shiftWheelZoomEnabled(options = {}) {
4909
- const mode = normalizeZoomMode(options);
4910
- return mode === 'shift-wheel' || mode === 'both';
4908
+ function isZoomClickEvent(svg, event) {
4909
+ return !!event?.shiftKey && eventHasZoomClickKey(svg, event);
4911
4910
  }
4912
4911
 
4913
4912
  function parseViewBox(svg) {
@@ -5029,8 +5028,8 @@ function routeZoomBounds(route, options = {}) {
5029
5028
  function viewBoxForSmallBounds(svg, bounds, originalViewBox, options = {}) {
5030
5029
  if (!bounds || !originalViewBox) return null;
5031
5030
  const aspect = svgDisplayAspect(svg, originalViewBox) || 1;
5032
- const fractionRaw = Number(options.zoomTargetHeightFraction ?? options.smallZoomHeightFraction ?? 0.05);
5033
- const fraction = Number.isFinite(fractionRaw) && fractionRaw > 0 && fractionRaw < 1 ? fractionRaw : 0.05;
5031
+ const fractionRaw = Number(options.zoomTargetHeightFraction ?? options.smallZoomHeightFraction ?? 0.08);
5032
+ const fraction = Number.isFinite(fractionRaw) && fractionRaw > 0 && fractionRaw < 1 ? fractionRaw : 0.08;
5034
5033
  const minTargetH = Number.isFinite(Number(options.smallZoomMinTargetHeight)) ? Number(options.smallZoomMinTargetHeight) : 10;
5035
5034
  const targetH = Math.max(bounds.h || 0, minTargetH);
5036
5035
  let h = targetH / fraction;
@@ -5052,15 +5051,18 @@ function viewBoxForZoomTarget(svg, target, result, originalViewBox, options = {}
5052
5051
  }
5053
5052
  if (target.type === 'edge') {
5054
5053
  const route = result.routes?.get?.(target.id);
5055
- return viewBoxForSmallBounds(svg, routeZoomBounds(route, options), originalViewBox, options);
5054
+ const edgeOptions = {
5055
+ ...options,
5056
+ zoomTargetHeightFraction: options.arrowZoomHeightFraction ?? options.edgeZoomHeightFraction ?? 0.02,
5057
+ smallZoomMinTargetHeight: options.arrowZoomMinTargetHeight ?? options.edgeZoomMinTargetHeight ?? 10
5058
+ };
5059
+ return viewBoxForSmallBounds(svg, routeZoomBounds(route, edgeOptions), originalViewBox, edgeOptions);
5056
5060
  }
5057
5061
  return null;
5058
5062
  }
5059
5063
 
5060
5064
  function enableLispgramZoom(svg, result, options = {}) {
5061
- if (!svg || !result) return;
5062
- const mode = normalizeZoomMode(options);
5063
- if (mode === 'none') return;
5065
+ if (!svg || !result || !zoomEnabled(options)) return;
5064
5066
  const originalViewBox = parseViewBox(svg) || result.viewBox || null;
5065
5067
  if (!originalViewBox) return;
5066
5068
  let currentViewBox = { ...originalViewBox };
@@ -5090,19 +5092,98 @@ function enableLispgramZoom(svg, result, options = {}) {
5090
5092
  return setViewBox(originalViewBox);
5091
5093
  }
5092
5094
 
5093
- if (shiftWheelZoomEnabled(options) && typeof svg.addEventListener === 'function') {
5095
+ let suppressNextShiftClick = false;
5096
+ const keyState = { aDown: false };
5097
+ lispgramZoomKeyState.set(svg, keyState);
5098
+
5099
+ function updateZoomKeyState(event, value) {
5100
+ const key = normalizeKeyboardKey(event);
5101
+ if (key === 'a' || key === 'keya') keyState.aDown = value;
5102
+ }
5103
+
5104
+ const keyTargets = [];
5105
+ const ownerDocument = svg.ownerDocument || (typeof document !== 'undefined' ? document : null);
5106
+ if (ownerDocument?.defaultView?.addEventListener) keyTargets.push(ownerDocument.defaultView);
5107
+ if (ownerDocument?.addEventListener) keyTargets.push(ownerDocument);
5108
+ for (const target of keyTargets) {
5109
+ target.addEventListener('keydown', (event) => updateZoomKeyState(event, true));
5110
+ target.addEventListener('keyup', (event) => updateZoomKeyState(event, false));
5111
+ target.addEventListener('blur', () => { keyState.aDown = false; });
5112
+ }
5113
+
5114
+ if (typeof svg.addEventListener === 'function') {
5094
5115
  svg.addEventListener('wheel', (event) => {
5095
5116
  if (!event?.shiftKey) return;
5096
5117
  if (typeof event.preventDefault === 'function') event.preventDefault();
5097
- const scale = Number.isFinite(Number(options.zoomWheelScale)) ? Number(options.zoomWheelScale) : 1.08;
5118
+ const scale = Number.isFinite(Number(options.zoomWheelScale)) ? Number(options.zoomWheelScale) : 1.05;
5098
5119
  const delta = Number(event.deltaY || 0);
5099
5120
  const factor = delta < 0 ? 1 / scale : scale;
5100
5121
  const pivot = svgPointFromEvent(svg, event, currentViewBox);
5101
5122
  currentViewBox = zoomViewBoxAt(svg, pivot, factor, originalViewBox, options) || currentViewBox;
5102
5123
  }, { passive: false });
5124
+
5125
+ let dragState = null;
5126
+ const dragThreshold = Number.isFinite(Number(options.zoomDragThreshold)) ? Number(options.zoomDragThreshold) : 3;
5127
+
5128
+ function updateDrag(event) {
5129
+ if (!dragState) return;
5130
+ const rect = typeof svg.getBoundingClientRect === 'function' ? svg.getBoundingClientRect() : null;
5131
+ if (!rect?.width || !rect?.height) return;
5132
+ const dx = Number(event.clientX) - dragState.startClientX;
5133
+ const dy = Number(event.clientY) - dragState.startClientY;
5134
+ if (!dragState.moved && Math.hypot(dx, dy) >= dragThreshold) dragState.moved = true;
5135
+ if (typeof event.preventDefault === 'function') event.preventDefault();
5136
+ const next = {
5137
+ x: dragState.startViewBox.x - dx / rect.width * dragState.startViewBox.w,
5138
+ y: dragState.startViewBox.y - dy / rect.height * dragState.startViewBox.h,
5139
+ w: dragState.startViewBox.w,
5140
+ h: dragState.startViewBox.h
5141
+ };
5142
+ currentViewBox = next;
5143
+ writeViewBox(svg, currentViewBox);
5144
+ }
5145
+
5146
+ function endDrag(event) {
5147
+ if (!dragState) return;
5148
+ if (dragState.moved) {
5149
+ suppressNextShiftClick = true;
5150
+ if (typeof event?.preventDefault === 'function') event.preventDefault();
5151
+ }
5152
+ if (dragState.pointerId != null && typeof svg.releasePointerCapture === 'function') {
5153
+ try { svg.releasePointerCapture(dragState.pointerId); } catch {}
5154
+ }
5155
+ dragState = null;
5156
+ if (svg.style) svg.style.cursor = '';
5157
+ setTimeout(() => { suppressNextShiftClick = false; }, 0);
5158
+ }
5159
+
5160
+ if (typeof svg.addEventListener === 'function') {
5161
+ svg.addEventListener('pointerdown', (event) => {
5162
+ if (!event?.shiftKey) return;
5163
+ if (isZoomClickEvent(svg, event)) return;
5164
+ if (event.button != null && event.button !== 0) return;
5165
+ const start = parseViewBox(svg) || currentViewBox;
5166
+ if (!start) return;
5167
+ dragState = {
5168
+ pointerId: event.pointerId,
5169
+ startClientX: Number(event.clientX),
5170
+ startClientY: Number(event.clientY),
5171
+ startViewBox: { ...start },
5172
+ moved: false
5173
+ };
5174
+ if (typeof svg.setPointerCapture === 'function' && event.pointerId != null) {
5175
+ try { svg.setPointerCapture(event.pointerId); } catch {}
5176
+ }
5177
+ if (svg.style) svg.style.cursor = 'grabbing';
5178
+ if (typeof event.preventDefault === 'function') event.preventDefault();
5179
+ }, { passive: false });
5180
+ svg.addEventListener('pointermove', updateDrag, { passive: false });
5181
+ svg.addEventListener('pointerup', endDrag, { passive: false });
5182
+ svg.addEventListener('pointercancel', endDrag, { passive: false });
5183
+ }
5103
5184
  }
5104
5185
 
5105
- if (shiftClickZoomEnabled(options)) {
5186
+ {
5106
5187
  const hitboxes = [];
5107
5188
  walkSvgElements(svg, (node) => {
5108
5189
  if (typeof node.getAttribute !== 'function') return;
@@ -5118,7 +5199,8 @@ function enableLispgramZoom(svg, result, options = {}) {
5118
5199
  for (const [el, target] of hitboxes) {
5119
5200
  if (typeof el.addEventListener !== 'function') continue;
5120
5201
  el.addEventListener('click', (event) => {
5121
- if (!event?.shiftKey) return;
5202
+ if (!isZoomClickEvent(svg, event)) return;
5203
+ if (suppressNextShiftClick) return;
5122
5204
  if (typeof event.preventDefault === 'function') event.preventDefault();
5123
5205
  if (typeof event.stopPropagation === 'function') event.stopPropagation();
5124
5206
  if (typeof event.stopImmediatePropagation === 'function') event.stopImmediatePropagation();
@@ -5127,7 +5209,7 @@ function enableLispgramZoom(svg, result, options = {}) {
5127
5209
  }
5128
5210
  }
5129
5211
 
5130
- svg.__lispgramZoom = { mode, originalViewBox, get viewBox() { return currentViewBox; }, reset, zoomToBox, zoomToEdge, zoomToTarget };
5212
+ svg.__lispgramZoom = { enabled: true, originalViewBox, get viewBox() { return currentViewBox; }, reset, zoomToBox, zoomToEdge, zoomToTarget };
5131
5213
  }
5132
5214
 
5133
5215
  function enableLispgramClickFocus(svg, result, options = {}) {
@@ -5202,7 +5284,7 @@ function enableLispgramClickFocus(svg, result, options = {}) {
5202
5284
  if (typeof el.addEventListener !== 'function') continue;
5203
5285
  if (el.style) el.style.cursor = 'pointer';
5204
5286
  el.addEventListener('click', (event) => {
5205
- if (event?.shiftKey && shiftClickZoomEnabled(options) && result.boxes?.has?.(id)) return;
5287
+ if (zoomEnabled(options) && event?.shiftKey && result.boxes?.has?.(id)) return;
5206
5288
  stop(event);
5207
5289
  const next = { type: 'node', id };
5208
5290
  applyFocus(sameFocus(currentFocus, next) ? null : next);
@@ -5215,6 +5297,7 @@ function enableLispgramClickFocus(svg, result, options = {}) {
5215
5297
  if (typeof el.addEventListener !== 'function') continue;
5216
5298
  if (el.style) el.style.cursor = 'pointer';
5217
5299
  el.addEventListener('click', (event) => {
5300
+ if (zoomEnabled(options) && event?.shiftKey && result.routes?.has?.(id)) return;
5218
5301
  stop(event);
5219
5302
  const next = { type: 'edge', id };
5220
5303
  applyFocus(sameFocus(currentFocus, next) ? null : next);
@@ -5451,8 +5534,7 @@ function elementRenderOptions(sourceElement, options = {}) {
5451
5534
  ...options,
5452
5535
  showArrowGlyphs: parseBooleanOption(data.lispgramArrowGlyphs, options.showArrowGlyphs ?? false),
5453
5536
  clickToFocus: parseBooleanOption(data.lispgramClickFocus, options.clickToFocus ?? true),
5454
- zoom: data.lispgramZoom || data.lispgramZoomMode || options.zoom || options.zoomMode,
5455
- zoomMode: data.lispgramZoomMode || data.lispgramZoom || options.zoomMode || options.zoom,
5537
+ zoom: parseBooleanOption(data.lispgramZoom, options.zoom ?? true),
5456
5538
  arrowGlyphRadius: parseNumericOption(data.lispgramArrowGlyphRadius, options.arrowGlyphRadius),
5457
5539
  diagramId: data.lispgramDiagram || data.diagramId || options.diagramId || options.name || options.id || sourceElement?.id || undefined
5458
5540
  };
@@ -5523,8 +5605,7 @@ function initialize(options = {}) {
5523
5605
  showNCF = false,
5524
5606
  showArrowGlyphs = false,
5525
5607
  clickToFocus = true,
5526
- zoom,
5527
- zoomMode,
5608
+ zoom = true,
5528
5609
  arrowGlyphRadius = 5.75,
5529
5610
  height,
5530
5611
  width,
@@ -5536,7 +5617,7 @@ function initialize(options = {}) {
5536
5617
  for (const element of elements) {
5537
5618
  if (element.dataset.lispgramMounted === 'true') continue;
5538
5619
  try {
5539
- renderElement(element, { showNCF, showArrowGlyphs, clickToFocus, zoom: zoomMode ?? zoom, zoomMode: zoomMode ?? zoom, arrowGlyphRadius, height, width, diagramId });
5620
+ renderElement(element, { showNCF, showArrowGlyphs, clickToFocus, zoom, arrowGlyphRadius, height, width, diagramId });
5540
5621
  element.dataset.lispgramMounted = 'true';
5541
5622
  } catch {
5542
5623
  element.dataset.lispgramMounted = 'error';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lispgram",
3
- "version": "0.10.2",
3
+ "version": "0.10.3",
4
4
  "description": "A small public Lispgram surface compiler, constraint-layout SVG renderer, and interaction API.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",