@khanglvm/relay 0.15.0 → 0.16.1

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
@@ -1888,6 +1888,24 @@
1888
1888
  }
1889
1889
 
1890
1890
  // ---------- image ----------
1891
+ let activeRegionMode = null;
1892
+ let viewerSpacePan = false;
1893
+ const setViewerSpacePan = (enabled) => {
1894
+ viewerSpacePan = Boolean(enabled);
1895
+ document.body.classList.toggle('blk-space-pan', viewerSpacePan);
1896
+ };
1897
+ document.addEventListener('keydown', (e) => {
1898
+ const tag = e.target && e.target.tagName ? e.target.tagName.toLowerCase() : '';
1899
+ if (e.code === 'Space' && !e.repeat && !['input', 'textarea', 'select'].includes(tag)) {
1900
+ setViewerSpacePan(true);
1901
+ e.preventDefault();
1902
+ }
1903
+ });
1904
+ document.addEventListener('keyup', (e) => {
1905
+ if (e.code === 'Space') setViewerSpacePan(false);
1906
+ });
1907
+ window.addEventListener('blur', () => setViewerSpacePan(false));
1908
+
1891
1909
  // src is a remote URL, or absent for embedded local files (served by the
1892
1910
  // board server at /img/b/<id>). Same sizing rule as diagrams: never upscale
1893
1911
  // past natural width; zoom/full-screen viewer attached once loaded.
@@ -1901,7 +1919,11 @@
1901
1919
  src,
1902
1920
  alt: block.alt || 'image',
1903
1921
  loading: 'lazy',
1922
+ draggable: 'false',
1923
+ title: ctx.canComment === false ? '' : 'Drag to draw a comment area · Space-drag to pan',
1904
1924
  });
1925
+ img.addEventListener('dragstart', (e) => e.preventDefault());
1926
+ const stage = el('div', { class: 'blk-imgstage' }, img);
1905
1927
  // Inline default: the image fills its full width (container width, never
1906
1928
  // upscaled past natural) so it's readable without manual zoom; the CONTAINER
1907
1929
  // caps the height (default 800 via CSS, or block.height) and SCROLLS — we
@@ -1911,18 +1933,31 @@
1911
1933
  img.addEventListener('error', () => {
1912
1934
  container.replaceChildren(el('div', { class: 'blk-error' }, 'Image failed to load'));
1913
1935
  });
1914
- container.append(img);
1936
+ container.append(stage);
1937
+ const region = ctx.annotate && ctx.canComment !== false
1938
+ ? enableImageRegions({
1939
+ host: stage,
1940
+ surface: img,
1941
+ panHost: container,
1942
+ sourceForSide: () => img,
1943
+ sideAtPoint: () => null,
1944
+ ctx,
1945
+ blockId,
1946
+ label: block.alt || 'Image',
1947
+ })
1948
+ : null;
1915
1949
  const attachImgViewer = () =>
1916
1950
  attachViewer(container, {
1917
- zoomEl: img,
1951
+ zoomEl: stage,
1918
1952
  natural: () => (img.naturalWidth > 0 ? { w: img.naturalWidth, h: img.naturalHeight } : null),
1919
1953
  label: 'image',
1920
1954
  comment: wholeBlockComment(ctx, blockId, 'image'),
1955
+ region,
1921
1956
  });
1922
1957
  if (img.complete && img.naturalWidth > 0) attachImgViewer();
1923
1958
  else img.addEventListener('load', attachImgViewer, { once: true });
