@khanglvm/relay 0.14.2 → 0.16.0

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.
package/src/ui/blocks.js CHANGED
@@ -734,7 +734,7 @@
734
734
 
735
735
  function renderDiff(block, ctx, blockId) {
736
736
  const files = splitDiffFiles(block.diff);
737
- if (block.review === true) return renderDiffReview(block, ctx, blockId, files);
737
+ if (block.review === true && ctx.canEditBlocks !== false) return renderDiffReview(block, ctx, blockId, files);
738
738
  const named = files.filter((f) => f.name);
739
739
  const multi = named.length > 1;
740
740
  let view = block.view === 'split' ? 'split' : 'unified';
@@ -844,6 +844,7 @@
844
844
  }
845
845
 
846
846
  function renderGitConflict(block, ctx, blockId) {
847
+ const editable = ctx.canEditBlocks !== false;
847
848
  const conflicts = Array.isArray(block.conflicts) ? block.conflicts : [];
848
849
  const prior = ctx && ctx.edits && ctx.edits[blockId] && typeof ctx.edits[blockId] === 'object'
849
850
  ? ctx.edits[blockId]
@@ -891,6 +892,7 @@
891
892
  spellcheck: 'false',
892
893
  rows: '5',
893
894
  placeholder: 'Write the resolved content for this hunk',
895
+ disabled: editable ? null : '',
894
896
  });
895
897
  const buttons = [];
896
898
  const setChoice = (choice) => {
@@ -902,7 +904,7 @@
902
904
  emit();
903
905
  };
904
906
  const makeButton = (choice, label) => {
905
- const b = el('button', { type: 'button', class: 'gitconf-choice' }, label);
907
+ const b = el('button', { type: 'button', class: 'gitconf-choice', disabled: editable ? null : '' }, label);
906
908
  b.addEventListener('mousedown', (e) => e.stopPropagation());
907
909
  b.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); setChoice(choice); });
908
910
  buttons.push({ choice, el: b });
@@ -1312,11 +1314,14 @@
1312
1314
  const height = clampHeight(block.height, 320);
1313
1315
  const wrap = el('div', { class: 'blk-chart' });
1314
1316
  wrap.style.height = height + 'px';
1317
+ const stage = el('div', { class: 'blk-chart-stage' });
1315
1318
  const canvas = el('canvas');
1316
- wrap.append(canvas);
1319
+ stage.append(canvas);
1320
+ wrap.append(stage);
1317
1321
 
1318
1322
  let chartInst = null;
1319
1323
  let chartBadges = [];
1324
+ let naturalSize = null;
1320
1325
 
1321
1326
  const openFor = (target) => (anchor) =>
1322
1327
  ctx.annotate.openExternal({ blockId, questionId: ctx.questionId, target }, anchor);
@@ -1358,7 +1363,7 @@
1358
1363
  const badge = makeBadge(count, 'blk-chart-badge-pt', openFor(target));
1359
1364
  badge.style.left = (canvas.offsetLeft + pos.x) + 'px';
1360
1365
  badge.style.top = (canvas.offsetTop + pos.y) + 'px';
1361
- wrap.append(badge);
1366
+ stage.append(badge);
1362
1367
  chartBadges.push(badge);
1363
1368
  }
1364
1369
  }
@@ -1370,9 +1375,6 @@
1370
1375
  }
1371
1376
 
1372
1377
  if (ctx.annotate && ctx.annotate.onBadgeRefresh) ctx.annotate.onBadgeRefresh(syncChartBadges);
