@khanglvm/relay 0.4.1 → 0.4.2

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.4.1",
3
+ "version": "0.4.2",
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",
@@ -202,7 +202,14 @@
202
202
  badges = [];
203
203
  if (torndown) return;
204
204
  registered = registered.filter((r) => r.el.isConnected);
205
+ // While a block is expanded full-screen, body-level overlay badges for
206
+ // OTHER blocks would float above the overlay at stale positions — only
207
+ // badge elements inside the expanded block.
208
+ const fullEl = document.body.classList.contains('blk-full-open')
209
+ ? document.querySelector('.blk-full')
210
+ : null;
205
211
  for (const entry of registered) {
212
+ if (fullEl && !fullEl.contains(entry.el)) continue;
206
213
  const count = matching(entry.info).length;
207
214
  if (!count) continue;
208
215
  const badge = el('span', { class: 'ann-badge', title: count + (count === 1 ? ' comment' : ' comments') }, String(count));
package/src/ui/blocks.css CHANGED
@@ -265,3 +265,37 @@
265
265
  max-width: 100%;
266
266
  height: auto;
267
267
  }
268
+
269
+ /* ---------- viewer controls (zoom / fit / full-screen) ---------- */
270
+ .blk-viewer { position: relative; }
271
+ .blk-tools {
272
+ position: absolute; top: 6px; right: 6px;
273
+ display: flex; gap: 2px; align-items: center;
274
+ background: var(--card); border: 1px solid var(--border);
275
+ border-radius: 8px; padding: 2px 4px;
276
+ opacity: 0.35; transition: opacity 150ms var(--ease);
277
+ z-index: 5;
278
+ }
279
+ .blk-viewer:hover .blk-tools, .blk-full .blk-tools { opacity: 1; }
280
+ .blk-tools button {
281
+ background: none; border: none; color: var(--fg-2);
282
+ font: inherit; font-size: 0.8rem; line-height: 1;
283
+ padding: 4px 6px; border-radius: 6px; cursor: pointer;
284
+ }
285
+ .blk-tools button:hover { background: var(--bg-sunken); color: var(--accent); }
286
+ .blk-tools .tool-pct { color: var(--muted); font-size: 0.72rem; min-width: 30px; text-align: center; }
287
+ /* charts keep their annotation-count badge top-right; tools go top-left there */
288
+ .blk-chart .blk-tools { right: auto; left: 6px; }
289
+
290
+ /* full-screen = fixed overlay (NOT native fullscreen: annotation pins/badges/
291
+ popover live on <body> and must stay visible above the expanded block) */
292
+ .blk-full {
293
+ position: fixed !important; inset: 12px; z-index: 49;
294
+ background: var(--card); border: 1px solid var(--border);
295
+ border-radius: 12px; box-shadow: var(--shadow-lift);
296
+ overflow: auto; max-height: none !important; margin: 0 !important;
297
+ padding: 48px 16px 16px;
298
+ }
299
+ body.blk-full-open { overflow: hidden; }
300
+ .blk-chart.blk-full { height: calc(100vh - 24px) !important; width: auto !important; }
301
+ .blk-htmlwrap.blk-full iframe.viz { height: calc(100vh - 90px) !important; width: 100%; }
package/src/ui/blocks.js CHANGED
@@ -502,6 +502,7 @@
502
502
  }
503
503
  syncBadge();
504
504
  wrap.append(badge);
505
+ attachViewer(wrap, { zoomEl: null }); // full-screen only; charts redraw responsively
505
506
 
506
507
  loadChart().then((Chart) => {
507
508
  let config = block.config
@@ -724,6 +725,8 @@
724
725
  });
725
726
  });
726
727
  }
728
+ // re-attach per render: innerHTML replacement above wiped the old bar
729
+ if (svgEl) attachViewer(container, { zoomEl: svgEl, natural: svgNatural(svgEl) });
727
730
  if (onDone) onDone();
728
731
  };
