@khanglvm/relay 0.16.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/README.md CHANGED
@@ -78,7 +78,7 @@ unchanged; pick whichever surface fits.
78
78
  | "Here's the diff — paste it in your editor" | Side-by-side **diff** blocks, syntax-highlighted **code**, **video** walkthroughs, and **file paths you click to open** in the default app |
79
79
  | "Which commits should I pick? Resolve this conflict manually." | `rly git pick` / `rly git cherry-pick` boards and color-coded `git-conflict` resolvers with ours/theirs/custom hunk choices |
80
80
  | "Type *done* when finished reviewing" | A Submit button; answers, notes, and inline comments returned as JSON |
81
- | Feedback = another wall of text | Click any chart point, diagram node, table cell, or sentence; hold+drag an image area to attach a local crop — the agent replies and the thread grows on the board |
81
+ | Feedback = another wall of text | Click any chart point, diagram node, table cell, or sentence; drag an image region to attach a local crop — the agent replies and the thread grows on the board |
82
82
 
83
83
  Everything autosaves in real time, detached board links keep serving after
84
84
  agent timeouts, multiple boards run at once, and the package has **zero npm
@@ -111,6 +111,15 @@ npm test # smoke tests with no external services (spawns real servers, fake-
111
111
 
112
112
  ## Changelog
113
113
 
114
+ ### 0.16.1 — reliable area-comment zones
115
+
116
+ - Primary drag now draws a comment area directly on image and comparison views;
117
+ **Area** remains as a visible one-shot lock. Image pan uses Space-drag or the
118
+ middle button, while the comparison divider moves only from its handle.
119
+ - The selected rectangle remains visible while its comment is composed. Saved
120
+ zones carry a comment icon plus count, reopen for add/edit/delete, and disappear
121
+ automatically when their final comment is removed.
122
+
114
123
  ### 0.16.0 — native viewer chrome, area comments, and durable agent waits
115
124
 
116
125
  - Viewer toolbars now use browser-native sticky positioning instead of a
package/docs/AGENT.md CHANGED
@@ -536,7 +536,7 @@ the question-types section above.
536
536
  | `diff` | proposed code changes / before-after — a unified diff rendered as a colored git-style comparison (no git needed) |
537
537
  | `video` | demos, screen recordings, walkthroughs — YouTube/Vimeo embeds, a media URL, or a local video file (streamed) |
538
538
  | `pdf` | quotes, reports, exports, forms — local `.pdf` files or PDF URLs rendered inline; local files stream from the board server |
539
- | `image` | screenshots, mockup exports, photos — local files embed and work offline; hold then drag to comment on an exact area |
539
+ | `image` | screenshots, mockup exports, photos — local files embed and work offline; drag to comment on exact pixels |
540
540
  | `palette` | color palettes / themes — swatch cards with hover-hex + click-to-copy; pair with a `color` question to let the user pick |
541
541
  | `kpi` | big-number metric cards (`items:[{label,value,delta?,dir?,sub?}]`) with up/down/flat-tinted deltas — at-a-glance numbers without a chart |
542
542
  | `typography` | type specimens (`specimens:[{label?,size?,weight?,font?,text?}]`) — react to type choices like a palette |
@@ -550,12 +550,18 @@ click-to-drop coordinate pin-comments. `table` blocks take `"rowsFile"` (load
550
550
  rows from .csv/.tsv/.json), `"filterable"`, and `"exportable"` (CSV download).
551
551
 
552
552
  Every interactive `image` and `compare` block also supports area feedback:
553
- hold briefly, then drag a rectangle. Relay saves a pixel crop beside the board
554
- record and returns its local path with the annotation, so the agent can open the
555
- exact pixels instead of inferring from a whole-image comment. In a comparison,
556
- the side visible where the hold began is recorded as `before` or `after` and the
557
- crop comes from that source image. Remote images that prevent browser canvas
558
- access still return coordinates and a `cropUnavailable` marker.
553
+ primary drag draws a rectangle directly; the viewer's **Area** control remains a
554
+ discoverable one-shot lock for the same gesture. On images, Space-drag or the
555
+ middle mouse button pans. In comparisons, only dragging the divider handle moves
556
+ the split. The provisional rectangle remains visible while the comment is
557
+ composed; a saved zone shows a comment icon plus count and reopens for
558
+ add/edit/delete. Removing its final comment removes the zone. Relay saves a pixel
559
+ crop beside the board record and returns its local path with the annotation, so
560
+ the agent can open the exact pixels instead of inferring from a whole-image
561
+ comment. In a comparison, the visible side where drawing began is recorded as
562
+ `before` or `after` and the crop comes from that source image. Remote images that
563
+ prevent browser canvas access still return coordinates and a `cropUnavailable`
564
+ marker.
559
565
 