1373
- // full-screen + whole-chart comment only; charts redraw responsively (no pixel zoom)
1374
- attachViewer(wrap, { zoomEl: null, label: 'chart', comment: wholeBlockComment(ctx, blockId, 'chart') });
1375
-
1376
1378
  loadChart().then((Chart) => {
1377
1379
  let config = block.config
1378
1380
  ? JSON.parse(JSON.stringify(block.config))
@@ -1384,17 +1386,33 @@
1384
1386
  try {
1385
1387
  chartInst = new Chart(canvas.getContext('2d'), config);
1386
1388
  chartRegistry.push({ chart: chartInst });
1389
+ // Chart.js stays responsive inside a stage whose dimensions the shared
1390
+ // viewer controls. This gives charts the same bounded, eased wheel zoom
1391
+ // as diagrams/images while retaining sharp redraws at every scale.
1392
+ naturalSize = {
1393
+ w: Math.max(1, Math.round(stage.getBoundingClientRect().width || wrap.clientWidth || 640)),
1394
+ h: height,
1395
+ };
1396
+ attachViewer(wrap, {
1397
+ zoomEl: stage,
1398
+ natural: () => naturalSize,
1399
+ fluidFit: true,
1400
+ scaleHeight: true,
1401
+ label: 'chart',
1402
+ comment: wholeBlockComment(ctx, blockId, 'chart'),
1403
+ });
1387
1404
  } catch (err) {
1388
1405
  wrap.replaceChildren(el('div', { class: 'blk-error' }, 'Chart error: ' + (err && err.message ? err.message : String(err))));
1389
1406
  return;
1390
1407
  }
1391
1408
  // hover -> pointer cursor on a hit
1392
1409
  canvas.addEventListener('mousemove', (e) => {
1410
+ if (ctx.canComment === false) { canvas.style.cursor = 'default'; return; }
1393
1411
  const hits = chartInst.getElementsAtEventForMode(e, 'nearest', { intersect: true }, true);
1394
1412
  canvas.style.cursor = hits.length ? 'pointer' : 'default';
1395
1413
  });
1396
1414
  canvas.addEventListener('click', (e) => {
1397
- if (!ctx.annotate) return;
1415
+ if (!ctx.annotate || ctx.canComment === false) return;
1398
1416
  const hits = chartInst.getElementsAtEventForMode(e, 'nearest', { intersect: true }, true);
1399
1417
  if (!hits.length) return;
1400
1418
  const { datasetIndex, index } = hits[0];
@@ -1449,7 +1467,7 @@
1449
1467
  entry.code = effectiveMermaidCode(entry);
1450
1468
  mermaidRegistry.push(entry);
1451
1469
 
1452
- if (!block.editable) {
1470
+ if (!block.editable || ctx.canEditBlocks === false) {
1453
1471
  drawMermaid(entry);
1454
1472
  return container;
1455
1473
  }
@@ -1883,7 +1901,9 @@
1883
1901
  src,
1884
1902
  alt: block.alt || 'image',
1885
1903
  loading: 'lazy',
1904
+ title: ctx.canComment === false ? '' : 'Hold briefly, then drag to comment on an area',
1886
1905
  });
1906
+ const stage = el('div', { class: 'blk-imgstage' }, img);
1887
1907
  // Inline default: the image fills its full width (container width, never
1888
1908
  // upscaled past natural) so it's readable without manual zoom; the CONTAINER
1889
1909
  // caps the height (default 800 via CSS, or block.height) and SCROLLS — we
@@ -1893,18 +1913,30 @@
1893
1913
  img.addEventListener('error', () => {
1894
1914
  container.replaceChildren(el('div', { class: 'blk-error' }, 'Image failed to load'));
1895
1915
  });
1896
- container.append(img);
1916
+ container.append(stage);
1897
1917
  const attachImgViewer = () =>
1898
1918
  attachViewer(container, {
1899
- zoomEl: img,
1919
+ zoomEl: stage,
1900
1920
  natural: () => (img.naturalWidth > 0 ? { w: img.naturalWidth, h: img.naturalHeight } : null),
1901
1921
  label: 'image',
1902
1922
  comment: wholeBlockComment(ctx, blockId, 'image'),
1903
1923
  });
1904
1924
  if (img.complete && img.naturalWidth > 0) attachImgViewer();
1905
1925
  else img.addEventListener('load', attachImgViewer, { once: true });
1926
+ if (ctx.annotate && ctx.canComment !== false) {
1927
+ enableImageRegions({
1928
+ host: stage,
1929
+ surface: img,
1930
+ panHost: container,
1931
+ sourceForSide: () => img,
1932
+ sideAtPoint: () => null,
1933
+ ctx,
1934
+ blockId,
1935
+ label: block.alt || 'Image',
1936
+ });
1937
+ }
1906
1938
  if (ctx.annotate && block.pins === true) {
1907
- enableImagePins(container, img, ctx, blockId, block.alt || 'Image');
1939
+ enableImagePins(stage, img, ctx, blockId, block.alt || 'Image');
1908
1940
  } else if (ctx.annotate) {
1909
1941
  ctx.annotate.register(img, {
1910
1942
  blockId,
@@ -1922,10 +1954,12 @@
1922
1954
  function enableImagePins(container, img, ctx, blockId, label) {
1923
1955
  const layer = el('div', { class: 'blk-imgpins' });
1924
1956
  container.append(layer);
1925
- img.classList.add('blk-img-pinnable');
1957
+ if (ctx.canComment !== false) img.classList.add('blk-img-pinnable');
1926
1958
  let down = null; // {x,y} at pointerdown — used to reject drags (pan) as clicks
1927
1959
  img.addEventListener('pointerdown', (e) => { down = { x: e.clientX, y: e.clientY }; });
1928
1960
  img.addEventListener('click', (e) => {
1961
+ if (ctx.canComment === false) return;
1962
+ if (img._rlySuppressPointClick) { img._rlySuppressPointClick = false; down = null; return; }
1929
1963
  if (down && (Math.abs(e.clientX - down.x) > 4 || Math.abs(e.clientY - down.y) > 4)) { down = null; return; }
1930
1964
  const r = img.getBoundingClientRect();
1931
1965
  if (!r.width || !r.height) return;
@@ -1965,6 +1999,192 @@
1965
1999
  syncPins();
1966
2000
  }
1967
2001
 
2002
+ // Hold, then drag, to select a rectangular image area. A short move remains
2003
+ // native viewer pan / comparison-slider input; the hold threshold makes the
2004
+ // two gestures coexist without a mode switch. Region coordinates are stored
2005
+ // as normalized fractions, while a pixel crop is uploaded through the host
2006
+ // context so the result gives the agent an actual viewable image artifact.
2007
+ function enableImageRegions({ host, surface, panHost, sourceForSide, sideAtPoint, ctx, blockId, label }) {
2008
+ const layer = el('div', { class: 'blk-region-layer' });
2009
+ const selection = el('div', { class: 'blk-region-selection' });
2010
+ layer.append(selection);
2011
+ host.append(layer);
2012
+ let pending = null;
2013
+ let active = false;
2014
+ let holdTimer = 0;
2015
+ let status = null;
2016
+ const HOLD_MS = 280;
2017
+ const MIN_REGION = 0.012;
2018
+
2019
+ const localPoint = (e) => {
2020
+ const r = host.getBoundingClientRect();
2021
+ return {
2022
+ x: r.width ? Math.max(0, Math.min(1, (e.clientX - r.left) / r.width)) : 0,
2023
+ y: r.height ? Math.max(0, Math.min(1, (e.clientY - r.top) / r.height)) : 0,
2024
+ };
2025
+ };
2026
+ const clearStatus = () => { if (status) status.remove(); status = null; };
2027
+ const showStatus = (text) => {
2028
+ clearStatus();
2029
+ status = el('div', { class: 'blk-region-status', role: 'status' }, text);
2030
+ host.append(status);
2031
+ };
2032
+ const reset = () => {
2033
+ clearTimeout(holdTimer);
2034
+ holdTimer = 0;
2035
+ active = false;
2036
+ pending = null;
2037
+ panHost._rlyRegionSelecting = false;
2038
+ host.classList.remove('blk-region-arming', 'blk-region-selecting');
2039
+ selection.style.display = 'none';
2040
+ clearStatus();
2041
+ };
2042
+ const draw = (a, b) => {
2043
+ const x = Math.min(a.x, b.x), y = Math.min(a.y, b.y);
2044
+ const w = Math.abs(a.x - b.x), h = Math.abs(a.y - b.y);
2045
+ selection.style.left = (x * 100) + '%';
2046
+ selection.style.top = (y * 100) + '%';
2047
+ selection.style.width = (w * 100) + '%';
2048
+ selection.style.height = (h * 100) + '%';
2049
+ return { x, y, w, h };
2050
+ };
2051
+ const beginSelection = (pointerId) => {
2052
+ if (!pending || active) return;
2053
+ active = true;
2054
+ panHost._rlyRegionSelecting = true;
2055
+ host.classList.remove('blk-region-arming');
2056
+ host.classList.add('blk-region-selecting');
2057
+ selection.style.display = 'block';
2058
+ draw(pending.start, pending.start);
2059
+ showStatus((pending.side ? pending.side.charAt(0).toUpperCase() + pending.side.slice(1) + ' · ' : '') + 'drag to select an area');
2060
+ try { surface.setPointerCapture(pointerId); } catch (_) {}
2061
+ };
2062
+
2063
+ async function cropRegion(img, region) {
2064
+ if (!img || !img.naturalWidth || !img.naturalHeight) throw new Error('image is not ready');
2065
+ const sx = Math.max(0, Math.floor(region.x * img.naturalWidth));
2066
+ const sy = Math.max(0, Math.floor(region.y * img.naturalHeight));
2067
+ const sw = Math.max(1, Math.min(img.naturalWidth - sx, Math.round(region.w * img.naturalWidth)));
2068
+ const sh = Math.max(1, Math.min(img.naturalHeight - sy, Math.round(region.h * img.naturalHeight)));
2069
+ const scale = Math.min(1, 1400 / sw, 1400 / sh);
2070
+ const width = Math.max(1, Math.round(sw * scale));
2071
+ const height = Math.max(1, Math.round(sh * scale));
2072
+ const canvas = document.createElement('canvas');
2073
+ canvas.width = width;
2074
+ canvas.height = height;
2075
+ const g = canvas.getContext('2d');
2076
+ if (!g) throw new Error('canvas unavailable');
2077
+ g.drawImage(img, sx, sy, sw, sh, 0, 0, width, height);
2078
+ const dataUrl = canvas.toDataURL('image/png');
2079
+ if (!/^data:image\/png;base64,/.test(dataUrl)) throw new Error('crop unavailable');
2080
+ if (ctx.saveArtifact) return ctx.saveArtifact({ dataUrl, mime: 'image/png', width, height, blockId });
2081
+ return { data: dataUrl.slice(dataUrl.indexOf(',') + 1), mime: 'image/png', width, height };
2082
+ }
2083
+
2084
+ const finishRegion = async (region, side) => {
2085
+ const target = {
2086
+ kind: 'image-region',
2087
+ x: Math.round(region.x * 10000) / 10000,
2088
+ y: Math.round(region.y * 10000) / 10000,
2089
+ w: Math.round(region.w * 10000) / 10000,
2090
+ h: Math.round(region.h * 10000) / 10000,
2091
+ side: side || undefined,
2092
+ label,
2093
+ };
2094
+ showStatus('Saving selected area…');
2095
+ try {
2096
+ target.crop = await cropRegion(sourceForSide(side), region);
2097
+ } catch {
2098
+ target.cropUnavailable = true;
2099
+ }
2100
+ clearStatus();
2101
+ ctx.annotate.openExternal({ blockId, questionId: ctx.questionId, target }, host);
2102
+ };
2103
+
2104
+ surface.addEventListener('pointerdown', (e) => {
2105
+ if (e.button !== 0 || ctx.canComment === false) return;
2106
+ if (e.target.closest && e.target.closest('.cmp-handle, .blk-imgregion, .blk-tools')) return;
2107
+ const p = localPoint(e);
2108
+ pending = {
2109
+ start: p,
2110
+ last: p,
2111
+ clientX: e.clientX,
2112
+ clientY: e.clientY,
2113
+ pointerId: e.pointerId,
2114
+ startedAt: performance.now(),
2115
+ side: sideAtPoint(p.x),
2116
+ };
2117
+ host.classList.add('blk-region-arming');
2118
+ holdTimer = setTimeout(() => beginSelection(e.pointerId), HOLD_MS);
2119
+ });
2120
+ surface.addEventListener('pointermove', (e) => {
2121
+ if (!pending) return;
2122
+ const moved = Math.hypot(e.clientX - pending.clientX, e.clientY - pending.clientY);
2123
+ // Timers can be throttled while an automation host or background tab owns
2124
+ // the event loop. The first post-hold move is also authoritative, so a
2125
+ // genuine hold still enters area mode even if setTimeout fired late.
2126
+ if (!active && performance.now() - pending.startedAt >= HOLD_MS) beginSelection(pending.pointerId);
2127
+ if (!active) {
2128
+ if (moved > 5) reset();
2129
+ return;
2130
+ }
2131
+ e.preventDefault();
2132
+ pending.last = localPoint(e);
2133
+ draw(pending.start, pending.last);
2134
+ });
2135
+ const end = (e) => {
2136
+ if (!pending) return;
2137
+ clearTimeout(holdTimer);
2138
+ if (!active) { reset(); return; }
2139
+ e.preventDefault();
2140
+ const region = draw(pending.start, pending.last || localPoint(e));
2141
+ const side = pending.side;
2142
+ surface._rlySuppressPointClick = true;
2143
+ reset();
2144
+ clearStatus();
2145
+ if (region.w >= MIN_REGION && region.h >= MIN_REGION) finishRegion(region, side);
2146
+ };
2147
+ surface.addEventListener('pointerup', end);
2148
+ // Some Chromium/CDP paths emit pointercancel immediately after granting
2149
+ // capture on the first dragged frame. Once area mode is active, keep the
2150
+ // last drawn rectangle instead of throwing the user's deliberate hold away.
2151
+ surface.addEventListener('pointercancel', (e) => active ? end(e) : reset());
2152
+
2153
+ const syncRegions = () => {
2154
+ for (const node of Array.from(layer.querySelectorAll('.blk-imgregion'))) node.remove();
2155
+ const groups = new Map();
2156
+ for (const a of ctx.annotate.list()) {
2157
+ if (a.blockId !== blockId || !a.target || a.target.kind !== 'image-region') continue;
2158
+ const t = a.target;
2159
+ const key = [t.side || '', t.x, t.y, t.w, t.h].join(':');
2160
+ const group = groups.get(key) || { target: t, count: 0 };
2161
+ group.count++;
2162
+ groups.set(key, group);
2163
+ }
2164
+ for (const { target, count } of groups.values()) {
2165
+ const region = el('button', {
2166
+ class: 'blk-imgregion',
2167
+ type: 'button',
2168
+ 'data-count': String(count),
2169
+ title: `${target.side ? target.side + ' · ' : ''}${count} ${count === 1 ? 'comment' : 'comments'}`,
2170
+ 'aria-label': `${target.side ? target.side + ' image area' : 'image area'} with ${count} ${count === 1 ? 'comment' : 'comments'}`,
2171
+ });
2172
+ region.style.left = (target.x * 100) + '%';
2173
+ region.style.top = (target.y * 100) + '%';
2174
+ region.style.width = (target.w * 100) + '%';
2175
+ region.style.height = (target.h * 100) + '%';
2176
+ region.addEventListener('pointerdown', (e) => e.stopPropagation());
2177
+ region.addEventListener('click', (e) => {
2178
+ e.stopPropagation();
2179
+ ctx.annotate.openExternal({ blockId, questionId: ctx.questionId, target }, region);
2180
+ });
2181
+ layer.append(region);
2182
+ }
2183
+ };
2184
+ if (ctx.annotate.onBadgeRefresh) ctx.annotate.onBadgeRefresh(syncRegions);
2185
+ syncRegions();
2186
+ }
2187
+
1968
2188
  // ---------- palette ----------
1969
2189
  // Color palettes as swatch cards: hover a swatch to reveal its hex, click to
1970
2190
  // copy it. One palette may be {featured:true} → a larger spotlight row. Lets
@@ -2106,18 +2326,46 @@
2106
2326
  window.addEventListener('resize', sizeBefore);
2107
2327
 
2108
2328
  let dragging = false;
2329
+ let dragMoved = false;
2330
+ let dragStartX = 0;
2109
2331
  const setFromX = (clientX) => {
2110
2332
  const r = frame.getBoundingClientRect();
2111
2333
  if (r.width) { pos = Math.max(0, Math.min(100, ((clientX - r.left) / r.width) * 100)); apply(); }
2112
2334
  };
2113
- frame.addEventListener('pointerdown', (e) => { dragging = true; setFromX(e.clientX); try { frame.setPointerCapture(e.pointerId); } catch (_) {} e.preventDefault(); });
2114
- frame.addEventListener('pointermove', (e) => { if (dragging) setFromX(e.clientX); });
2115
- frame.addEventListener('pointerup', () => { dragging = false; });
2116
- frame.addEventListener('pointercancel', () => { dragging = false; });
2335
+ frame.addEventListener('pointerdown', (e) => {
2336
+ if (e.target.closest && e.target.closest('.blk-imgregion')) return;
2337
+ dragging = true;
2338
+ dragMoved = false;
2339
+ dragStartX = e.clientX;
2340
+ try { frame.setPointerCapture(e.pointerId); } catch (_) {}
2341
+ });
2342
+ frame.addEventListener('pointermove', (e) => {
2343
+ if (!dragging || frame._rlyRegionSelecting) return;
2344
+ if (Math.abs(e.clientX - dragStartX) > 3) dragMoved = true;
2345
+ if (dragMoved) { setFromX(e.clientX); e.preventDefault(); }
2346
+ });
2347
+ frame.addEventListener('pointerup', (e) => {
2348
+ if (dragging && !dragMoved && !frame._rlyRegionSelecting) setFromX(e.clientX);
2349
+ dragging = false;
2350
+ });
2351
+ frame.addEventListener('pointercancel', () => { dragging = false; dragMoved = false; });
2117
2352
  handle.addEventListener('keydown', (e) => {
2118
2353
  if (e.key === 'ArrowLeft') { pos = Math.max(0, pos - 2); apply(); e.preventDefault(); }
2119
2354
  else if (e.key === 'ArrowRight') { pos = Math.min(100, pos + 2); apply(); e.preventDefault(); }
2120
2355
  });
2356
+ if (ctx.annotate && ctx.canComment !== false) {
2357
+ frame.title = 'Hold briefly, then drag to comment on the visible Before or After image';
2358
+ enableImageRegions({
2359
+ host: frame,
2360
+ surface: frame,
2361
+ panHost: frame,
2362
+ sourceForSide: (side) => side === 'before' ? beforeImg : afterImg,
2363
+ sideAtPoint: (x) => (x * 100 <= pos ? 'before' : 'after'),
2364
+ ctx,
2365
+ blockId,
2366
+ label: 'Comparison',
2367
+ });
2368
+ }
2121
2369
  attachViewer(wrap, { zoomEl: null, label: 'comparison', comment: wholeBlockComment(ctx, blockId, 'comparison') });
2122
2370
  return wrap;
2123
2371
  }
@@ -2132,6 +2380,15 @@
2132
2380
  // full-screen content insets — must mirror the .blk-full padding in blocks.css
2133
2381
  // so the fit-to-screen scale clears the fixed toolbar + leaves a small margin.
2134
2382
  const FULL_PAD_X = 24, FULL_PAD_TOP = 44, FULL_PAD_BOTTOM = 24;
2383
+ // Wheel/pinch zoom is delta-proportional and animated. The old fixed 15% jump
2384
+ // ran once for every wheel event, so a high-frequency trackpad gesture could
2385
+ // compound to several hundred percent almost instantly. Cap the contribution
2386
+ // of one event (mouse wheels often report ±100px) and ease toward the gesture's
2387
+ // accumulated target at most once per animation frame.
2388
+ const WHEEL_ZOOM_SENSITIVITY = 0.003;
2389
+ const WHEEL_ZOOM_MAX_DELTA = 20;
2390
+ const WHEEL_ZOOM_EASE = 0.24;
2391
+ const WHEEL_ZOOM_EPSILON = 0.001;
2135
2392
 
2136
2393
  // Toolbar icons: full-screen (4-corner expand) and a speech-bubble for the
2137
2394
  // "comment on the whole block" button. Zoom is both cmd/ctrl+wheel AND
@@ -2146,13 +2403,13 @@
2146
2403
  function exitFull() {
2147
2404
  if (!fullOpen) return;
2148
2405
  const c = fullOpen;
2406
+ if (c._rlyStopWheelZoom) c._rlyStopWheelZoom();
2149
2407
  c.classList.remove('blk-full');
2150
2408
  document.body.classList.remove('blk-full-open');
2151
2409
  const btn = c.querySelector('.blk-tools .tool-full');
2152
2410
  if (btn) btn.innerHTML = ICON_EXPAND;
2153
2411
  fullOpen = null;
2154
2412
  window.dispatchEvent(new Event('resize'));
2155
- if (c._rlyToolsSync) c._rlyToolsSync(); // re-pin toolbar to its scrolled corner
2156
2413
  if (c._rlyZoom) c._rlyZoom.reapply(); // restore the compact inline height cap
2157
2414
  }
2158
2415
  document.addEventListener('keydown', (e) => {
@@ -2164,13 +2421,13 @@
2164
2421
  function enterFull(container) {
2165
2422
  if (fullOpen === container) return;
2166
2423
  exitFull();
2424
+ if (container._rlyStopWheelZoom) container._rlyStopWheelZoom();
2167
2425
  container.classList.add('blk-full');
2168
2426
  document.body.classList.add('blk-full-open');
2169
2427
  fullOpen = container;
2170
2428
  const btn = container.querySelector('.blk-tools .tool-full');
2171
2429
  if (btn) btn.textContent = '✕';
2172
2430
  window.dispatchEvent(new Event('resize'));
2173
- if (container._rlyToolsSync) container._rlyToolsSync();
2174
2431
  if (container._rlyZoom) container._rlyZoom.reapply();
2175
2432
  }
2176
2433
 
@@ -2273,6 +2530,7 @@
2273
2530
  });
2274
2531
  scrollEl.addEventListener('pointermove', (e) => {
2275
2532
  if (!pending) return;
2533
+ if (scrollEl._rlyRegionSelecting) { pending = false; active = false; return; }
2276
2534
  const dx = e.clientX - sx, dy = e.clientY - sy;
2277
2535
  if (!active) {
2278
2536
  if (Math.abs(dx) < THRESH && Math.abs(dy) < THRESH) return;
@@ -2306,7 +2564,7 @@
2306
2564
  // with a block-scoped target ("Whole chart/diagram/…") so a user can comment
2307
2565
  // on the visual as a whole. null when annotation is off (omits the button).
2308
2566
  function wholeBlockComment(ctx, blockId, label) {
2309
- if (!ctx || !ctx.annotate) return null;
2567
+ if (!ctx || !ctx.annotate || ctx.canComment === false) return null;
2310
2568
  const a = ctx.annotate;
2311
2569
  return {
2312
2570
  open: (anchorEl) =>
@@ -2333,25 +2591,13 @@
2333
2591
  if (!container._rlyPan) container._rlyPan = enablePan(container);
2334
2592
  const refreshPan = container._rlyPan;
2335
2593
 
2336
- // The toolbar is absolute inside the scroll box, so it scrolls away when the
2337
- // user pans/scrolls. Counter-translate it by the scroll offset to pin it to
2338
- // the visible corner (off in full-screen, where it is position:fixed). Bound
2339
- // once; always re-reads the current toolbar (mermaid rebuilds it on render).
2340
- if (!container._rlyToolsSync) {
2341
- const sync = () => {
2342
- const tb = container._rlyTools;
2343
- if (!tb) return;
2344
- if (container.classList.contains('blk-full')) { tb.style.transform = ''; return; }
2345
- const x = container.scrollLeft, y = container.scrollTop;
2346
- tb.style.transform = x || y ? 'translate(' + x + 'px,' + y + 'px)' : '';
2347
- };
2348
- container.addEventListener('scroll', sync);
2349
- container._rlyToolsSync = sync;
2350
- }
2351
-
2352
2594
  // zoom level persists across re-renders; the wheel handler (bound once)
2353
2595
  // delegates through container._rlyZoom so it never holds a stale zoomEl
2354
2596
  if (container._rlyZ === undefined) container._rlyZ = null; // null = fit-to-width
2597
+ // Fluid visuals (currently Chart.js) fit the live container width. Capture
2598
+ // that rendered size only when leaving fit mode so zoom remains proportional
2599
+ // without freezing later fit-mode viewport growth to an old width.
2600
+ let fluidZoomNatural = null;
2355
2601
  const pct = el('span', { class: 'tool-pct' }, 'fit');
2356
2602
  function apply() {
2357
2603
  if (!zoomable) return;
@@ -2377,36 +2623,57 @@
2377
2623
  const scale = Math.min(availW / nat.w, availH / nat.h);
2378
2624
  target.style.maxWidth = 'none';
2379
2625
  target.style.width = Math.max(1, Math.round(nat.w * scale)) + 'px';
2380
- target.style.height = 'auto';
2626
+ target.style.height = opts.scaleHeight ? Math.max(1, Math.round(nat.h * scale)) + 'px' : 'auto';
2381
2627
  pct.textContent = 'fit';
2382
2628
  } else if (z === null || !nat || !nat.w) {
2383
2629
  target.style.width = '100%';
2384
- target.style.maxWidth = nat && nat.w ? Math.ceil(nat.w) + 'px' : '100%';
2385
- target.style.height = 'auto';
2630
+ target.style.maxWidth = opts.fluidFit ? '100%' : (nat && nat.w ? Math.ceil(nat.w) + 'px' : '100%');
2631
+ target.style.height = opts.scaleHeight ? '100%' : 'auto';
2386
2632
  pct.textContent = 'fit';
2387
2633
  } else {
2634
+ const zoomNat = fluidZoomNatural || nat;
2388
2635
  target.style.maxWidth = 'none';
2389
- target.style.width = Math.round(nat.w * z) + 'px';
2390
- target.style.height = 'auto';
2636
+ target.style.width = Math.round(zoomNat.w * z) + 'px';
2637
+ target.style.height = opts.scaleHeight && zoomNat && zoomNat.h ? Math.round(zoomNat.h * z) + 'px' : 'auto';
2391
2638
  pct.textContent = Math.round(z * 100) + '%';
2392
2639
  }
2393
2640
  window.dispatchEvent(new Event('resize')); // annotation badges reposition
2394
2641
  refreshPan(); // content size → grab affordance
2395
- container._rlyToolsSync(); // keep the toolbar pinned
2396
2642
  }
2397
2643
  function currentZ() {
2398
2644
  if (container._rlyZ !== null) return container._rlyZ;
2645
+ if (opts.fluidFit) return 1;
2399
2646
  const nat = opts.natural();
2400
2647
  if (!nat || !nat.w) return 1;
2401
2648
  const shown = opts.zoomEl.getBoundingClientRect().width;
2402
2649
  return shown > 0 ? shown / nat.w : 1;
2403
2650
  }
2404
2651
  function setZoom(next) {
2652
+ if (next === null) {
2653
+ fluidZoomNatural = null;
2654
+ } else if (opts.fluidFit && container._rlyZ === null) {
2655
+ const shown = opts.zoomEl.getBoundingClientRect();
2656
+ fluidZoomNatural = {
2657
+ w: Math.max(1, shown.width),
2658
+ h: Math.max(1, shown.height),
2659
+ };
2660
+ }
2405
2661
  container._rlyZ = next === null ? null : Math.min(8, Math.max(0.2, next));
2406
2662
  apply();
2407
2663
  }
2408
2664
  container._rlyZoom = { setZoom, currentZ, reapply: apply };
2409
2665
 
2666
+ // Stop any in-flight wheel easing before an explicit toolbar/full-screen
2667
+ // action. This prevents an older gesture target from overriding that action
2668
+ // on the next animation frame.
2669
+ if (!container._rlyStopWheelZoom) {
2670
+ container._rlyStopWheelZoom = () => {
2671
+ if (container._rlyWheelFrame) cancelAnimationFrame(container._rlyWheelFrame);
2672
+ container._rlyWheelFrame = null;
2673
+ container._rlyWheelTarget = null;
2674
+ };
2675
+ }
2676
+
2410
2677
  const tools = el('div', { class: 'blk-tools' });
2411
2678
 
2412
2679
  // comment on the whole block (leftmost) — opens the annotation popover with
@@ -2435,18 +2702,53 @@
2435
2702
  const onWheel = (e) => {
2436
2703
  if (!(e.ctrlKey || e.metaKey) || !container._rlyZoom) return;
2437
2704
  e.preventDefault();
2438
- container._rlyZoom.setZoom(container._rlyZoom.currentZ() * (e.deltaY < 0 ? 1.15 : 1 / 1.15));
2705
+ // Normalize line/page-mode devices to pixel-like units, then cap a
2706
+ // single event. Exponential scaling makes equal motion reversible and
2707
+ // lets tiny trackpad deltas stay tiny instead of becoming fixed jumps.
2708
+ const unit = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? window.innerHeight : 1;
2709
+ const delta = Math.max(-WHEEL_ZOOM_MAX_DELTA, Math.min(WHEEL_ZOOM_MAX_DELTA, e.deltaY * unit));
2710
+ const base = Number.isFinite(container._rlyWheelTarget)
2711
+ ? container._rlyWheelTarget
2712
+ : container._rlyZoom.currentZ();
2713
+ container._rlyWheelTarget = Math.min(8, Math.max(0.2, base * Math.exp(-delta * WHEEL_ZOOM_SENSITIVITY)));
2714
+
2715
+ // Respect reduced-motion while retaining the gentler delta-based step.
2716
+ if (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
2717
+ container._rlyZoom.setZoom(container._rlyWheelTarget);
2718
+ container._rlyWheelTarget = null;
2719
+ return;
2720
+ }
2721
+ if (container._rlyWheelFrame) return; // coalesce bursty input to one rAF loop
2722
+ const tick = () => {
2723
+ const zoom = container._rlyZoom;
2724
+ const target = container._rlyWheelTarget;
2725
+ if (!zoom || !Number.isFinite(target)) {
2726
+ container._rlyWheelFrame = null;
2727
+ return;
2728
+ }
2729
+ const current = zoom.currentZ();
2730
+ const gap = target - current;
2731
+ if (Math.abs(gap) <= WHEEL_ZOOM_EPSILON) {
2732
+ zoom.setZoom(target);
2733
+ container._rlyWheelFrame = null;
2734
+ container._rlyWheelTarget = null;
2735
+ return;
2736
+ }
2737
+ zoom.setZoom(current + gap * WHEEL_ZOOM_EASE);
2738
+ container._rlyWheelFrame = requestAnimationFrame(tick);
2739
+ };
2740
+ container._rlyWheelFrame = requestAnimationFrame(tick);
2439
2741
  };
2440
2742
  container.addEventListener('wheel', onWheel, { passive: false });
2441
2743
  container._rlyWheel = onWheel;
2442
2744
  }
2443
2745
  const zoomOut = el('button', { class: 'tool-zoom', type: 'button', title: 'Zoom out' }, '−');
2444
2746
  const zoomIn = el('button', { class: 'tool-zoom', type: 'button', title: 'Zoom in' }, '+');
2445
- zoomOut.addEventListener('click', (e) => { e.stopPropagation(); setZoom(currentZ() / 1.2); });
2446
- zoomIn.addEventListener('click', (e) => { e.stopPropagation(); setZoom(currentZ() * 1.2); });
2747
+ zoomOut.addEventListener('click', (e) => { e.stopPropagation(); container._rlyStopWheelZoom(); setZoom(currentZ() / 1.2); });
2748
+ zoomIn.addEventListener('click', (e) => { e.stopPropagation(); container._rlyStopWheelZoom(); setZoom(currentZ() * 1.2); });
2447
2749
  pct.classList.add('is-btn');
2448
2750
  pct.title = 'Reset to fit';
2449
- pct.addEventListener('click', (e) => { e.stopPropagation(); setZoom(null); });
2751
+ pct.addEventListener('click', (e) => { e.stopPropagation(); container._rlyStopWheelZoom(); setZoom(null); });
2450
2752
  tools.append(zoomOut, pct, zoomIn);
2451
2753
  }
2452
2754
 
@@ -2459,12 +2761,11 @@
2459
2761
  });
2460
2762
  tools.append(fullBtn);
2461
2763
 
2462
- container.append(tools);
2764
+ container.prepend(tools);
2463
2765
  container._rlyTools = tools;
2464
2766
  if (zoomable) apply();
2465
2767
  // non-zoomable diagrams (tall mermaid, oversized image) can still overflow
2466
2768
  refreshPan();
2467
- container._rlyToolsSync();
2468
2769
  if (container._rlyCmtSync) container._rlyCmtSync(); // reflect existing comment
2469
2770
  }
2470
2771
 
@@ -2586,6 +2887,10 @@
2586
2887
  // editable-mermaid plumbing: edits maps blockId -> edited code; onBlockEdit
2587
2888
  // reports an accepted change (or null to clear back to the original).
2588
2889
  edits: ctx && ctx.edits ? ctx.edits : {},
2890
+ canComment: !ctx || ctx.canComment !== false,
2891
+ saveArtifact:
2892
+ ctx && typeof ctx.saveArtifact === 'function' ? ctx.saveArtifact : null,
2893
+ canEditBlocks: !ctx || ctx.canEditBlocks !== false,
2589
2894
  onBlockEdit:
2590
2895
  ctx && typeof ctx.onBlockEdit === 'function' ? ctx.onBlockEdit : () => {},
2591
2896
  };