1924
1959
  if (ctx.annotate && block.pins === true) {
1925
- enableImagePins(container, img, ctx, blockId, block.alt || 'Image');
1960
+ enableImagePins(stage, img, ctx, blockId, block.alt || 'Image');
1926
1961
  } else if (ctx.annotate) {
1927
1962
  ctx.annotate.register(img, {
1928
1963
  blockId,
@@ -1945,6 +1980,7 @@
1945
1980
  img.addEventListener('pointerdown', (e) => { down = { x: e.clientX, y: e.clientY }; });
1946
1981
  img.addEventListener('click', (e) => {
1947
1982
  if (ctx.canComment === false) return;
1983
+ if (img._rlySuppressPointClick) { img._rlySuppressPointClick = false; down = null; return; }
1948
1984
  if (down && (Math.abs(e.clientX - down.x) > 4 || Math.abs(e.clientY - down.y) > 4)) { down = null; return; }
1949
1985
  const r = img.getBoundingClientRect();
1950
1986
  if (!r.width || !r.height) return;
@@ -1984,6 +2020,244 @@
1984
2020
  syncPins();
1985
2021
  }
1986
2022
 
2023
+ // Hold, then drag, to select a rectangular image area. A short move remains
2024
+ // native viewer pan / comparison-slider input; the hold threshold makes the
2025
+ // two gestures coexist without a mode switch. Region coordinates are stored
2026
+ // as normalized fractions, while a pixel crop is uploaded through the host
2027
+ // context so the result gives the agent an actual viewable image artifact.
2028
+ function enableImageRegions({ host, surface, panHost, sourceForSide, sideAtPoint, ctx, blockId, label }) {
2029
+ const layer = el('div', { class: 'blk-region-layer' });
2030
+ const selection = el('div', { class: 'blk-region-selection' });
2031
+ layer.append(selection);
2032
+ host.append(layer);
2033
+ panHost._rlyHasRegions = true;
2034
+ panHost.classList.add('blk-has-regions');
2035
+ let pending = null;
2036
+ let active = false;
2037
+ let status = null;
2038
+ let modeActive = false;
2039
+ const modeHooks = new Set();
2040
+ let controller = null;
2041
+ const MIN_REGION = 0.012;
2042
+
2043
+ const localPoint = (e) => {
2044
+ const r = host.getBoundingClientRect();
2045
+ return {
2046
+ x: r.width ? Math.max(0, Math.min(1, (e.clientX - r.left) / r.width)) : 0,
2047
+ y: r.height ? Math.max(0, Math.min(1, (e.clientY - r.top) / r.height)) : 0,
2048
+ };
2049
+ };
2050
+ const clearStatus = () => { if (status) status.remove(); status = null; };
2051
+ const showStatus = (text) => {
2052
+ clearStatus();
2053
+ status = el('div', { class: 'blk-region-status', role: 'status' }, text);
2054
+ host.append(status);
2055
+ };
2056
+ const reset = () => {
2057
+ active = false;
2058
+ pending = null;
2059
+ panHost._rlyRegionSelecting = false;
2060
+ host.classList.remove('blk-region-arming', 'blk-region-selecting');
2061
+ selection.style.display = 'none';
2062
+ clearStatus();
2063
+ };
2064
+ const setModeActive = (next) => {
2065
+ const enabled = Boolean(next);
2066
+ if (enabled && activeRegionMode && activeRegionMode !== controller) {
2067
+ activeRegionMode.setModeActive(false);
2068
+ }
2069
+ if (!enabled && pending) reset();
2070
+ if (modeActive === enabled) return;
2071
+ modeActive = enabled;
2072
+ panHost._rlyRegionMode = enabled;
2073
+ host.classList.toggle('blk-region-mode', enabled);
2074
+ activeRegionMode = enabled ? controller : (activeRegionMode === controller ? null : activeRegionMode);
2075
+ for (const fn of modeHooks) { try { fn(enabled); } catch (_) {} }
2076
+ };
2077
+ controller = {
2078
+ setModeActive,
2079
+ isModeActive: () => modeActive,
2080
+ onModeChange: (fn) => {
2081
+ if (typeof fn === 'function') modeHooks.add(fn);
2082
+ return () => modeHooks.delete(fn);
2083
+ },
2084
+ };
2085
+ document.addEventListener('keydown', (e) => {
2086
+ if (e.key === 'Escape' && modeActive) setModeActive(false);
2087
+ });
2088
+ const draw = (a, b) => {
2089
+ const x = Math.min(a.x, b.x), y = Math.min(a.y, b.y);
2090
+ const w = Math.abs(a.x - b.x), h = Math.abs(a.y - b.y);
2091
+ selection.style.left = (x * 100) + '%';
2092
+ selection.style.top = (y * 100) + '%';
2093
+ selection.style.width = (w * 100) + '%';
2094
+ selection.style.height = (h * 100) + '%';
2095
+ return { x, y, w, h };
2096
+ };
2097
+ const createRegionZone = (target, count, provisional = false) => {
2098
+ const badge = el('span', { class: 'blk-imgregion-badge', 'aria-hidden': 'true' });
2099
+ badge.innerHTML = ICON_COMMENT;
2100
+ badge.append(el('span', { class: 'blk-imgregion-count' }, String(count)));
2101
+ const sideLabel = target.side ? target.side + ' ' : '';
2102
+ const region = el('button', {
2103
+ class: 'blk-imgregion' + (provisional ? ' blk-imgregion-provisional' : ''),
2104
+ type: 'button',
2105
+ title: provisional
2106
+ ? `Add a comment to this ${sideLabel}area`
2107
+ : `${target.side ? target.side + ' · ' : ''}${count} ${count === 1 ? 'comment' : 'comments'}`,
2108
+ 'aria-label': provisional
2109
+ ? `New ${sideLabel}image area comment`
2110
+ : `${sideLabel}image area with ${count} ${count === 1 ? 'comment' : 'comments'}`,
2111
+ }, badge);
2112
+ region.style.left = (target.x * 100) + '%';
2113
+ region.style.top = (target.y * 100) + '%';
2114
+ region.style.width = (target.w * 100) + '%';
2115
+ region.style.height = (target.h * 100) + '%';
2116
+ region.addEventListener('pointerdown', (e) => e.stopPropagation());
2117
+ if (!provisional) {
2118
+ region.addEventListener('click', (e) => {
2119
+ e.stopPropagation();
2120
+ ctx.annotate.openExternal({ blockId, questionId: ctx.questionId, target }, region);
2121
+ });
2122
+ }
2123
+ return region;
2124
+ };
2125
+ const beginSelection = (pointerId) => {
2126
+ if (!pending || active) return;
2127
+ active = true;
2128
+ panHost._rlyRegionSelecting = true;
2129
+ host.classList.remove('blk-region-arming');
2130
+ host.classList.add('blk-region-selecting');
2131
+ selection.style.display = 'block';
2132
+ draw(pending.start, pending.start);
2133
+ showStatus((pending.side ? pending.side.charAt(0).toUpperCase() + pending.side.slice(1) + ' · ' : '') + 'drag to select an area');
2134
+ try { surface.setPointerCapture(pointerId); } catch (_) {}
2135
+ };
2136
+
2137
+ async function cropRegion(img, region) {
2138
+ if (!img || !img.naturalWidth || !img.naturalHeight) throw new Error('image is not ready');
2139
+ const sx = Math.max(0, Math.floor(region.x * img.naturalWidth));
2140
+ const sy = Math.max(0, Math.floor(region.y * img.naturalHeight));
2141
+ const sw = Math.max(1, Math.min(img.naturalWidth - sx, Math.round(region.w * img.naturalWidth)));
2142
+ const sh = Math.max(1, Math.min(img.naturalHeight - sy, Math.round(region.h * img.naturalHeight)));
2143
+ const scale = Math.min(1, 1400 / sw, 1400 / sh);
2144
+ const width = Math.max(1, Math.round(sw * scale));
2145
+ const height = Math.max(1, Math.round(sh * scale));
2146
+ const canvas = document.createElement('canvas');
2147
+ canvas.width = width;
2148
+ canvas.height = height;
2149
+ const g = canvas.getContext('2d');
2150
+ if (!g) throw new Error('canvas unavailable');
2151
+ g.drawImage(img, sx, sy, sw, sh, 0, 0, width, height);
2152
+ const dataUrl = canvas.toDataURL('image/png');
2153
+ if (!/^data:image\/png;base64,/.test(dataUrl)) throw new Error('crop unavailable');
2154
+ if (ctx.saveArtifact) return ctx.saveArtifact({ dataUrl, mime: 'image/png', width, height, blockId });
2155
+ return { data: dataUrl.slice(dataUrl.indexOf(',') + 1), mime: 'image/png', width, height };
2156
+ }
2157
+
2158
+ const finishRegion = async (region, side) => {
2159
+ const target = {
2160
+ kind: 'image-region',
2161
+ x: Math.round(region.x * 10000) / 10000,
2162
+ y: Math.round(region.y * 10000) / 10000,
2163
+ w: Math.round(region.w * 10000) / 10000,
2164
+ h: Math.round(region.h * 10000) / 10000,
2165
+ side: side || undefined,
2166
+ label,
2167
+ };
2168
+ const provisional = createRegionZone(target, 0, true);
2169
+ layer.append(provisional);
2170
+ showStatus('Saving selected area…');
2171
+ try {
2172
+ target.crop = await cropRegion(sourceForSide(side), region);
2173
+ } catch {
2174
+ target.cropUnavailable = true;
2175
+ }
2176
+ clearStatus();
2177
+ ctx.annotate.openExternal({
2178
+ blockId,
2179
+ questionId: ctx.questionId,
2180
+ target,
2181
+ onClose: () => provisional.remove(),
2182
+ }, provisional);
2183
+ };
2184
+
2185
+ surface.addEventListener('pointerdown', (e) => {
2186
+ if (e.button !== 0 || viewerSpacePan || ctx.canComment === false) return;
2187
+ if (e.target.closest && e.target.closest('.cmp-handle, .blk-imgregion, .blk-tools')) return;
2188
+ if (modeActive) {
2189
+ e.preventDefault();
2190
+ e.stopPropagation();
2191
+ }
2192
+ const p = localPoint(e);
2193
+ pending = {
2194
+ start: p,
2195
+ last: p,
2196
+ clientX: e.clientX,
2197
+ clientY: e.clientY,
2198
+ pointerId: e.pointerId,
2199
+ explicitMode: modeActive,
2200
+ side: sideAtPoint(p.x),
2201
+ };
2202
+ host.classList.add('blk-region-arming');
2203
+ if (modeActive) beginSelection(e.pointerId);
2204
+ });
2205
+ surface.addEventListener('pointermove', (e) => {
2206
+ if (!pending) return;
2207
+ const moved = Math.hypot(e.clientX - pending.clientX, e.clientY - pending.clientY);
2208
+ if (!active && moved > 5) beginSelection(pending.pointerId);
2209
+ if (!active) {
2210
+ return;
2211
+ }
2212
+ e.preventDefault();
2213
+ e.stopPropagation();
2214
+ pending.last = localPoint(e);
2215
+ draw(pending.start, pending.last);
2216
+ });
2217
+ const end = (e) => {
2218
+ if (!pending) return;
2219
+ if (!active) { reset(); return; }
2220
+ e.preventDefault();
2221
+ const region = draw(pending.start, pending.last || localPoint(e));
2222
+ const side = pending.side;
2223
+ const explicitMode = pending.explicitMode;
2224
+ surface._rlySuppressPointClick = true;
2225
+ reset();
2226
+ clearStatus();
2227
+ if (region.w >= MIN_REGION && region.h >= MIN_REGION) {
2228
+ if (explicitMode) setModeActive(false);
2229
+ finishRegion(region, side);
2230
+ } else if (explicitMode) {
2231
+ showStatus('Drag a larger area');
2232
+ setTimeout(clearStatus, 1200);
2233
+ }
2234
+ };
2235
+ surface.addEventListener('pointerup', end);
2236
+ // Some Chromium/CDP paths emit pointercancel immediately after granting
2237
+ // capture on the first dragged frame. Once area mode is active, keep the
2238
+ // last drawn rectangle instead of throwing the user's deliberate hold away.
2239
+ surface.addEventListener('pointercancel', (e) => active ? end(e) : reset());
2240
+
2241
+ const syncRegions = () => {
2242
+ for (const node of Array.from(layer.querySelectorAll('.blk-imgregion:not(.blk-imgregion-provisional)'))) node.remove();
2243
+ const groups = new Map();
2244
+ for (const a of ctx.annotate.list()) {
2245
+ if (a.blockId !== blockId || !a.target || a.target.kind !== 'image-region') continue;
2246
+ const t = a.target;
2247
+ const key = [t.side || '', t.x, t.y, t.w, t.h].join(':');
2248
+ const group = groups.get(key) || { target: t, count: 0 };
2249
+ group.count++;
2250
+ groups.set(key, group);
2251
+ }
2252
+ for (const { target, count } of groups.values()) {
2253
+ layer.append(createRegionZone(target, count));
2254
+ }
2255
+ };
2256
+ if (ctx.annotate.onBadgeRefresh) ctx.annotate.onBadgeRefresh(syncRegions);
2257
+ syncRegions();
2258
+ return controller;
2259
+ }
2260
+
1987
2261
  // ---------- palette ----------
1988
2262
  // Color palettes as swatch cards: hover a swatch to reveal its hex, click to
1989
2263
  // copy it. One palette may be {featured:true} → a larger spotlight row. Lets
@@ -2125,19 +2399,54 @@
2125
2399
  window.addEventListener('resize', sizeBefore);
2126
2400
 
2127
2401
  let dragging = false;
2402
+ let dragMoved = false;
2403
+ let dragStartX = 0;
2128
2404
  const setFromX = (clientX) => {
2129
2405
  const r = frame.getBoundingClientRect();
2130
2406
  if (r.width) { pos = Math.max(0, Math.min(100, ((clientX - r.left) / r.width) * 100)); apply(); }
2131
2407
  };
2132
- frame.addEventListener('pointerdown', (e) => { dragging = true; setFromX(e.clientX); try { frame.setPointerCapture(e.pointerId); } catch (_) {} e.preventDefault(); });
2133
- frame.addEventListener('pointermove', (e) => { if (dragging) setFromX(e.clientX); });
2134
- frame.addEventListener('pointerup', () => { dragging = false; });
2135
- frame.addEventListener('pointercancel', () => { dragging = false; });
2408
+ frame.addEventListener('pointerdown', (e) => {
2409
+ const handleTarget = e.target.closest('.cmp-handle');
2410
+ if (!handleTarget || frame._rlyRegionMode) return;
2411
+ dragging = true;
2412
+ dragMoved = false;
2413
+ dragStartX = e.clientX;
2414
+ try { frame.setPointerCapture(e.pointerId); } catch (_) {}
2415
+ });
2416
+ frame.addEventListener('pointermove', (e) => {
2417
+ if (!dragging || frame._rlyRegionMode || frame._rlyRegionSelecting) return;
2418
+ if (Math.abs(e.clientX - dragStartX) > 3) dragMoved = true;
2419
+ if (dragMoved) { setFromX(e.clientX); e.preventDefault(); }
2420
+ });
2421
+ frame.addEventListener('pointerup', (e) => {
2422
+ if (dragging && !dragMoved && !frame._rlyRegionMode && !frame._rlyRegionSelecting) setFromX(e.clientX);
2423
+ dragging = false;
2424
+ });
2425
+ frame.addEventListener('pointercancel', () => { dragging = false; dragMoved = false; });
2136
2426
  handle.addEventListener('keydown', (e) => {
2137
2427
  if (e.key === 'ArrowLeft') { pos = Math.max(0, pos - 2); apply(); e.preventDefault(); }
2138
2428
  else if (e.key === 'ArrowRight') { pos = Math.min(100, pos + 2); apply(); e.preventDefault(); }
2139
2429
  });
2140
- attachViewer(wrap, { zoomEl: null, label: 'comparison', comment: wholeBlockComment(ctx, blockId, 'comparison') });
2430
+ let region = null;
2431
+ if (ctx.annotate && ctx.canComment !== false) {
2432
+ frame.title = 'Drag to draw a comment area · drag the divider handle to compare';
2433
+ region = enableImageRegions({
2434
+ host: frame,
2435
+ surface: frame,
2436
+ panHost: frame,
2437
+ sourceForSide: (side) => side === 'before' ? beforeImg : afterImg,
2438
+ sideAtPoint: (x) => (x * 100 <= pos ? 'before' : 'after'),
2439
+ ctx,
2440
+ blockId,
2441
+ label: 'Comparison',
2442
+ });
2443
+ }
2444
+ attachViewer(wrap, {
2445
+ zoomEl: null,
2446
+ label: 'comparison',
2447
+ comment: wholeBlockComment(ctx, blockId, 'comparison'),
2448
+ region,
2449
+ });
2141
2450
  return wrap;
2142
2451
  }
2143
2452
 
@@ -2170,6 +2479,10 @@
2170
2479
  const ICON_COMMENT =
2171
2480
  '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
2172
2481
  '<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8z"/></svg>';
2482
+ const ICON_REGION =
2483
+ '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
2484
+ '<rect x="3" y="4" width="14" height="12" rx="2" stroke-dasharray="3 2"/>' +
2485
+ '<path d="M14 14.5h6.5v4H18l-2.5 2v-2H14z" fill="currentColor" stroke="none"/></svg>';
2173
2486
 
2174
2487
  function exitFull() {
2175
2488
  if (!fullOpen) return;
@@ -2181,7 +2494,6 @@
2181
2494
  if (btn) btn.innerHTML = ICON_EXPAND;
2182
2495
  fullOpen = null;
2183
2496
  window.dispatchEvent(new Event('resize'));
2184
- if (c._rlyToolsSync) c._rlyToolsSync(); // re-pin toolbar to its scrolled corner
2185
2497
  if (c._rlyZoom) c._rlyZoom.reapply(); // restore the compact inline height cap
2186
2498
  }
2187
2499
  document.addEventListener('keydown', (e) => {
@@ -2200,7 +2512,6 @@
2200
2512
  const btn = container.querySelector('.blk-tools .tool-full');
2201
2513
  if (btn) btn.textContent = '✕';
2202
2514
  window.dispatchEvent(new Event('resize'));
2203
- if (container._rlyToolsSync) container._rlyToolsSync();
2204
2515
  if (container._rlyZoom) container._rlyZoom.reapply();
2205
2516
  }
2206
2517
 
@@ -2293,7 +2604,8 @@
2293
2604
  const refresh = () => scrollEl.classList.toggle('blk-pannable', pannable());
2294
2605
 
2295
2606
  scrollEl.addEventListener('pointerdown', (e) => {
2296
- if (e.button !== 0 || !pannable()) return;
2607
+ const panGesture = e.button === 1 || (e.button === 0 && (!scrollEl._rlyHasRegions || viewerSpacePan));
2608
+ if (!panGesture || scrollEl._rlyRegionMode || !pannable()) return;
2297
2609
  // leave the toolbar, the diagram editor, and real controls interactive
2298
2610
  if (e.target.closest && e.target.closest('.blk-tools, .blk-editor, button, a, input, textarea, select')) return;
2299
2611
  pending = true; active = false;
@@ -2303,6 +2615,7 @@
2303
2615
  });
2304
2616
  scrollEl.addEventListener('pointermove', (e) => {
2305
2617
  if (!pending) return;
2618
+ if (scrollEl._rlyRegionMode || scrollEl._rlyRegionSelecting) { pending = false; active = false; return; }
2306
2619
  const dx = e.clientX - sx, dy = e.clientY - sy;
2307
2620
  if (!active) {
2308
2621
  if (Math.abs(dx) < THRESH && Math.abs(dy) < THRESH) return;
@@ -2353,6 +2666,10 @@
2353
2666
  // comment (optional) = { open(anchor), active(), subscribe(fn) } wiring the
2354
2667
  // "comment on the whole block" button + its already-commented style.
2355
2668
  function attachViewer(container, opts) {
2669
+ if (container._rlyRegionHook) {
2670
+ try { container._rlyRegionHook(); } catch (_) {}
2671
+ container._rlyRegionHook = null;
2672
+ }
2356
2673
  if (container._rlyTools) container._rlyTools.remove();
2357
2674
  container.classList.add('blk-viewer');
2358
2675
  const zoomable = Boolean(opts && opts.zoomEl);
@@ -2363,22 +2680,6 @@
2363
2680
  if (!container._rlyPan) container._rlyPan = enablePan(container);
2364
2681
  const refreshPan = container._rlyPan;
2365
2682
 
2366
- // The toolbar is absolute inside the scroll box, so it scrolls away when the
2367
- // user pans/scrolls. Counter-translate it by the scroll offset to pin it to
2368
- // the visible corner (off in full-screen, where it is position:fixed). Bound
2369
- // once; always re-reads the current toolbar (mermaid rebuilds it on render).
2370
- if (!container._rlyToolsSync) {
2371
- const sync = () => {
2372
- const tb = container._rlyTools;
2373
- if (!tb) return;
2374
- if (container.classList.contains('blk-full')) { tb.style.transform = ''; return; }
2375
- const x = container.scrollLeft, y = container.scrollTop;
2376
- tb.style.transform = x || y ? 'translate(' + x + 'px,' + y + 'px)' : '';
2377
- };
2378
- container.addEventListener('scroll', sync);
2379
- container._rlyToolsSync = sync;
2380
- }
2381
-
2382
2683
  // zoom level persists across re-renders; the wheel handler (bound once)
2383
2684
  // delegates through container._rlyZoom so it never holds a stale zoomEl
2384
2685
  if (container._rlyZ === undefined) container._rlyZ = null; // null = fit-to-width
@@ -2427,7 +2728,6 @@
2427
2728
  }
2428
2729
  window.dispatchEvent(new Event('resize')); // annotation badges reposition
2429
2730
  refreshPan(); // content size → grab affordance
2430
- container._rlyToolsSync(); // keep the toolbar pinned
2431
2731
  }
2432
2732
  function currentZ() {
2433
2733
  if (container._rlyZ !== null) return container._rlyZ;
@@ -2484,6 +2784,34 @@
2484
2784
  }
2485
2785
  }
2486
2786
 
2787
+ if (opts && opts.region) {
2788
+ const regionCtl = opts.region;
2789
+ const regionLabel = el('span', { class: 'tool-region-label' }, 'Area');
2790
+ const regionBtn = el('button', {
2791
+ class: 'tool-region',
2792
+ type: 'button',
2793
+ title: 'Draw an area comment',
2794
+ 'aria-label': 'Draw an area comment',
2795
+ 'aria-pressed': 'false',
2796
+ });
2797
+ regionBtn.innerHTML = ICON_REGION;
2798
+ regionBtn.append(regionLabel);
2799
+ const syncRegionMode = (enabled) => {
2800
+ regionBtn.classList.toggle('is-active', enabled);
2801
+ regionBtn.setAttribute('aria-pressed', enabled ? 'true' : 'false');
2802
+ regionBtn.title = enabled ? 'Cancel area comment' : 'Draw an area comment';
2803
+ regionBtn.setAttribute('aria-label', regionBtn.title);
2804
+ regionLabel.textContent = enabled ? 'Drawing' : 'Area';
2805
+ };
2806
+ regionBtn.addEventListener('click', (e) => {
2807
+ e.stopPropagation();
2808
+ regionCtl.setModeActive(!regionCtl.isModeActive());
2809
+ });
2810
+ container._rlyRegionHook = regionCtl.onModeChange(syncRegionMode);
2811
+ syncRegionMode(regionCtl.isModeActive());
2812
+ tools.append(regionBtn);
2813
+ }
2814
+
2487
2815
  if (zoomable) {
2488
2816
  // cmd/ctrl+wheel zoom, bound ONCE (re-binding per render would stack);
2489
2817
  // delegates to the current controller so it never uses a stale zoomEl
@@ -2550,12 +2878,11 @@
2550
2878
  });
2551
2879
  tools.append(fullBtn);
2552
2880
 
2553
- container.append(tools);
2881
+ container.prepend(tools);
2554
2882
  container._rlyTools = tools;
2555
2883
  if (zoomable) apply();
2556
2884
  // non-zoomable diagrams (tall mermaid, oversized image) can still overflow
2557
2885
  refreshPan();
2558
- container._rlyToolsSync();
2559
2886
  if (container._rlyCmtSync) container._rlyCmtSync(); // reflect existing comment
2560
2887
  }
2561
2888
 
@@ -2678,6 +3005,8 @@
2678
3005
  // reports an accepted change (or null to clear back to the original).
2679
3006
  edits: ctx && ctx.edits ? ctx.edits : {},
2680
3007
  canComment: !ctx || ctx.canComment !== false,
3008
+ saveArtifact:
3009
+ ctx && typeof ctx.saveArtifact === 'function' ? ctx.saveArtifact : null,
2681
3010
  canEditBlocks: !ctx || ctx.canEditBlocks !== false,
2682
3011
  onBlockEdit:
2683
3012
  ctx && typeof ctx.onBlockEdit === 'function' ? ctx.onBlockEdit : () => {},