560
566
  ### Height rules
561
567
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@khanglvm/relay",
3
- "version": "0.16.0",
3
+ "version": "0.16.1",
4
4
  "description": "Question boards with rich blocks (markdown, charts, mermaid, tables, code, diffs, video, sandboxed HTML), clickable local file-links, and element-level annotations for AI coding agents (Claude Code, Codex, …): ask users structured questions, present interactive visuals, collect inline comments, read answers as JSON — in a local browser board OR rendered INLINE inside the Claude & Codex apps as an MCP App (SEP-1865).",
5
5
  "keywords": [
6
6
  "ai-agents",
@@ -85,7 +85,7 @@ priority call in a `rank` question — not in paragraphs. Unsure which exists? R
85
85
  | `code` | source / config / command output — highlighted, line numbers, hover-a-line to comment |
86
86
  | `diff` | code changes — colored unified/split, multi-file (`rly diff` builds the whole board) |
87
87
  | `git-conflict` | conflict-marker files — side-by-side ours/theirs/base with hunk choices; returns resolved content in `result.blockEdits[blockId]` |
88
- | `image` | screenshots / mockups / renders — zoom+pan; hold+drag → area crop comment; `pins:true` → point comments |
88
+ | `image` | screenshots / mockups / renders — drag → area crop; Space-drag/middle-button → pan; `pins:true` → point comments |
89
89
  | `compare` | a before/after pair — draggable divider; area comments retain the selected side |
90
90
  | `video` | a demo / screen recording / walkthrough |
91
91
  | `pdf` | a quote / report / exported document that should render inline |
@@ -441,12 +441,16 @@ drop a comment anchored to that exact spot (Figma-style), returned as an
441
441
 
442
442
  ### Comment on an exact image area (local crop)
443
443
 
444
- Every interactive `image` and `compare` block supports hold-then-drag area
445
- feedback without another spec flag. Relay returns an `image-region` target with
446
- normalized `x`, `y`, `w`, and `h`; browser boards also save the selected pixels
447
- beside the board and return `target.crop.path`, which the agent should open with
448
- its image viewer. Comparison targets include `side:"before"|"after"`, and the
449
- crop comes from that source image rather than the composited slider view.
444
+ Every interactive `image` and `compare` block accepts direct primary drag for the
445
+ desired rectangle; **Area** remains as a discoverable one-shot lock. Image pan is
446
+ Space-drag or middle-button drag, and a comparison divider moves only from its
447
+ handle. The provisional zone remains while the user writes, and a saved zone
448
+ exposes a comment icon/count plus add/edit/delete threads; deleting its last
449
+ comment removes the zone. Relay returns an `image-region` target with normalized
450
+ `x`, `y`, `w`, and `h`; browser boards also save the selected pixels beside the
451
+ board and return `target.crop.path`, which the agent should open with its image
452
+ viewer. Comparison targets include `side:"before"|"after"`, and the crop comes
453
+ from that source image rather than the composited slider view.
450
454
 
451
455
  ### Show a git diff in one step — `rly diff`
452
456
 
package/src/cli.js CHANGED
@@ -1125,7 +1125,7 @@ the user should view — put it in relay instead of printing it.**
1125
1125
  - **There's a purpose-built component for most content — use the MOST SPECIFIC one,
1126
1126
  never plain prose when a block fits.** Blocks: \`table\` (sortable/filterable/CSV,
1127
1127
  load from .csv/.json), \`chart\`, \`kpi\` (stat cards), \`mermaid\`/\`graphviz\`/\`plantuml\`,
1128
- \`code\`, \`diff\`, \`git-conflict\`, \`image\` (hold+drag area crops; +\`pins\` for points), \`compare\` (before/after with side-aware area crops), \`video\`, \`pdf\`, \`palette\`,
1128
+ \`code\`, \`diff\`, \`git-conflict\`, \`image\` (drag for crop comments; Space-drag/middle-button to pan; +\`pins\` for points), \`compare\` (before/after with side-aware area crops; divider-handle drag), \`video\`, \`pdf\`, \`palette\`,
1129
1129
  \`typography\`, \`html\`. Question types: \`single\`/\`multi\`/\`yesno\`/\`scale\`/\`color\`/
1130
1130
  \`text\`/\`textarea\` plus \`rank\` (prioritize), \`allocate\` (split a budget), \`checklist\`
1131
1131
  (per-item sign-off). For a business user, reach for \`kpi\`+\`chart\`+\`table\` and
@@ -115,6 +115,7 @@
115
115
  let pinEntry = null;
116
116
  let popOpen = false;
117
117
  let popSave = null;
118
+ let popCloseHook = null;
118
119
  let popAnchorEl = null;
119
120
  let popPositionFrame = 0;
120
121
  const commentDrafts = new Map();
@@ -523,15 +524,20 @@
523
524
  popPositionFrame = requestAnimationFrame(positionPopover);
524
525
  }
525
526
 
526
- function closePopover() {
527
+ function closePopover(reason = 'dismissed') {
527
528
  if (!popOpen) return;
529
+ const closeHook = popCloseHook;
528
530
  popOpen = false;
529
531
  popSave = null;
532
+ popCloseHook = null;
530
533
  popAnchorEl = null;
531
534
  if (popPositionFrame) cancelAnimationFrame(popPositionFrame);
532
535
  popPositionFrame = 0;
533
536
  dom.pop.style.display = 'none';
534
537
  dom.pop.replaceChildren();
538
+ if (closeHook) {
539
+ try { closeHook(reason); } catch (_) { /* lifecycle hooks are best-effort */ }
540
+ }
535
541
  }
536
542
 
537
543
  // ---------- delete a comment (with confirm) ----------
@@ -589,11 +595,12 @@
589
595
  // add-comment dialog.
590
596
  if (!permissions.add && !hasExisting) return;
591
597
  closePopover();
598
+ popCloseHook = typeof info.onClose === 'function' ? info.onClose : null;
592
599
  hidePin();
593
600
  const pop = dom.pop;
594
601
  // Header: target label + an explicit CLOSE button, so × always means "close".
595
602
  const popClose = el('button', { class: 'ann-pop-close', type: 'button', title: 'Close', 'aria-label': 'Close' }, '×');
596
- popClose.addEventListener('click', closePopover);
603
+ popClose.addEventListener('click', () => closePopover('dismissed'));
597
604
  pop.replaceChildren(el('div', { class: 'ann-pop-head' }, el('div', { class: 'ann-pop-label' }, humanize(info.target)), popClose));
598
605
 
599
606
  // Existing comments on this exact target, rendered as threads: author
@@ -679,12 +686,13 @@
679
686
  const text = ta.value.trim();
680
687
  if (text) addAnnotation(info, text);
681
688
  commentDrafts.delete(draftKey);
682
- closePopover();
689
+ if (text) closePopover('saved');
690
+ else closePopover('empty');
683
691
  };
684
692
  save.addEventListener('click', () => popSave && popSave());
685
693
  cancel.addEventListener('click', () => {
686
694
  commentDrafts.delete(draftKey);
687
- closePopover();
695
+ closePopover('cancelled');
688
696
  });
689
697
  if (permissions.add) pop.append(ta, el('div', { class: 'ann-pop-actions' }, save, cancel));
690
698
 
package/src/ui/blocks.css CHANGED
@@ -651,6 +651,8 @@
651
651
  width: 100%;
652
652
  max-width: none;
653
653
  height: auto;
654
+ -webkit-user-drag: none;
655
+ user-select: none;
654
656
  border-radius: 6px;
655
657
  object-fit: contain;
656
658
  object-position: left top;
@@ -757,6 +759,9 @@ body.blk-full-open { overflow: hidden; }
757
759
  dragging. During an active drag the inner svg/img/canvas ignore the pointer
758
760
  so hover pins/cursors don't flicker and the whole surface shows "grabbing". */
759
761
  .blk-pannable { cursor: grab; }
762
+ .blk-has-regions.blk-pannable { cursor: crosshair; }
763
+ body.blk-space-pan .blk-has-regions.blk-pannable,
764
+ body.blk-space-pan .blk-has-regions.blk-pannable * { cursor: grab !important; }
760
765
  .blk-panning { cursor: grabbing; user-select: none; }
761
766
  .blk-panning svg,
762
767
  .blk-panning img,
@@ -789,6 +794,19 @@ body.blk-full-open { overflow: hidden; }
789
794
  background: var(--accent); border-color: var(--accent); color: var(--accent-fg);
790
795
  }
791
796
  .blk-tools .tool-comment.has-comment:hover { background: var(--accent-hover); border-color: var(--accent-hover); color: var(--accent-fg); }
797
+ .blk-tools .tool-region {
798
+ display: inline-flex; align-items: center; gap: 5px;
799
+ }
800
+ .blk-tools .tool-region svg { display: block; }
801
+ .blk-tools .tool-region.is-active {
802
+ background: var(--accent); border-color: var(--accent); color: var(--accent-fg);
803
+ }
804
+ .blk-tools .tool-region.is-active:hover {
805
+ background: var(--accent-hover); border-color: var(--accent-hover); color: var(--accent-fg);
806
+ }
807
+ @media (max-width: 520px) {
808
+ .blk-tools .tool-region-label { display: none; }
809
+ }
792
810
 
793
811
  /* code blocks reuse the viewer toolbar (comment + full-screen); the pre keeps
794
812
  its own card, the wrapper only hosts the toolbar strip */
@@ -847,7 +865,7 @@ body.blk-full-open { overflow: hidden; }
847
865
  .cmp-frame {
848
866
  position: relative; overflow: hidden; user-select: none; touch-action: none;
849
867
  border: 1px solid var(--border); border-radius: 12px; line-height: 0;
850
- cursor: ew-resize;
868
+ cursor: crosshair;
851
869
  }
852
870
  .cmp-img { display: block; width: 100%; height: auto; }
853
871
  .cmp-before { position: absolute; inset: 0; width: 50%; overflow: hidden; }
@@ -855,6 +873,7 @@ body.blk-full-open { overflow: hidden; }
855
873
  .cmp-handle {
856
874
  position: absolute; top: 0; bottom: 0; left: 50%; width: 2px;
857
875
  background: #fff; box-shadow: 0 0 0 1px rgba(0,0,0,0.25); transform: translateX(-1px);
876
+ cursor: ew-resize;
858
877
  }
859
878
  .cmp-handle-grip {
860
879
  position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
@@ -941,17 +960,29 @@ body.blk-full-open { overflow: hidden; }
941
960
  padding: 0;
942
961
  min-width: 10px;
943
962
  min-height: 10px;
963
+ appearance: none;
944
964
  }
945
- .blk-imgregion::after {
946
- content: attr(data-count);
965
+ .blk-imgregion-badge {
947
966
  position: absolute; top: -11px; right: -11px;
948
- min-width: 20px; height: 20px; padding: 0 5px;
949
- display: grid; place-items: center;
967
+ min-width: 24px; height: 22px; padding: 0 6px;
968
+ display: inline-flex; align-items: center; justify-content: center; gap: 3px;
950
969
  border: 2px solid #fff; border-radius: 999px;
951
970
  background: var(--accent); color: #fff;
952
971
  font: 600 0.68rem/1 var(--sans);
953
972
  box-shadow: 0 1px 4px rgba(0,0,0,0.3);
973
+ pointer-events: none;
954
974
  }
975
+ .blk-imgregion-badge svg { width: 10px; height: 10px; }
976
+ .blk-imgregion-count { min-width: 0.7em; text-align: center; }
977
+ .blk-imgregion-provisional {
978
+ z-index: 8;
979
+ border-style: dashed;
980
+ background: color-mix(in srgb, var(--card) 34%, var(--accent) 20%);
981
+ }
982
+ .blk-imgregion-provisional .blk-imgregion-badge { opacity: 0.9; }
983
+ .blk-region-mode,
984
+ .blk-region-mode * { cursor: crosshair !important; }
985
+ .blk-region-mode { touch-action: none; }
955
986
  .blk-region-arming { cursor: crosshair !important; }
956
987
  .blk-region-selecting { cursor: crosshair !important; touch-action: none; }
957
988
  .blk-region-status {
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,8 +1919,10 @@
1901
1919
  src,
1902
1920
  alt: block.alt || 'image',
1903
1921
  loading: 'lazy',
1904
- title: ctx.canComment === false ? '' : 'Hold briefly, then drag to comment on an area',
1922
+ draggable: 'false',
1923
+ title: ctx.canComment === false ? '' : 'Drag to draw a comment area · Space-drag to pan',
1905
1924
  });
1925
+ img.addEventListener('dragstart', (e) => e.preventDefault());
1906
1926
  const stage = el('div', { class: 'blk-imgstage' }, img);
1907
1927
  // Inline default: the image fills its full width (container width, never
1908
1928
  // upscaled past natural) so it's readable without manual zoom; the CONTAINER
@@ -1914,27 +1934,28 @@
1914
1934
  container.replaceChildren(el('div', { class: 'blk-error' }, 'Image failed to load'));
1915
1935
  });
1916
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;
1917
1949
  const attachImgViewer = () =>
1918
1950
  attachViewer(container, {
1919
1951
  zoomEl: stage,
1920
1952
  natural: () => (img.naturalWidth > 0 ? { w: img.naturalWidth, h: img.naturalHeight } : null),
1921
1953
  label: 'image',
1922
1954
  comment: wholeBlockComment(ctx, blockId, 'image'),
1955
+ region,
1923
1956
  });
1924
1957
  if (img.complete && img.naturalWidth > 0) attachImgViewer();
1925
1958
  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
- }
1938
1959
  if (ctx.annotate && block.pins === true) {
1939
1960
  enableImagePins(stage, img, ctx, blockId, block.alt || 'Image');
1940
1961
  } else if (ctx.annotate) {
@@ -2009,11 +2030,14 @@
2009
2030
  const selection = el('div', { class: 'blk-region-selection' });
2010
2031
  layer.append(selection);
2011
2032
  host.append(layer);
2033
+ panHost._rlyHasRegions = true;
2034
+ panHost.classList.add('blk-has-regions');
2012
2035
  let pending = null;
2013
2036
  let active = false;
2014
- let holdTimer = 0;
2015
2037
  let status = null;
2016
- const HOLD_MS = 280;
2038
+ let modeActive = false;
2039
+ const modeHooks = new Set();
2040
+ let controller = null;
2017
2041
  const MIN_REGION = 0.012;
2018
2042
 
2019
2043
  const localPoint = (e) => {
@@ -2030,8 +2054,6 @@
2030
2054
  host.append(status);
2031
2055
  };
2032
2056
  const reset = () => {
2033
- clearTimeout(holdTimer);
2034
- holdTimer = 0;
2035
2057
  active = false;
2036
2058
  pending = null;
2037
2059
  panHost._rlyRegionSelecting = false;
@@ -2039,6 +2061,30 @@
2039
2061
  selection.style.display = 'none';
2040
2062
  clearStatus();
2041
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
+ });
2042
2088
  const draw = (a, b) => {
2043
2089
  const x = Math.min(a.x, b.x), y = Math.min(a.y, b.y);
2044
2090
  const w = Math.abs(a.x - b.x), h = Math.abs(a.y - b.y);
@@ -2048,6 +2094,34 @@
2048
2094
  selection.style.height = (h * 100) + '%';
2049
2095
  return { x, y, w, h };
2050
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
+ };
2051
2125
  const beginSelection = (pointerId) => {
2052
2126
  if (!pending || active) return;
2053
2127
  active = true;
@@ -2091,6 +2165,8 @@
2091
2165
  side: side || undefined,
2092
2166
  label,
2093
2167
  };
2168
+ const provisional = createRegionZone(target, 0, true);
2169
+ layer.append(provisional);
2094
2170
  showStatus('Saving selected area…');
2095
2171
  try {
2096
2172
  target.crop = await cropRegion(sourceForSide(side), region);
@@ -2098,12 +2174,21 @@
2098
2174
  target.cropUnavailable = true;
2099
2175
  }
2100
2176
  clearStatus();
2101
- ctx.annotate.openExternal({ blockId, questionId: ctx.questionId, target }, host);
2177
+ ctx.annotate.openExternal({
2178
+ blockId,
2179
+ questionId: ctx.questionId,
2180
+ target,
2181
+ onClose: () => provisional.remove(),
2182
+ }, provisional);
2102
2183
  };
2103
2184
 
2104
2185
  surface.addEventListener('pointerdown', (e) => {
2105
- if (e.button !== 0 || ctx.canComment === false) return;
2186
+ if (e.button !== 0 || viewerSpacePan || ctx.canComment === false) return;
2106
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
+ }
2107
2192
  const p = localPoint(e);
2108
2193
  pending = {
2109
2194
  start: p,
@@ -2111,38 +2196,41 @@
2111
2196
  clientX: e.clientX,
2112
2197
  clientY: e.clientY,
2113
2198
  pointerId: e.pointerId,
2114
- startedAt: performance.now(),
2199
+ explicitMode: modeActive,
2115
2200
  side: sideAtPoint(p.x),
2116
2201
  };
2117
2202
  host.classList.add('blk-region-arming');
2118
- holdTimer = setTimeout(() => beginSelection(e.pointerId), HOLD_MS);
2203
+ if (modeActive) beginSelection(e.pointerId);
2119
2204
  });
2120
2205
  surface.addEventListener('pointermove', (e) => {
2121
2206
  if (!pending) return;
2122
2207
  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);
2208
+ if (!active && moved > 5) beginSelection(pending.pointerId);
2127
2209
  if (!active) {
2128
- if (moved > 5) reset();
2129
2210
  return;
2130
2211
  }
2131
2212
  e.preventDefault();
2213
+ e.stopPropagation();
2132
2214
  pending.last = localPoint(e);
2133
2215
  draw(pending.start, pending.last);
2134
2216
  });
2135
2217
  const end = (e) => {
2136
2218
  if (!pending) return;
2137
- clearTimeout(holdTimer);
2138
2219
  if (!active) { reset(); return; }
2139
2220
  e.preventDefault();
2140
2221
  const region = draw(pending.start, pending.last || localPoint(e));
2141
2222
  const side = pending.side;
2223
+ const explicitMode = pending.explicitMode;
2142
2224
  surface._rlySuppressPointClick = true;
2143
2225
  reset();
2144
2226
  clearStatus();
2145
- if (region.w >= MIN_REGION && region.h >= MIN_REGION) finishRegion(region, side);
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
+ }
2146
2234
  };
2147
2235
  surface.addEventListener('pointerup', end);
2148
2236
  // Some Chromium/CDP paths emit pointercancel immediately after granting
@@ -2151,7 +2239,7 @@
2151
2239
  surface.addEventListener('pointercancel', (e) => active ? end(e) : reset());
2152
2240
 
2153
2241
  const syncRegions = () => {
2154
- for (const node of Array.from(layer.querySelectorAll('.blk-imgregion'))) node.remove();
2242
+ for (const node of Array.from(layer.querySelectorAll('.blk-imgregion:not(.blk-imgregion-provisional)'))) node.remove();
2155
2243
  const groups = new Map();
2156
2244
  for (const a of ctx.annotate.list()) {
2157
2245
  if (a.blockId !== blockId || !a.target || a.target.kind !== 'image-region') continue;
@@ -2162,27 +2250,12 @@
2162
2250
  groups.set(key, group);
2163
2251
  }
2164
2252
  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);
2253
+ layer.append(createRegionZone(target, count));
2182
2254
  }
2183
2255
  };
2184
2256
  if (ctx.annotate.onBadgeRefresh) ctx.annotate.onBadgeRefresh(syncRegions);
2185
2257
  syncRegions();
2258
+ return controller;
2186
2259
  }
2187
2260
 
2188
2261
  // ---------- palette ----------
@@ -2333,19 +2406,20 @@
2333
2406
  if (r.width) { pos = Math.max(0, Math.min(100, ((clientX - r.left) / r.width) * 100)); apply(); }
2334
2407
  };
2335
2408
  frame.addEventListener('pointerdown', (e) => {
2336
- if (e.target.closest && e.target.closest('.blk-imgregion')) return;
2409
+ const handleTarget = e.target.closest('.cmp-handle');
2410
+ if (!handleTarget || frame._rlyRegionMode) return;
2337
2411
  dragging = true;
2338
2412
  dragMoved = false;
2339
2413
  dragStartX = e.clientX;
2340
2414
  try { frame.setPointerCapture(e.pointerId); } catch (_) {}
2341
2415
  });
2342
2416
  frame.addEventListener('pointermove', (e) => {
2343
- if (!dragging || frame._rlyRegionSelecting) return;
2417
+ if (!dragging || frame._rlyRegionMode || frame._rlyRegionSelecting) return;
2344
2418
  if (Math.abs(e.clientX - dragStartX) > 3) dragMoved = true;
2345
2419
  if (dragMoved) { setFromX(e.clientX); e.preventDefault(); }
2346
2420
  });
2347
2421
  frame.addEventListener('pointerup', (e) => {
2348
- if (dragging && !dragMoved && !frame._rlyRegionSelecting) setFromX(e.clientX);
2422
+ if (dragging && !dragMoved && !frame._rlyRegionMode && !frame._rlyRegionSelecting) setFromX(e.clientX);
2349
2423
  dragging = false;
2350
2424
  });
2351
2425
  frame.addEventListener('pointercancel', () => { dragging = false; dragMoved = false; });
@@ -2353,9 +2427,10 @@
2353
2427
  if (e.key === 'ArrowLeft') { pos = Math.max(0, pos - 2); apply(); e.preventDefault(); }
2354
2428
  else if (e.key === 'ArrowRight') { pos = Math.min(100, pos + 2); apply(); e.preventDefault(); }
2355
2429
  });
2430
+ let region = null;
2356
2431
  if (ctx.annotate && ctx.canComment !== false) {
2357
- frame.title = 'Hold briefly, then drag to comment on the visible Before or After image';
2358
- enableImageRegions({
2432
+ frame.title = 'Drag to draw a comment area · drag the divider handle to compare';
2433
+ region = enableImageRegions({
2359
2434
  host: frame,
2360
2435
  surface: frame,
2361
2436
  panHost: frame,
@@ -2366,7 +2441,12 @@
2366
2441
  label: 'Comparison',
2367
2442
  });
2368
2443
  }
2369
- attachViewer(wrap, { zoomEl: null, label: 'comparison', comment: wholeBlockComment(ctx, blockId, 'comparison') });
2444
+ attachViewer(wrap, {
2445
+ zoomEl: null,
2446
+ label: 'comparison',
2447
+ comment: wholeBlockComment(ctx, blockId, 'comparison'),
2448
+ region,
2449
+ });
2370
2450
  return wrap;
2371
2451
  }
2372
2452
 
@@ -2399,6 +2479,10 @@
2399
2479
  const ICON_COMMENT =
2400
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">' +
2401
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>';
2402
2486
 
2403
2487
  function exitFull() {
2404
2488
  if (!fullOpen) return;
@@ -2520,7 +2604,8 @@
2520
2604
  const refresh = () => scrollEl.classList.toggle('blk-pannable', pannable());
2521
2605
 
2522
2606
  scrollEl.addEventListener('pointerdown', (e) => {
2523
- 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;
2524
2609
  // leave the toolbar, the diagram editor, and real controls interactive
2525
2610
  if (e.target.closest && e.target.closest('.blk-tools, .blk-editor, button, a, input, textarea, select')) return;
2526
2611
  pending = true; active = false;
@@ -2530,7 +2615,7 @@
2530
2615
  });
2531
2616
  scrollEl.addEventListener('pointermove', (e) => {
2532
2617
  if (!pending) return;
2533
- if (scrollEl._rlyRegionSelecting) { pending = false; active = false; return; }
2618
+ if (scrollEl._rlyRegionMode || scrollEl._rlyRegionSelecting) { pending = false; active = false; return; }
2534
2619
  const dx = e.clientX - sx, dy = e.clientY - sy;
2535
2620
  if (!active) {
2536
2621
  if (Math.abs(dx) < THRESH && Math.abs(dy) < THRESH) return;
@@ -2581,6 +2666,10 @@
2581
2666
  // comment (optional) = { open(anchor), active(), subscribe(fn) } wiring the
2582
2667
  // "comment on the whole block" button + its already-commented style.
2583
2668
  function attachViewer(container, opts) {
2669
+ if (container._rlyRegionHook) {
2670
+ try { container._rlyRegionHook(); } catch (_) {}
2671
+ container._rlyRegionHook = null;
2672
+ }
2584
2673
  if (container._rlyTools) container._rlyTools.remove();
2585
2674
  container.classList.add('blk-viewer');
2586
2675
  const zoomable = Boolean(opts && opts.zoomEl);
@@ -2695,6 +2784,34 @@
2695
2784
  }
2696
2785
  }
2697
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
+
2698
2815
  if (zoomable) {
2699
2816
  // cmd/ctrl+wheel zoom, bound ONCE (re-binding per render would stack);
2700
2817
  // delegates to the current controller so it never uses a stale zoomEl