@khanglvm/relay 0.8.2 → 0.9.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@khanglvm/relay",
3
- "version": "0.8.2",
3
+ "version": "0.9.1",
4
4
  "description": "Browser-based question boards with rich blocks (markdown, charts, mermaid, tables, code, sandboxed HTML) and element-level annotations for AI coding agents (Claude Code, Codex, …): ask users structured questions, present interactive visuals, collect inline comments, wait for submit, read answers as JSON.",
5
5
  "keywords": [
6
6
  "ai-agents",
@@ -30,8 +30,12 @@
30
30
  user-select: none;
31
31
  }
32
32
  .ann-badge:hover { background: var(--accent-hover); }
33
- /* SVG targets (mermaid nodes) get the badge overlaid in document coords */
34
- .ann-badge-overlay { top: auto; right: auto; z-index: 58; }
33
+ /* SVG / image targets get the badge overlaid (positioned as a child of the
34
+ scrollable diagram viewport so it rides with the content). z 15: above the
35
+ diagram it marks, but BELOW the sticky submit bar (z 30) so a badge near the
36
+ page bottom never covers the Submit button. Pins/popover/rail stay high —
37
+ they're transient and must clear the full-screen overlay. */
38
+ .ann-badge-overlay { top: auto; right: auto; z-index: 15; }
35
39
 
36
40
  /* ---------- comment popover ---------- */
37
41
  .ann-pop {
@@ -42,6 +42,8 @@
42
42
  return 'Diagram · ' + truncate(t.text || t.nodeId || 'node', 50);
43
43
  case 'graphviz-node':
44
44
  return 'Graph · ' + truncate(t.text || t.nodeId || 'node', 50);
45
+ case 'plantuml-node':
46
+ return 'Diagram · ' + truncate(t.text || 'element', 50);
45
47
  case 'image':
46
48
  return truncate(t.label || 'Image', 50);
47
49
  case 'table-cell': {
@@ -53,6 +55,8 @@
53
55
  return `“${truncate(t.quote || '', 60)}”`;
54
56
  case 'html-element':
55
57
  return truncate(t.label || 'Element', 50) + (t.detail ? ` — ${truncate(t.detail, 40)}` : '');
58
+ case 'block':
59
+ return 'Whole ' + truncate(t.label || 'block', 40);
56
60
  default:
57
61
  return t.kind || 'Element';
58
62
  }
@@ -405,10 +409,19 @@
405
409
  }, 0);
406
410
  }
407
411
 
412
+ // Hosts (e.g. blocks.js chart badge / comment button) subscribe here to be
413
+ // notified on every badge refresh — i.e. whenever annotations change — so
414
+ // their own indicators stay in sync. Returns an unsubscribe fn.
415
+ const badgeHooks = new Set();
416
+ function onBadgeRefresh(fn) {
417
+ if (typeof fn === 'function') badgeHooks.add(fn);
418
+ return () => badgeHooks.delete(fn);
419
+ }
420
+
408
421
  function refreshBadges() {
409
422
  for (const b of badges) b.remove();
410
423
  badges = [];
411
- if (torndown) return;
424
+ if (torndown) { for (const fn of badgeHooks) { try { fn(); } catch (_) {} } return; }
412
425
  registered = registered.filter((r) => r.el.isConnected);
413
426
  // While a block is expanded full-screen, body-level overlay badges for
414
427
  // OTHER blocks would float above the overlay at stale positions — only
@@ -428,20 +441,36 @@
428
441
  hidePin();
429
442
  openPopover(entry.info, entry.el);
430
443
  });
431
- if (entry.el instanceof HTMLElement) {
444
+ if (entry.el instanceof HTMLElement && !(entry.el instanceof HTMLImageElement)) {
445
+ // a normal HTML element hosts the badge as a child — it tracks naturally
432
446
  if (getComputedStyle(entry.el).position === 'static') entry.el.classList.add('ann-rel');
433
447
  entry.el.append(badge);
434
448
  } else {
435
- // SVG targets (mermaid nodes) can't host an HTML child overlay it
436
- // in document coordinates so it scrolls with the content.
449
+ // SVG node or <img> — neither can host an HTML child. Anchor the badge
450
+ // INSIDE the scrollable diagram viewport in CONTENT coordinates: as an
451
+ // absolutely-positioned child of the scroll box it rides with the content
452
+ // on drag-pan / container-scroll / page-scroll, with no listener needed.
453
+ // (A document-body overlay stayed put when the diagram itself scrolled.)
437
454
  badge.classList.add('ann-badge-overlay');
438
- document.body.append(badge);
455
+ const host = entry.el.closest ? entry.el.closest('.blk-viewer') : null;
439
456
  const r = entry.el.getBoundingClientRect();
440
- badge.style.left = (r.right + window.scrollX - 10) + 'px';
441
- badge.style.top = (r.top + window.scrollY - 6) + 'px';
457
+ if (host) {
458
+ const cr = host.getBoundingClientRect();
459
+ const cs = getComputedStyle(host);
460
+ const bL = parseFloat(cs.borderLeftWidth) || 0;
461
+ const bT = parseFloat(cs.borderTopWidth) || 0;
462
+ badge.style.left = (r.right - cr.left - bL + host.scrollLeft - 10) + 'px';
463
+ badge.style.top = (r.top - cr.top - bT + host.scrollTop - 6) + 'px';
464
+ host.append(badge);
465
+ } else {
466
+ badge.style.left = (r.right + window.scrollX - 10) + 'px';
467
+ badge.style.top = (r.top + window.scrollY - 6) + 'px';
468
+ document.body.append(badge);
469
+ }
442
470
  }
443
471
  badges.push(badge);
444
472
  }
473
+ for (const fn of badgeHooks) { try { fn(); } catch (_) {} }
445
474
  }
446
475
 
447
476
  // ---------- popover ----------
@@ -705,6 +734,11 @@
705
734
  const marks = highlightMap.get(sigOf(a.blockId, a.target));
706
735
  jump = marks && marks.find((m) => m.isConnected);
707
736
  }
