@khanglvm/relay 0.15.0 → 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
@@ -1901,7 +1901,9 @@
1901
1901
  src,
1902
1902
  alt: block.alt || 'image',
1903
1903
  loading: 'lazy',
1904
+ title: ctx.canComment === false ? '' : 'Hold briefly, then drag to comment on an area',
1904
1905
  });
1906
+ const stage = el('div', { class: 'blk-imgstage' }, img);
1905
1907
  // Inline default: the image fills its full width (container width, never
1906
1908
  // upscaled past natural) so it's readable without manual zoom; the CONTAINER
1907
1909
  // caps the height (default 800 via CSS, or block.height) and SCROLLS — we
@@ -1911,18 +1913,30 @@
1911
1913
  img.addEventListener('error', () => {
1912
1914
  container.replaceChildren(el('div', { class: 'blk-error' }, 'Image failed to load'));
1913
1915
  });
1914
- container.append(img);
1916
+ container.append(stage);
1915
1917
  const attachImgViewer = () =>
1916
1918
  attachViewer(container, {
1917
- zoomEl: img,
1919
+ zoomEl: stage,
1918
1920
  natural: () => (img.naturalWidth > 0 ? { w: img.naturalWidth, h: img.naturalHeight } : null),
1919
1921
  label: 'image',
1920
1922
  comment: wholeBlockComment(ctx, blockId, 'image'),
1921
1923
  });
1922
1924
  if (img.complete && img.naturalWidth > 0) attachImgViewer();
1923
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
+ }
1924
1938
  if (ctx.annotate && block.pins === true) {
1925
- enableImagePins(container, img, ctx, blockId, block.alt || 'Image');
1939
+ enableImagePins(stage, img, ctx, blockId, block.alt || 'Image');
1926
1940
  } else if (ctx.annotate) {
1927
1941
  ctx.annotate.register(img, {
1928
1942
  blockId,
@@ -1945,6 +1959,7 @@
1945
1959
  img.addEventListener('pointerdown', (e) => { down = { x: e.clientX, y: e.clientY }; });
1946
1960
  img.addEventListener('click', (e) => {
1947
1961
  if (ctx.canComment === false) return;
1962
+ if (img._rlySuppressPointClick) { img._rlySuppressPointClick = false; down = null; return; }
1948
1963
  if (down && (Math.abs(e.clientX - down.x) > 4 || Math.abs(e.clientY - down.y) > 4)) { down = null; return; }
1949
1964
  const r = img.getBoundingClientRect();
1950
1965
  if (!r.width || !r.height) return;
@@ -1984,6 +1999,192 @@
1984
1999
  syncPins();
1985
2000
  }
1986
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
+
1987
2188
  // ---------- palette ----------
1988
2189
  // Color palettes as swatch cards: hover a swatch to reveal its hex, click to
1989
2190
  // copy it. One palette may be {featured:true} → a larger spotlight row. Lets
@@ -2125,18 +2326,46 @@
2125
2326
  window.addEventListener('resize', sizeBefore);
2126
2327
 
2127
2328
  let dragging = false;
2329
+ let dragMoved = false;
2330
+ let dragStartX = 0;
2128
2331
  const setFromX = (clientX) => {
2129
2332
  const r = frame.getBoundingClientRect();
2130
2333
  if (r.width) { pos = Math.max(0, Math.min(100, ((clientX - r.left) / r.width) * 100)); apply(); }
2131
2334
  };
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; });
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; });
2136
2352
  handle.addEventListener('keydown', (e) => {
2137
2353
  if (e.key === 'ArrowLeft') { pos = Math.max(0, pos - 2); apply(); e.preventDefault(); }
2138
2354
  else if (e.key === 'ArrowRight') { pos = Math.min(100, pos + 2); apply(); e.preventDefault(); }
2139
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
+ }
2140
2369
  attachViewer(wrap, { zoomEl: null, label: 'comparison', comment: wholeBlockComment(ctx, blockId, 'comparison') });
2141
2370
  return wrap;
2142
2371
  }
@@ -2181,7 +2410,6 @@
2181
2410
  if (btn) btn.innerHTML = ICON_EXPAND;
2182
2411
  fullOpen = null;
2183
2412
  window.dispatchEvent(new Event('resize'));
2184
- if (c._rlyToolsSync) c._rlyToolsSync(); // re-pin toolbar to its scrolled corner
2185
2413
  if (c._rlyZoom) c._rlyZoom.reapply(); // restore the compact inline height cap
2186
2414
  }
2187
2415
  document.addEventListener('keydown', (e) => {
@@ -2200,7 +2428,6 @@
2200
2428
  const btn = container.querySelector('.blk-tools .tool-full');
2201
2429
  if (btn) btn.textContent = '✕';
2202
2430
  window.dispatchEvent(new Event('resize'));
2203
- if (container._rlyToolsSync) container._rlyToolsSync();
2204
2431
  if (container._rlyZoom) container._rlyZoom.reapply();
2205
2432
  }
2206
2433
 
@@ -2303,6 +2530,7 @@
2303
2530
  });
2304
2531
  scrollEl.addEventListener('pointermove', (e) => {
2305
2532
  if (!pending) return;
2533
+ if (scrollEl._rlyRegionSelecting) { pending = false; active = false; return; }
2306
2534
  const dx = e.clientX - sx, dy = e.clientY - sy;
2307
2535
  if (!active) {
2308
2536
  if (Math.abs(dx) < THRESH && Math.abs(dy) < THRESH) return;
@@ -2363,22 +2591,6 @@
2363
2591
  if (!container._rlyPan) container._rlyPan = enablePan(container);
2364
2592
  const refreshPan = container._rlyPan;
2365
2593
 
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
2594
  // zoom level persists across re-renders; the wheel handler (bound once)
2383
2595
  // delegates through container._rlyZoom so it never holds a stale zoomEl
2384
2596
  if (container._rlyZ === undefined) container._rlyZ = null; // null = fit-to-width
@@ -2427,7 +2639,6 @@
2427
2639
  }
2428
2640
  window.dispatchEvent(new Event('resize')); // annotation badges reposition
2429
2641
  refreshPan(); // content size → grab affordance
2430
- container._rlyToolsSync(); // keep the toolbar pinned
2431
2642
  }
2432
2643
  function currentZ() {
2433
2644
  if (container._rlyZ !== null) return container._rlyZ;
@@ -2550,12 +2761,11 @@
2550
2761
  });
2551
2762
  tools.append(fullBtn);
2552
2763
 
2553
- container.append(tools);
2764
+ container.prepend(tools);
2554
2765
  container._rlyTools = tools;
2555
2766
  if (zoomable) apply();
2556
2767
  // non-zoomable diagrams (tall mermaid, oversized image) can still overflow
2557
2768
  refreshPan();
2558
- container._rlyToolsSync();
2559
2769
  if (container._rlyCmtSync) container._rlyCmtSync(); // reflect existing comment
2560
2770
  }
2561
2771
 
@@ -2678,6 +2888,8 @@
2678
2888
  // reports an accepted change (or null to clear back to the original).
2679
2889
  edits: ctx && ctx.edits ? ctx.edits : {},
2680
2890
  canComment: !ctx || ctx.canComment !== false,
2891
+ saveArtifact:
2892
+ ctx && typeof ctx.saveArtifact === 'function' ? ctx.saveArtifact : null,
2681
2893
  canEditBlocks: !ctx || ctx.canEditBlocks !== false,
2682
2894
  onBlockEdit:
2683
2895
  ctx && typeof ctx.onBlockEdit === 'function' ? ctx.onBlockEdit : () => {},