@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 +10 -1
- package/docs/AGENT.md +13 -7
- package/package.json +1 -1
- package/skills/relay/SKILL.md +11 -7
- package/src/cli.js +1 -1
- package/src/ui/annotate.js +12 -4
- package/src/ui/blocks.css +36 -5
- package/src/ui/blocks.js +171 -54
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;
|
|
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;
|
|
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
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
the
|
|
557
|
-
|
|
558
|
-
|
|
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.
|
|
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",
|
package/skills/relay/SKILL.md
CHANGED
|
@@ -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 —
|
|
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
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
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\` (
|
|
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
|
package/src/ui/annotate.js
CHANGED
|
@@ -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:
|
|
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
|
|
946
|
-
content: attr(data-count);
|
|
965
|
+
.blk-imgregion-badge {
|
|
947
966
|
position: absolute; top: -11px; right: -11px;
|
|
948
|
-
min-width:
|
|
949
|
-
display:
|
|
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
|
-
|
|
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
|
-
|
|
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({
|
|
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
|
-
|
|
2199
|
+
explicitMode: modeActive,
|
|
2115
2200
|
side: sideAtPoint(p.x),
|
|
2116
2201
|
};
|
|
2117
2202
|
host.classList.add('blk-region-arming');
|
|
2118
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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 = '
|
|
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, {
|
|
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
|
-
|
|
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
|