737
+ // Fallback for targets with no registered element (e.g. chart data
738
+ // points / whole-block comments): flash the whole block container.
739
+ if (!jump && a.blockId != null) {
740
+ jump = document.querySelector('[data-block-id="' + String(a.blockId).replace(/"/g, '\\"') + '"]');
741
+ }
708
742
  if (!jump) return;
709
743
  // On narrow screens the rail overlays the page — close it so the
710
744
  // flashed target is actually visible.
@@ -816,5 +850,5 @@
816
850
  renderSummaryInto(target);
817
851
  }
818
852
 
819
- window.RelayAnnotate = { init, register, enableTextSelection, openExternal, list, renderSummary, teardown };
853
+ window.RelayAnnotate = { init, register, enableTextSelection, openExternal, list, renderSummary, onBadgeRefresh, teardown };
820
854
  })();
package/src/ui/app.js CHANGED
@@ -543,7 +543,13 @@
543
543
  Annotate?.register(titleEl, { blockId: null, questionId: null, target: { kind: 'html-element', label: spec.title } });
544
544
  }
545
545
  if (spec.intro) {
546
- const intro = el('p', { class: 'intro' }, spec.intro);
546
+ // Render the intro as markdown (bold/italic/code/links/lists) agents write
547
+ // markdown here by default. Falls back to plain text if blocks.js is absent.
548
+ // The .blk-markdown class scopes the shared markdown typography to it.
549
+ const md = typeof window.RelayBlocks !== 'undefined' && window.RelayBlocks.renderMarkdown;
550
+ const intro = md
551
+ ? el('div', { class: 'intro blk-markdown' }, window.RelayBlocks.renderMarkdown(spec.intro))
552
+ : el('p', { class: 'intro' }, spec.intro);
547
553
  app.append(intro);
548
554
  Annotate?.enableTextSelection(intro, { blockId: null, questionId: null });
549
555
  }
package/src/ui/blocks.css CHANGED
@@ -160,6 +160,15 @@
160
160
  font-weight: 600;
161
161
  font-family: var(--sans);
162
162
  box-shadow: var(--shadow-card);
163
+ cursor: pointer;
164
+ z-index: 4; /* above the canvas, below the toolbar(5)/footer(30) */
165
+ }
166
+ .blk-chart-badge:hover { background: var(--accent-hover); }
167
+ /* per-data-point badge: positioned via inline left/top, centered on the point */
168
+ .blk-chart-badge-pt {
169
+ top: auto; right: auto;
170
+ transform: translate(-50%, -50%);
171
+ border: 1.5px solid var(--card);
163
172
  }
164
173
 
165
174
  /* ---------- mermaid ---------- */
@@ -336,3 +345,57 @@ body.blk-full-open { overflow: hidden; }
336
345
  height: calc(100vh - 44px) !important; width: 100%;
337
346
  margin: 0; border: 0; border-radius: 0;
338
347
  }
348
+
349
+ /* Small frame on charts + mermaid so they read as a deliberate card, matching
350
+ the graphviz/plantuml/image blocks (already framed). Scoped to the inner
351
+ .blk-viewer element so the border isn't doubled on the outer .blk dispatch
352
+ wrapper; :not(.blk-full) drops it in the edge-to-edge full-screen overlay. */
353
+ .blk-chart.blk-viewer:not(.blk-full),
354
+ .blk-mermaid.blk-viewer:not(.blk-full) {
355
+ border: 1px solid var(--border);
356
+ border-radius: 10px;
357
+ }
358
+ /* keep the canvas corners inside the rounded frame (mermaid already clips via
359
+ its overflow:auto) */
360
+ .blk-chart.blk-viewer:not(.blk-full) { overflow: hidden; }
361
+
362
+ /* drag-to-pan affordance: grab when a visual overflows its box, grabbing while
363
+ dragging. During an active drag the inner svg/img/canvas ignore the pointer
364
+ so hover pins/cursors don't flicker and the whole surface shows "grabbing". */
365
+ .blk-pannable { cursor: grab; }
366
+ .blk-panning { cursor: grabbing; user-select: none; }
367
+ .blk-panning svg,
368
+ .blk-panning img,
369
+ .blk-panning canvas { pointer-events: none; }
370
+
371
+ /* The diagram type-class is shared by the dispatch wrapper (.blk.blk-graphviz)
372
+ and the inner card, so a bordered rule on the bare class paints TWO concentric
373
+ borders (the wrapper's padding:10px makes the gap). Strip the card off the
374
+ dispatch wrapper so only the inner element is framed — single clean border. */
375
+ .blk.blk-graphviz,
376
+ .blk.blk-plantuml {
377
+ border: 0;
378
+ background: none;
379
+ padding: 0;
380
+ overflow: visible;
381
+ max-height: none;
382
+ }
383
+
384
+ /* zoom −/+ buttons and the % (which doubles as reset-to-fit) */
385
+ .blk-tools .tool-zoom {
386
+ font-size: 0.95rem; font-weight: 600; line-height: 1;
387
+ min-width: 24px; text-align: center;
388
+ }
389
+ .blk-tools .tool-pct.is-btn { cursor: pointer; transition: color 150ms var(--ease); }
390
+ .blk-tools .tool-pct.is-btn:hover { color: var(--accent); }
391
+ .blk-tools .tool-comment svg { display: block; }
392
+ /* the comment button switches to a filled accent state once the block carries
393
+ a whole-block comment, so it's obvious at a glance */
394
+ .blk-tools .tool-comment.has-comment {
395
+ background: var(--accent); border-color: var(--accent); color: var(--accent-fg);
396
+ }
397
+ .blk-tools .tool-comment.has-comment:hover { background: var(--accent-hover); border-color: var(--accent-hover); color: var(--accent-fg); }
398
+
399
+ /* code blocks reuse the viewer toolbar (comment + full-screen); the pre keeps
400
+ its own card, the wrapper only hosts the toolbar strip */
401
+ .blk-codewrap { position: relative; }
package/src/ui/blocks.js CHANGED
@@ -298,10 +298,16 @@
298
298
  return out;
299
299
  }
300
300
 