729
732
  try {
@@ -809,6 +812,7 @@
809
812
  const svgEl = viz.renderSVGElement(block.dot || '');
810
813
  sizeDiagramSvg(svgEl);
811
814
  container.replaceChildren(svgEl);
815
+ attachViewer(container, { zoomEl: svgEl, natural: svgNatural(svgEl) });
812
816
  if (!ctx.annotate) return;
813
817
  const parts = svgEl.querySelectorAll('g.node, g.edge');
814
818
  parts.forEach((g) => {
@@ -885,6 +889,13 @@
885
889
  if (block.height) img.style.height = clampHeight(block.height, 360) + 'px';
886
890
  img.addEventListener('error', fail);
887
891
  container.replaceChildren(img);
892
+ const attachImgViewer = () =>
893
+ attachViewer(container, {
894
+ zoomEl: img,
895
+ natural: () => (img.naturalWidth > 0 ? { w: img.naturalWidth, h: img.naturalHeight } : null),
896
+ });
897
+ if (img.complete && img.naturalWidth > 0) attachImgViewer();
898
+ else img.addEventListener('load', attachImgViewer, { once: true });
888
899
  if (ctx.annotate) {
889
900
  ctx.annotate.register(img, {
890
901
  blockId,
@@ -897,10 +908,115 @@
897
908
  return container;
898
909
  }
899
910
 
911
+ // ---------- viewer controls (zoom / fit / full-screen) ----------
912
+ // Large diagrams get squeezed to the column width; these controls let the
913
+ // user zoom (buttons or cmd/ctrl+wheel) and expand any visual block into a
914
+ // full-screen overlay. An overlay — NOT the native Fullscreen API — keeps
915
+ // the annotation pins/badges/popover usable while expanded (they live on
916
+ // <body>, which native fullscreen would hide).
917
+ let fullOpen = null; // container currently expanded
918
+
919
+ function exitFull() {
920
+ if (!fullOpen) return;
921
+ fullOpen.classList.remove('blk-full');
922
+ document.body.classList.remove('blk-full-open');
923
+ const btn = fullOpen.querySelector('.blk-tools .tool-full');
924
+ if (btn) btn.textContent = '⛶';
925
+ fullOpen = null;
926
+ window.dispatchEvent(new Event('resize'));
927
+ }
928
+ document.addEventListener('keydown', (e) => {
929
+ if (e.key === 'Escape' && fullOpen) exitFull();
930
+ });
931
+
932
+ // opts: { zoomEl: svg|img|null, natural: () => ({w,h}|null) } — zoomEl null
933
+ // means full-screen only (charts, html iframes).
934
+ function attachViewer(container, opts) {
935
+ if (container._rlyTools) container._rlyTools.remove();
936
+ container.classList.add('blk-viewer');
937
+ const zoomable = Boolean(opts && opts.zoomEl);
938
+ let z = null; // null = fit-to-width
939
+
940
+ const pct = el('span', { class: 'tool-pct' }, 'fit');
941
+ function apply() {
942
+ if (!zoomable) return;
943
+ const target = opts.zoomEl;
944
+ const nat = opts.natural();
945
+ if (z === null || !nat || !nat.w) {
946
+ target.style.width = '100%';
947
+ target.style.maxWidth = nat && nat.w ? Math.ceil(nat.w) + 'px' : '100%';
948
+ target.style.height = 'auto';
949
+ pct.textContent = 'fit';
950
+ } else {
951
+ target.style.maxWidth = 'none';
952
+ target.style.width = Math.round(nat.w * z) + 'px';
953
+ target.style.height = 'auto';
954
+ pct.textContent = Math.round(z * 100) + '%';
955
+ }
956
+ // annotation overlay badges reposition on resize
957
+ window.dispatchEvent(new Event('resize'));
958
+ }
959
+ function currentZ() {
960
+ if (z !== null) return z;
961
+ const nat = opts.natural();
962
+ if (!nat || !nat.w) return 1;
963
+ const shown = opts.zoomEl.getBoundingClientRect().width;
964
+ return shown > 0 ? shown / nat.w : 1;
965
+ }
966
+ function setZoom(next) {
967
+ z = next === null ? null : Math.min(5, Math.max(0.2, next));
968
+ apply();
969
+ }
970
+
971
+ const tools = el('div', { class: 'blk-tools' });
972
+ if (zoomable) {
973
+ tools.append(
974
+ el('button', { type: 'button', title: 'Zoom out', onclick: () => setZoom(currentZ() / 1.25) }, '−'),
975
+ pct,
976
+ el('button', { type: 'button', title: 'Zoom in', onclick: () => setZoom(currentZ() * 1.25) }, '+'),
977
+ el('button', { type: 'button', title: 'Actual size', onclick: () => setZoom(1) }, '1:1'),
978
+ el('button', { type: 'button', title: 'Fit to width', onclick: () => setZoom(null) }, 'fit')
979
+ );
980
+ container.addEventListener(
981
+ 'wheel',
982
+ (e) => {
983
+ if (!(e.ctrlKey || e.metaKey)) return;
984
+ e.preventDefault();
985
+ setZoom(currentZ() * (e.deltaY < 0 ? 1.15 : 1 / 1.15));
986
+ },
987
+ { passive: false }
988
+ );
989
+ }
990
+ const fullBtn = el('button', { class: 'tool-full', type: 'button', title: 'Full screen (Esc closes)' }, '⛶');
991
+ fullBtn.addEventListener('click', () => {
992
+ if (fullOpen === container) {
993
+ exitFull();
994
+ return;
995
+ }
996
+ exitFull();
997
+ container.classList.add('blk-full');
998
+ document.body.classList.add('blk-full-open');
999
+ fullOpen = container;
1000
+ fullBtn.textContent = '✕';
1001
+ window.dispatchEvent(new Event('resize'));
1002
+ });
1003
+ tools.append(fullBtn);
1004
+ container.append(tools);
1005
+ container._rlyTools = tools;
1006
+ if (zoomable) apply();
1007
+ }
1008
+
1009
+ function svgNatural(svgEl) {
1010
+ return () => {
1011
+ const vb = svgEl.viewBox && svgEl.viewBox.baseVal;
1012
+ return vb && vb.width > 0 ? { w: vb.width, h: vb.height } : null;
1013
+ };
1014
+ }
1015
+
900
1016
  // ---------- html (sandboxed iframe) ----------
901
1017
  function renderHtml(block, ctx, blockId) {
902
1018
  const height = clampHeight(block.height, 360);
903
- return el('iframe', {
1019
+ const iframe = el('iframe', {
904
1020
  class: 'viz',
905
1021
  'data-block-id': blockId,
906
1022
  'data-question-id': ctx.questionId || '',
@@ -909,6 +1025,9 @@
909
1025
  sandbox: 'allow-scripts allow-forms allow-popups allow-modals',
910
1026
  loading: 'lazy',
911
1027
  });
1028
+ const wrap = el('div', { class: 'blk-htmlwrap' }, iframe);
1029
+ attachViewer(wrap, { zoomEl: null });
1030
+ return wrap;
912
1031
  }
913
1032
 
914
1033
  // ---------- dispatch ----------