@khanglvm/relay 0.8.2 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/ui/annotate.css +6 -2
- package/src/ui/annotate.js +42 -8
- package/src/ui/blocks.css +63 -0
- package/src/ui/blocks.js +361 -78
- package/src/ui/style.css +4 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@khanglvm/relay",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
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",
|
package/src/ui/annotate.css
CHANGED
|
@@ -30,8 +30,12 @@
|
|
|
30
30
|
user-select: none;
|
|
31
31
|
}
|
|
32
32
|
.ann-badge:hover { background: var(--accent-hover); }
|
|
33
|
-
/* SVG
|
|
34
|
-
|
|
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 {
|
package/src/ui/annotate.js
CHANGED
|
@@ -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
|
|
436
|
-
//
|
|
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
|
-
|
|
455
|
+
const host = entry.el.closest ? entry.el.closest('.blk-viewer') : null;
|
|
439
456
|
const r = entry.el.getBoundingClientRect();
|
|
440
|
-
|
|
441
|
-
|
|
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/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
|
-
|
|
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
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
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
|
-
|
|
561
|
-
|
|
562
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
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
|
|
1014
|
-
//
|
|
1015
|
-
//
|
|
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
|
|
1166
|
+
const c = fullOpen;
|
|
1167
|
+
c.classList.remove('blk-full');
|
|
1023
1168
|
document.body.classList.remove('blk-full-open');
|
|
1024
|
-
const btn =
|
|
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
|
-
//
|
|
1034
|
-
//
|
|
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
|
-
|
|
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
|
|
1058
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
//
|
|
1075
|
-
|
|
1076
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1089
|
-
|
|
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':
|
package/src/ui/style.css
CHANGED
|
@@ -203,6 +203,10 @@ textarea { min-height: 90px; resize: vertical; }
|
|
|
203
203
|
background: linear-gradient(transparent, var(--bg) 38%);
|
|
204
204
|
padding: 22px 0 16px;
|
|
205
205
|
display: flex; align-items: center; gap: 14px;
|
|
206
|
+
/* above content-anchored annotation badges (z 15) so the Submit bar is never
|
|
207
|
+
covered; still below the full-screen overlay (49) and the modal popover/
|
|
208
|
+
rail/toast, which are intentional and must sit on top */
|
|
209
|
+
z-index: 30;
|
|
206
210
|
}
|
|
207
211
|
.submit {
|
|
208
212
|
background: var(--accent); color: var(--accent-fg);
|