301
- function renderCode(block) {
301
+ function renderCode(block, ctx, blockId) {
302
302
  const code = el('code');
303
303
  code.innerHTML = tintCode(block.code || '', block.lang);
304
- return el('pre', { class: 'blk-pre', 'data-lang': block.lang || '' }, code);
304
+ const pre = el('pre', { class: 'blk-pre', 'data-lang': block.lang || '' }, code);
305
+ const wrap = el('div', { class: 'blk-codewrap' }, pre);
306
+ // select-to-comment on the code text (like markdown), plus a whole-block
307
+ // comment + full-screen via the shared viewer toolbar
308
+ ctx && ctx.annotate && ctx.annotate.enableTextSelection(pre, { blockId, questionId: ctx.questionId });
309
+ attachViewer(wrap, { zoomEl: null, label: 'code', comment: wholeBlockComment(ctx, blockId, 'code') });
310
+ return wrap;
305
311
  }
306
312
 
307
313
  // ---------- table ----------
@@ -537,13 +543,6 @@
537
543
  return config;
538
544
  }
539
545
 
540
- function chartAnnotationCount(ctx, blockId) {
541
- if (!ctx.annotate) return 0;
542
- return ctx.annotate.list().filter(
543
- (a) => a.blockId === blockId && a.target && a.target.kind === 'chart-element'
544
- ).length;
545
- }
546
-
547
546
  function renderChart(block, ctx, blockId) {
548
547
  const height = clampHeight(block.height, 320);
549
548
  const wrap = el('div', { class: 'blk-chart' });
@@ -551,37 +550,87 @@
551
550
  const canvas = el('canvas');
552
551
  wrap.append(canvas);
553
552
 
554
- const badge = el('span', { class: 'blk-chart-badge' }, '');
555
- function syncBadge() {
556
- const n = chartAnnotationCount(ctx, blockId);
557
- if (n > 0) { badge.textContent = String(n); badge.style.display = ''; }
558
- else badge.style.display = 'none';
553
+ let chartInst = null;
554
+ let chartBadges = [];
555
+
556
+ const openFor = (target) => (anchor) =>
557
+ ctx.annotate.openExternal({ blockId, questionId: ctx.questionId, target }, anchor);
558
+ function makeBadge(count, cls, onClick) {
559
+ const b = el('span', { class: 'blk-chart-badge ' + cls }, String(count));
560
+ b.addEventListener('mousedown', (e) => e.stopPropagation());
561
+ b.addEventListener('click', (e) => { e.stopPropagation(); e.preventDefault(); onClick(b); });
562
+ return b;
563
+ }
564
+ // Rebuild the chart's comment badges: one at each annotated DATA POINT
565
+ // (positioned from the Chart.js element geometry) plus a top-right badge for
566
+ // whole-chart comments. Each badge opens that comment's popover on click.
567
+ // Runs on annotation change (onBadgeRefresh) and on chart (re)render/resize
568
+ // (afterRender plugin), so badges stay pinned to their points.
569
+ function syncChartBadges() {
570
+ if (!ctx.annotate) return;
571
+ for (const b of chartBadges) b.remove();
572
+ chartBadges = [];
573
+ const groups = new Map(); // "di:index" -> { target, count }
574
+ let blockCount = 0, blockTarget = null;
575
+ for (const a of ctx.annotate.list()) {
576
+ if (a.blockId !== blockId) continue;
577
+ const t = a.target || {};
578
+ if (t.kind === 'chart-element' && typeof t.datasetIndex === 'number' && typeof t.index === 'number') {
579
+ const k = t.datasetIndex + ':' + t.index;
580
+ const g = groups.get(k) || { target: t, count: 0 };
581
+ g.count++; groups.set(k, g);
582
+ } else {
583
+ blockCount++; blockTarget = blockTarget || t;
584
+ }
585
+ }
586
+ if (chartInst) {
587
+ for (const { target, count } of groups.values()) {
588
+ let elem;
589
+ try { const m = chartInst.getDatasetMeta(target.datasetIndex); elem = m && m.data[target.index]; } catch (_) {}
590
+ if (!elem) continue;
591
+ let pos; try { pos = elem.tooltipPosition(); } catch (_) { pos = { x: elem.x, y: elem.y }; }
592
+ if (!pos || !Number.isFinite(pos.x) || !Number.isFinite(pos.y)) continue;
593
+ const badge = makeBadge(count, 'blk-chart-badge-pt', openFor(target));
594
+ badge.style.left = (canvas.offsetLeft + pos.x) + 'px';
595
+ badge.style.top = (canvas.offsetTop + pos.y) + 'px';
596
+ wrap.append(badge);
597
+ chartBadges.push(badge);
598
+ }
599
+ }
600
+ if (blockCount > 0) {
601
+ const badge = makeBadge(blockCount, 'blk-chart-badge-corner', openFor(blockTarget || { kind: 'block', label: 'chart' }));
602
+ wrap.append(badge);
603
+ chartBadges.push(badge);
604
+ }
559
605
  }
560
- syncBadge();
561
- wrap.append(badge);
562
- attachViewer(wrap, { zoomEl: null }); // full-screen only; charts redraw responsively
606
+
607
+ if (ctx.annotate && ctx.annotate.onBadgeRefresh) ctx.annotate.onBadgeRefresh(syncChartBadges);
608
+ // full-screen + whole-chart comment only; charts redraw responsively (no pixel zoom)
609
+ attachViewer(wrap, { zoomEl: null, label: 'chart', comment: wholeBlockComment(ctx, blockId, 'chart') });
563
610
 
564
611
  loadChart().then((Chart) => {
565
612
  let config = block.config
566
613
  ? JSON.parse(JSON.stringify(block.config))
567
614
  : simplifiedToConfig(block, ctx);
568
615
  config = applyChartTheme(config, ctx);
569
- let chart;
616
+ // reposition our DOM badges after every (re)render/resize of the chart
617
+ config.plugins = Array.isArray(config.plugins) ? config.plugins : [];
618
+ config.plugins.push({ id: 'relayChartBadges', afterRender: () => syncChartBadges() });
570
619
  try {
571
- chart = new Chart(canvas.getContext('2d'), config);
572
- chartRegistry.push({ chart });
620
+ chartInst = new Chart(canvas.getContext('2d'), config);
621
+ chartRegistry.push({ chart: chartInst });
573
622
  } catch (err) {
574
623
  wrap.replaceChildren(el('div', { class: 'blk-error' }, 'Chart error: ' + (err && err.message ? err.message : String(err))));
575
624
  return;
576
625
  }
577
626
  // hover -> pointer cursor on a hit
578
627
  canvas.addEventListener('mousemove', (e) => {
579
- const hits = chart.getElementsAtEventForMode(e, 'nearest', { intersect: true }, true);
628
+ const hits = chartInst.getElementsAtEventForMode(e, 'nearest', { intersect: true }, true);
580
629
  canvas.style.cursor = hits.length ? 'pointer' : 'default';
581
630
  });
582
631
  canvas.addEventListener('click', (e) => {
583
632
  if (!ctx.annotate) return;
584
- const hits = chart.getElementsAtEventForMode(e, 'nearest', { intersect: true }, true);
633
+ const hits = chartInst.getElementsAtEventForMode(e, 'nearest', { intersect: true }, true);
585
634
  if (!hits.length) return;
586
635
  const { datasetIndex, index } = hits[0];
587
636
  const ds = config.data.datasets[datasetIndex] || {};
@@ -596,12 +645,11 @@
596
645
  wrap
597
646
  );
598
647
  });
648
+ syncChartBadges();
599
649
  }).catch((err) => {
600
650
  wrap.replaceChildren(el('div', { class: 'blk-error' }, 'Chart error: ' + (err && err.message ? err.message : String(err))));
601
651
  });
602
652
 
603
- // expose a refresh hook so the list can update the badge after changes
604
- wrap._relaySyncBadge = syncBadge;
605
653
  return wrap;
606
654
  }
607
655
 
@@ -628,6 +676,10 @@
628
676
 
629
677
  function renderMermaid(block, ctx, blockId) {
630
678
  const container = el('div', { class: 'blk-mermaid' });
679
+ // Honor an authored height as the scrollable viewport: a diagram taller than
680
+ // this overflows and becomes drag-pannable in place (was silently ignored —
681
+ // diagrams only ever fit-to-width). No height keeps the 1200px CSS cap.
682
+ if (block.height != null) container.style.maxHeight = clampHeight(block.height, 1200) + 'px';
631
683
  const entry = { container, block, ctx, blockId };
632
684
  entry.code = effectiveMermaidCode(entry);
633
685
  mermaidRegistry.push(entry);
@@ -783,7 +835,7 @@
783
835
  });
784
836
  }
785
837
  // re-attach per render: innerHTML replacement above wiped the old bar
786
- if (svgEl) attachViewer(container, { zoomEl: svgEl, natural: svgNatural(svgEl) });
838
+ if (svgEl) attachViewer(container, { zoomEl: svgEl, natural: svgNatural(svgEl), label: 'diagram', comment: wholeBlockComment(ctx, blockId, 'diagram') });
787
839
  if (onDone) onDone();
788
840
  };
789
841
  try {
@@ -863,13 +915,15 @@
863
915
 
864
916
  function renderGraphviz(block, ctx, blockId) {
865
917
  const container = el('div', { class: 'blk-graphviz' });
918
+ // honor authored height as the scrollable viewport (see renderMermaid)
919
+ if (block.height != null) container.style.maxHeight = clampHeight(block.height, 1200) + 'px';
866
920
  loadViz()
867
921
  .then((Viz) => Viz.instance())
868
922
  .then((viz) => {
869
923
  const svgEl = viz.renderSVGElement(block.dot || '');
870
924
  sizeDiagramSvg(svgEl);
871
925
  container.replaceChildren(svgEl);
872
- attachViewer(container, { zoomEl: svgEl, natural: svgNatural(svgEl) });
926
+ attachViewer(container, { zoomEl: svgEl, natural: svgNatural(svgEl), label: 'graph', comment: wholeBlockComment(ctx, blockId, 'graph') });
873
927
  if (!ctx.annotate) return;
874
928
  const parts = svgEl.querySelectorAll('g.node, g.edge');
875
929
  parts.forEach((g) => {
@@ -928,6 +982,59 @@
928
982
  return encode64(new Uint8Array(compressed));
929
983
  }
930
984
 
985
+ // Fetch a remote SVG and return a sanitized inline <svg> element, so its parts
986
+ // become annotatable. Rejects on network/CORS failure (caller falls back to an
987
+ // <img>). The SVG is sanitized — it comes from a remote, author-set server, so
988
+ // strip scripts / foreignObject / on* handlers / javascript: links before it
989
+ // ever touches the document.
990
+ function fetchInlineSvg(url) {
991
+ return fetch(url, { credentials: 'omit', mode: 'cors' })
992
+ .then((r) => {
993
+ if (!r.ok) throw new Error('http ' + r.status);
994
+ // only trust a declared SVG/XML payload; anything else → <img> fallback
995
+ const ct = (r.headers.get('content-type') || '').toLowerCase();
996
+ if (ct && !ct.includes('svg') && !ct.includes('xml')) throw new Error('not svg: ' + ct);
997
+ return r.text();
998
+ })
999
+ .then((text) => {
1000
+ const doc = new DOMParser().parseFromString(text, 'image/svg+xml');
1001
+ if (doc.querySelector('parsererror')) throw new Error('bad svg');
1002
+ const svg = doc.querySelector('svg');
1003
+ if (!svg) throw new Error('no svg');
1004
+ sanitizeSvg(svg);
1005
+ return document.importNode(svg, true);
1006
+ });
1007
+ }
1008
+
1009
+ // The SVG comes from a remote, author-set server, so strip everything active
1010
+ // or capable of fetching an external resource before it touches the document.
1011
+ // Script execution is already impossible here (DOMParser doesn't run scripts;
1012
+ // importNode+appendChild never starts an SVG <script>), but we still drop
1013
+ // <script>/<foreignObject>/<style>, on* handlers, non-fragment href/xlink:href
1014
+ // (<use>/<image>/<a>), and any url(...) that doesn't point at a #fragment —
1015
+ // those are client-side SSRF / beacon vectors and there is no CSP backstop.
1016
+ function sanitizeSvg(root) {
1017
+ root.querySelectorAll('script, foreignObject, style').forEach((n) => n.remove());
1018
+ const walk = (n) => {
1019
+ if (n.nodeType === 1) {
1020
+ for (const attr of Array.from(n.attributes)) {
1021
+ const name = attr.name.toLowerCase();
1022
+ const val = (attr.value || '').replace(/\s+/g, '').toLowerCase();
1023
+ const isHref = name === 'href' || name.endsWith(':href');
1024
+ if (
1025
+ name.startsWith('on') || // event handlers
1026
+ (isHref && !val.startsWith('#')) || // only same-document fragment refs
1027
+ /url\((?!['"]?#)/.test(val) // url() to anything but a #fragment
1028
+ ) {
1029
+ n.removeAttribute(attr.name);
1030
+ }
1031
+ }
1032
+ }
1033
+ n.childNodes.forEach(walk);
1034
+ };
1035
+ walk(root);
1036
+ }
1037
+
931
1038
  function renderPlantuml(block, ctx, blockId) {
932
1039
  const container = el('div', { class: 'blk-plantuml' });
933
1040
  const fail = () =>
@@ -937,34 +1044,66 @@
937
1044
  encodePlantUml(block.code || '')
938
1045
  .then((encoded) => {
939
1046
  const server = block.server || 'https://www.plantuml.com/plantuml';
940
- const img = el('img', {
941
- class: 'blk-plantuml-img',
942
- src: server + '/svg/' + encoded,
943
- alt: 'PlantUML diagram',
944
- loading: 'lazy',
945
- });
946
- if (block.height) img.style.height = clampHeight(block.height, 360) + 'px';
947
- img.addEventListener('error', fail);
948
- container.replaceChildren(img);
949
- const attachImgViewer = () =>
950
- attachViewer(container, {
951
- zoomEl: img,
952
- natural: () => (img.naturalWidth > 0 ? { w: img.naturalWidth, h: img.naturalHeight } : null),
953
- });
954
- if (img.complete && img.naturalWidth > 0) attachImgViewer();
955
- else img.addEventListener('load', attachImgViewer, { once: true });
956
- if (ctx.annotate) {
957
- ctx.annotate.register(img, {
958
- blockId,
959
- questionId: ctx.questionId,
960
- target: { kind: 'image', label: 'PlantUML diagram' },
961
- });
962
- }
1047
+ const url = server + '/svg/' + encoded;
1048
+ // Prefer inline SVG (per-element annotation); fall back to an opaque
1049
+ // <img> if the server blocks cross-origin fetch or anything fails.
1050
+ fetchInlineSvg(url)
1051
+ .then((svg) => mountPlantumlSvg(container, svg, block, ctx, blockId))
1052
+ .catch(() => mountPlantumlImg(container, url, fail, block, ctx, blockId));
963
1053
  })
964
1054
  .catch(fail);
965
1055
  return container;
966
1056
  }
967
1057
 
1058
+ function mountPlantumlSvg(container, svg, block, ctx, blockId) {
1059
+ if (block.height != null) container.style.maxHeight = clampHeight(block.height, 1200) + 'px';
1060
+ sizeDiagramSvg(svg);
1061
+ container.replaceChildren(svg);
1062
+ attachViewer(container, {
1063
+ zoomEl: svg,
1064
+ natural: svgNatural(svg),
1065
+ label: 'diagram',
1066
+ comment: wholeBlockComment(ctx, blockId, 'diagram'),
1067
+ });
1068
+ if (!ctx.annotate) return;
1069
+ // register the text labels as annotation targets (participant boxes, message
1070
+ // labels, …) — the meaningful, clickable parts of a PlantUML diagram.
1071
+ // `idx` disambiguates: PlantUML repeats participant labels top AND bottom,
1072
+ // so without a unique key one comment would badge BOTH identical-text nodes.
1073
+ svg.querySelectorAll('text').forEach((t, idx) => {
1074
+ const text = (t.textContent || '').trim();
1075
+ if (!text) return;
1076
+ ctx.annotate.register(t, {
1077
+ blockId,
1078
+ questionId: ctx.questionId,
1079
+ target: { kind: 'plantuml-node', idx, text: text.slice(0, 120) },
1080
+ });
1081
+ });
1082
+ }
1083
+
1084
+ function mountPlantumlImg(container, url, fail, block, ctx, blockId) {
1085
+ const img = el('img', { class: 'blk-plantuml-img', src: url, alt: 'PlantUML diagram', loading: 'lazy' });
1086
+ if (block.height != null) img.style.height = clampHeight(block.height, 360) + 'px';
1087
+ img.addEventListener('error', fail);
1088
+ container.replaceChildren(img);
1089
+ const attachImgViewer = () =>
1090
+ attachViewer(container, {
1091
+ zoomEl: img,
1092
+ natural: () => (img.naturalWidth > 0 ? { w: img.naturalWidth, h: img.naturalHeight } : null),
1093
+ label: 'diagram',
1094
+ comment: wholeBlockComment(ctx, blockId, 'diagram'),
1095
+ });
1096
+ if (img.complete && img.naturalWidth > 0) attachImgViewer();
1097
+ else img.addEventListener('load', attachImgViewer, { once: true });
1098
+ if (ctx.annotate) {
1099
+ ctx.annotate.register(img, {
1100
+ blockId,
1101
+ questionId: ctx.questionId,
1102
+ target: { kind: 'image', label: 'PlantUML diagram' },
1103
+ });
1104
+ }
1105
+ }
1106
+
968
1107
  // ---------- image ----------
969
1108
  // src is a remote URL, or absent for embedded local files (served by the
970
1109
  // board server at /img/b/<id>). Same sizing rule as diagrams: never upscale
@@ -989,6 +1128,8 @@
989
1128
  attachViewer(container, {
990
1129
  zoomEl: img,
991
1130
  natural: () => (img.naturalWidth > 0 ? { w: img.naturalWidth, h: img.naturalHeight } : null),
1131
+ label: 'image',
1132
+ comment: wholeBlockComment(ctx, blockId, 'image'),
992
1133
  });
993
1134
  if (img.complete && img.naturalWidth > 0) attachImgViewer();
994
1135
  else img.addEventListener('load', attachImgViewer, { once: true });
@@ -1010,39 +1151,145 @@
1010
1151
  // <body>, which native fullscreen would hide).
1011
1152
  let fullOpen = null; // container currently expanded
1012
1153
 
1013
- // 4-corner expand icon. The toolbar deliberately holds ONLY the full-screen
1014
- // button (user feedback: the zoom button row was noise) zooming stays
1015
- // available via cmd/ctrl+wheel on zoomable blocks.
1154
+ // Toolbar icons: full-screen (4-corner expand) and a speech-bubble for the
1155
+ // "comment on the whole block" button. Zoom is both cmd/ctrl+wheel AND
1156
+ // explicit −/+ buttons (the % doubles as a reset-to-fit button).
1016
1157
  const ICON_EXPAND =
1017
1158
  '<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">' +
1018
1159
  '<path d="M3 9V3h6M10 10 3 3M15 3h6v6M14 10l7-7M9 21H3v-6M10 14l-7 7M21 15v6h-6M14 14l7 7"/></svg>';
1160
+ const ICON_COMMENT =
1161
+ '<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">' +
1162
+ '<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>';
1019
1163
 
1020
1164
  function exitFull() {
1021
1165
  if (!fullOpen) return;
1022
- fullOpen.classList.remove('blk-full');
1166
+ const c = fullOpen;
1167
+ c.classList.remove('blk-full');
1023
1168
  document.body.classList.remove('blk-full-open');
1024
- const btn = fullOpen.querySelector('.blk-tools .tool-full');
1169
+ const btn = c.querySelector('.blk-tools .tool-full');
1025
1170
  if (btn) btn.innerHTML = ICON_EXPAND;
1026
1171
  fullOpen = null;
1027
1172
  window.dispatchEvent(new Event('resize'));
1173
+ if (c._rlyToolsSync) c._rlyToolsSync(); // re-pin toolbar to its scrolled corner
1028
1174
  }
1029
1175
  document.addEventListener('keydown', (e) => {
1030
1176
  if (e.key === 'Escape' && fullOpen) exitFull();
1031
1177
  });
1032
1178
 
1033
- // opts: { zoomEl: svg|img|null, natural: () => ({w,h}|null) } zoomEl null
1034
- // means full-screen only (charts, html iframes).
1179
+ // Drag-to-pan: grab anywhere on a scrollable visual (a tall/zoomed diagram or
1180
+ // oversized image) and drag to move across it, instead of hunting for the
1181
+ // scrollbars. Self-gates on overflow, so charts and html iframes — which are
1182
+ // always sized to fit — never engage. Annotation on diagrams/images is
1183
+ // hover-pin based (not a node click), so a pan can't create a stray
1184
+ // annotation; a real drag still swallows the trailing click as a guard.
1185
+ // Returns a refresh() the viewer calls whenever content size changes (zoom,
1186
+ // full-screen, re-render) so the grab affordance tracks pannability.
1187
+ function enablePan(scrollEl) {
1188
+ let pending = false, active = false;
1189
+ let sx = 0, sy = 0, sl = 0, st = 0, pid = null;
1190
+ const THRESH = 4; // px before a press becomes a drag (keeps clicks clickable)
1191
+
1192
+ const pannable = () =>
1193
+ scrollEl.scrollWidth - scrollEl.clientWidth > 1 ||
1194
+ scrollEl.scrollHeight - scrollEl.clientHeight > 1;
1195
+ const refresh = () => scrollEl.classList.toggle('blk-pannable', pannable());
1196
+
1197
+ scrollEl.addEventListener('pointerdown', (e) => {
1198
+ if (e.button !== 0 || !pannable()) return;
1199
+ // leave the toolbar, the diagram editor, and real controls interactive
1200
+ if (e.target.closest && e.target.closest('.blk-tools, .blk-editor, button, a, input, textarea, select')) return;
1201
+ pending = true; active = false;
1202
+ sx = e.clientX; sy = e.clientY;
1203
+ sl = scrollEl.scrollLeft; st = scrollEl.scrollTop;
1204
+ pid = e.pointerId;
1205
+ });
1206
+ scrollEl.addEventListener('pointermove', (e) => {
1207
+ if (!pending) return;
1208
+ const dx = e.clientX - sx, dy = e.clientY - sy;
1209
+ if (!active) {
1210
+ if (Math.abs(dx) < THRESH && Math.abs(dy) < THRESH) return;
1211
+ active = true;
1212
+ scrollEl.classList.add('blk-panning');
1213
+ try { scrollEl.setPointerCapture(pid); } catch (_) {}
1214
+ }
1215
+ e.preventDefault();
1216
+ scrollEl.scrollLeft = sl - dx;
1217
+ scrollEl.scrollTop = st - dy;
1218
+ });
1219
+ const end = () => {
1220
+ if (active) {
1221
+ scrollEl.classList.remove('blk-panning');
1222
+ try { scrollEl.releasePointerCapture(pid); } catch (_) {}
1223
+ // a drag ends in a click on whatever was under the release — swallow it
1224
+ // once so it can't trigger anything the user didn't mean to click
1225
+ const swallow = (ev) => { ev.stopPropagation(); ev.preventDefault(); };
1226
+ scrollEl.addEventListener('click', swallow, { capture: true, once: true });
1227
+ setTimeout(() => scrollEl.removeEventListener('click', swallow, true), 0);
1228
+ }
1229
+ pending = false; active = false; pid = null;
1230
+ };
1231
+ scrollEl.addEventListener('pointerup', end);
1232
+ scrollEl.addEventListener('pointercancel', end);
1233
+ window.addEventListener('resize', refresh);
1234
+ return refresh;
1235
+ }
1236
+
1237
+ // Build the onComment thunk for attachViewer: opens the annotation popover
1238
+ // with a block-scoped target ("Whole chart/diagram/…") so a user can comment
1239
+ // on the visual as a whole. null when annotation is off (omits the button).
1240
+ function wholeBlockComment(ctx, blockId, label) {
1241
+ if (!ctx || !ctx.annotate) return null;
1242
+ const a = ctx.annotate;
1243
+ return {
1244
+ open: (anchorEl) =>
1245
+ a.openExternal({ blockId, questionId: ctx.questionId, target: { kind: 'block', label } }, anchorEl),
1246
+ // true once this block carries a whole-block comment
1247
+ active: () => a.list().some((x) => x.blockId === blockId && x.target && x.target.kind === 'block'),
1248
+ // fires on any annotation change; returns an unsubscribe
1249
+ subscribe: (fn) => (a.onBadgeRefresh ? a.onBadgeRefresh(fn) : () => {}),
1250
+ };
1251
+ }
1252
+
1253
+ // opts: { zoomEl, natural, comment, label } — zoomEl null means no pixel
1254
+ // zoom (charts/html/code: full-screen + comment only, no zoom buttons).
1255
+ // comment (optional) = { open(anchor), active(), subscribe(fn) } wiring the
1256
+ // "comment on the whole block" button + its already-commented style.
1035
1257
  function attachViewer(container, opts) {
1036
1258
  if (container._rlyTools) container._rlyTools.remove();
1037
1259
  container.classList.add('blk-viewer');
1038
1260
  const zoomable = Boolean(opts && opts.zoomEl);
1039
- let z = null; // null = fit-to-width
1261
+ const label = (opts && opts.label) || 'visual';
1262
+
1263
+ // pan listeners bind once per container; mermaid re-renders re-call
1264
+ // attachViewer, so reuse the existing refresh() instead of re-binding
1265
+ if (!container._rlyPan) container._rlyPan = enablePan(container);
1266
+ const refreshPan = container._rlyPan;
1267
+
1268
+ // The toolbar is absolute inside the scroll box, so it scrolls away when the
1269
+ // user pans/scrolls. Counter-translate it by the scroll offset to pin it to
1270
+ // the visible corner (off in full-screen, where it is position:fixed). Bound
1271
+ // once; always re-reads the current toolbar (mermaid rebuilds it on render).
1272
+ if (!container._rlyToolsSync) {
1273
+ const sync = () => {
1274
+ const tb = container._rlyTools;
1275
+ if (!tb) return;
1276
+ if (container.classList.contains('blk-full')) { tb.style.transform = ''; return; }
1277
+ const x = container.scrollLeft, y = container.scrollTop;
1278
+ tb.style.transform = x || y ? 'translate(' + x + 'px,' + y + 'px)' : '';
1279
+ };
1280
+ container.addEventListener('scroll', sync);
1281
+ container._rlyToolsSync = sync;
1282
+ }
1040
1283
 
1284
+ // zoom level persists across re-renders; the wheel handler (bound once)
1285
+ // delegates through container._rlyZoom so it never holds a stale zoomEl
1286
+ if (container._rlyZ === undefined) container._rlyZ = null; // null = fit-to-width
1041
1287
  const pct = el('span', { class: 'tool-pct' }, 'fit');
1042
1288
  function apply() {
1043
1289
  if (!zoomable) return;
1044
1290
  const target = opts.zoomEl;
1045
1291
  const nat = opts.natural();
1292
+ const z = container._rlyZ;
1046
1293
  if (z === null || !nat || !nat.w) {
1047
1294
  target.style.width = '100%';
1048
1295
  target.style.maxWidth = nat && nat.w ? Math.ceil(nat.w) + 'px' : '100%';
@@ -1054,52 +1301,88 @@
1054
1301
  target.style.height = 'auto';
1055
1302
  pct.textContent = Math.round(z * 100) + '%';
1056
1303
  }
1057
- // annotation overlay badges reposition on resize
1058
- window.dispatchEvent(new Event('resize'));
1304
+ window.dispatchEvent(new Event('resize')); // annotation badges reposition
1305
+ refreshPan(); // content size → grab affordance
1306
+ container._rlyToolsSync(); // keep the toolbar pinned
1059
1307
  }
1060
1308
  function currentZ() {
1061
- if (z !== null) return z;
1309
+ if (container._rlyZ !== null) return container._rlyZ;
1062
1310
  const nat = opts.natural();
1063
1311
  if (!nat || !nat.w) return 1;
1064
1312
  const shown = opts.zoomEl.getBoundingClientRect().width;
1065
1313
  return shown > 0 ? shown / nat.w : 1;
1066
1314
  }
1067
1315
  function setZoom(next) {
1068
- z = next === null ? null : Math.min(5, Math.max(0.2, next));
1316
+ container._rlyZ = next === null ? null : Math.min(5, Math.max(0.2, next));
1069
1317
  apply();
1070
1318
  }
1319
+ container._rlyZoom = { setZoom, currentZ };
1071
1320
 
1072
1321
  const tools = el('div', { class: 'blk-tools' });
1322
+
1323
+ // comment on the whole block (leftmost) — opens the annotation popover with
1324
+ // a block-scoped target so a user can comment without picking an element.
1325
+ // The button gains a .has-comment style once the block carries one, kept in
1326
+ // sync via the annotate badge-refresh subscription (bound once per container).
1327
+ if (opts && opts.comment) {
1328
+ const cmt = opts.comment;
1329
+ const cBtn = el('button', { class: 'tool-comment', type: 'button', title: 'Comment on this whole ' + label });
1330
+ cBtn.innerHTML = ICON_COMMENT;
1331
+ cBtn.addEventListener('click', (e) => { e.stopPropagation(); cmt.open(container); });
1332
+ tools.append(cBtn);
1333
+ container._rlyCmtSync = () => {
1334
+ const b = container._rlyTools && container._rlyTools.querySelector('.tool-comment');
1335
+ if (b && cmt.active) b.classList.toggle('has-comment', !!cmt.active());
1336
+ };
1337
+ if (!container._rlyCmtHook && cmt.subscribe) {
1338
+ container._rlyCmtHook = cmt.subscribe(() => container._rlyCmtSync && container._rlyCmtSync());
1339
+ }
1340
+ }
1341
+
1073
1342
  if (zoomable) {
1074
- // zoom lives on cmd/ctrl+wheel only the toolbar is full-screen-only
1075
- container.addEventListener(
1076
- 'wheel',
1077
- (e) => {
1078
- if (!(e.ctrlKey || e.metaKey)) return;
1343
+ // cmd/ctrl+wheel zoom, bound ONCE (re-binding per render would stack);
1344
+ // delegates to the current controller so it never uses a stale zoomEl
1345
+ if (!container._rlyWheel) {
1346
+ const onWheel = (e) => {
1347
+ if (!(e.ctrlKey || e.metaKey) || !container._rlyZoom) return;
1079
1348
  e.preventDefault();
1080
- setZoom(currentZ() * (e.deltaY < 0 ? 1.15 : 1 / 1.15));
1081
- },
1082
- { passive: false }
1083
- );
1349
+ container._rlyZoom.setZoom(container._rlyZoom.currentZ() * (e.deltaY < 0 ? 1.15 : 1 / 1.15));
1350
+ };
1351
+ container.addEventListener('wheel', onWheel, { passive: false });
1352
+ container._rlyWheel = onWheel;
1353
+ }
1354
+ const zoomOut = el('button', { class: 'tool-zoom', type: 'button', title: 'Zoom out' }, '−');
1355
+ const zoomIn = el('button', { class: 'tool-zoom', type: 'button', title: 'Zoom in' }, '+');
1356
+ zoomOut.addEventListener('click', (e) => { e.stopPropagation(); setZoom(currentZ() / 1.2); });
1357
+ zoomIn.addEventListener('click', (e) => { e.stopPropagation(); setZoom(currentZ() * 1.2); });
1358
+ pct.classList.add('is-btn');
1359
+ pct.title = 'Reset to fit';
1360
+ pct.addEventListener('click', (e) => { e.stopPropagation(); setZoom(null); });
1361
+ tools.append(zoomOut, pct, zoomIn);
1084
1362
  }
1085
- const fullBtn = el('button', { class: 'tool-full', type: 'button', title: 'Full screen (Esc closes; cmd/ctrl+wheel zooms)' });
1363
+
1364
+ const fullBtn = el('button', { class: 'tool-full', type: 'button', title: 'Full screen (Esc closes; ⌘/Ctrl+wheel zooms)' });
1086
1365
  fullBtn.innerHTML = ICON_EXPAND;
1087
- fullBtn.addEventListener('click', () => {
1088
- if (fullOpen === container) {
1089
- exitFull();
1090
- return;
1091
- }
1366
+ fullBtn.addEventListener('click', (e) => {
1367
+ e.stopPropagation();
1368
+ if (fullOpen === container) { exitFull(); return; }
1092
1369
  exitFull();
1093
1370
  container.classList.add('blk-full');
1094
1371
  document.body.classList.add('blk-full-open');
1095
1372
  fullOpen = container;
1096
1373
  fullBtn.textContent = '✕';
1097
1374
  window.dispatchEvent(new Event('resize'));
1375
+ container._rlyToolsSync();
1098
1376
  });
1099
1377
  tools.append(fullBtn);
1378
+
1100
1379
  container.append(tools);
1101
1380
  container._rlyTools = tools;
1102
1381
  if (zoomable) apply();
1382
+ // non-zoomable diagrams (tall mermaid, oversized image) can still overflow
1383
+ refreshPan();
1384
+ container._rlyToolsSync();
1385
+ if (container._rlyCmtSync) container._rlyCmtSync(); // reflect existing comment
1103
1386
  }
1104
1387
 
1105
1388
  function svgNatural(svgEl) {
@@ -1122,7 +1405,7 @@
1122
1405
  loading: 'lazy',
1123
1406
  });
1124
1407
  const wrap = el('div', { class: 'blk-htmlwrap' }, iframe);
1125
- attachViewer(wrap, { zoomEl: null });
1408
+ attachViewer(wrap, { zoomEl: null, label: 'embed', comment: wholeBlockComment(ctx, blockId, 'embed') });
1126
1409
  return wrap;
1127
1410
  }
1128
1411
 
@@ -1143,7 +1426,7 @@
1143
1426
  wrapper.append(inner);
1144
1427
  break;
1145
1428
  case 'code':
1146
- inner = renderCode(block);
1429
+ inner = renderCode(block, ctx, blockId);
1147
1430
  wrapper.append(inner);
1148
1431
  break;
1149
1432
  case 'chart':
@@ -1196,5 +1479,5 @@
1196
1479
  }
1197
1480
  }
1198
1481
 
1199
- window.RelayBlocks = { render, onThemeChange };
1482
+ window.RelayBlocks = { render, onThemeChange, renderMarkdown };
1200
1483
  })();
package/src/ui/style.css CHANGED
@@ -87,7 +87,12 @@ h1 {
87
87
  line-height: 1.25;
88
88
  text-wrap: balance;
89
89
  }
90
- .intro { color: var(--fg-2); white-space: pre-wrap; margin: 10px 0 18px; }
90
+ .intro { color: var(--fg-2); margin: 10px 0 18px; }
91
+ /* the intro renders markdown; keep it muted (out-specificity .blk-markdown .md)
92
+ and trim its block margins so it reads as a lead paragraph, not a content block */
93
+ .intro.blk-markdown .md { color: var(--fg-2); }
94
+ .intro.blk-markdown .md > :first-child { margin-top: 0; }
95
+ .intro.blk-markdown .md > :last-child { margin-bottom: 0; }
91
96
  .theme-btn {
92
97
  background: transparent; color: var(--fg-2);
93
98
  border: 1px solid var(--border-strong); border-radius: 999px;
@@ -203,6 +208,10 @@ textarea { min-height: 90px; resize: vertical; }
203
208
  background: linear-gradient(transparent, var(--bg) 38%);
204
209
  padding: 22px 0 16px;
205
210
  display: flex; align-items: center; gap: 14px;
211
+ /* above content-anchored annotation badges (z 15) so the Submit bar is never
212
+ covered; still below the full-screen overlay (49) and the modal popover/
213
+ rail/toast, which are intentional and must sit on top */
214
+ z-index: 30;
206
215
  }
207
216
  .submit {
208
217
  background: var(--accent); color: var(--accent-fg);