@khanglvm/relay 0.12.2 → 0.13.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/README.md +1 -0
- package/docs/AGENT.md +75 -6
- package/package.json +1 -1
- package/skills/relay/SKILL.md +180 -4
- package/src/cli.js +98 -17
- package/src/mcp-ui/board.js +213 -10
- package/src/server.js +8 -3
- package/src/spec.js +272 -47
- package/src/ui/annotate.css +75 -10
- package/src/ui/annotate.js +90 -12
- package/src/ui/app.js +232 -8
- package/src/ui/blocks.css +164 -4
- package/src/ui/blocks.js +430 -33
- package/src/ui/style.css +90 -0
package/src/ui/blocks.js
CHANGED
|
@@ -95,6 +95,23 @@
|
|
|
95
95
|
s = s.replace(/`([^`]+)`/g, (_m, c) =>
|
|
96
96
|
looksLikeLocalPath(c) ? keep(fileLinkHtml(c.trim(), c, true)) : keep(`<code>${c}</code>`)
|
|
97
97
|
);
|
|
98
|
+
// reference links [label](#ref:name) / [label](#block:id) open the named/identified
|
|
99
|
+
// block in a full-screen modal — so a question can point back to a chart/image
|
|
100
|
+
// shown earlier without the user scrolling up. Runs before the generic link
|
|
101
|
+
// pass so the #ref:/#block: target isn't rendered as a plain href.
|
|
102
|
+
s = s.replace(/\[([^\]]+)\]\(#(ref|block):([^)\s]+)\)/g, (_m, label, kind, target) =>
|
|
103
|
+
keep(`<a class="rly-reflink" role="button" tabindex="0" data-rly-${kind === 'block' ? 'refid' : 'ref'}="${target}" title="Open this visual">` +
|
|
104
|
+
`<span class="rly-reflink-ico" aria-hidden="true"></span><span>${label}</span></a>`));
|
|
105
|
+
// images  BEFORE links so the leading "!" isn't stranded as text.
|
|
106
|
+
// A remote/data image embeds inline; a local path stays a click-to-open link
|
|
107
|
+
// (the board server doesn't serve the source file's directory).
|
|
108
|
+
// ponytail: remote/data images embed; local-relative images open in the app
|
|
109
|
+
// rather than render — add directory serving only if users need inline locals.
|
|
110
|
+
s = s.replace(/!\[([^\]]*)\]\(([^)\s]+)\)/g, (_m, alt, url) => {
|
|
111
|
+
if (/^(https?:|data:image\/)/i.test(url)) return keep(`<img class="md-img" src="${url}" alt="${alt}" loading="lazy">`);
|
|
112
|
+
if (looksLikeLocalPath(url)) return keep(fileLinkHtml(url, alt || url, false));
|
|
113
|
+
return ``; // unknown scheme — leave literal, don't link
|
|
114
|
+
});
|
|
98
115
|
// links [text](url) — url is already escaped; a local-path target becomes a
|
|
99
116
|
// click-to-open link, otherwise a normal link (guarding odd schemes).
|
|
100
117
|
s = s.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_m, text, url) => {
|
|
@@ -107,6 +124,10 @@
|
|
|
107
124
|
// bold then italic (bold uses ** so must run before single *)
|
|
108
125
|
s = s.replace(/\*\*([^*]+)\*\*/g, (_m, c) => `<strong>${c}</strong>`);
|
|
109
126
|
s = s.replace(/\*([^*]+)\*/g, (_m, c) => `<em>${c}</em>`);
|
|
127
|
+
// underscore emphasis __bold__ / _italic_, only at word boundaries so
|
|
128
|
+
// snake_case and RLY_BOARD_ID are left alone (CommonMark intraword rule).
|
|
129
|
+
s = s.replace(/(^|[^A-Za-z0-9_])__([^_]+)__(?![A-Za-z0-9_])/g, (_m, p, c) => `${p}<strong>${c}</strong>`);
|
|
130
|
+
s = s.replace(/(^|[^A-Za-z0-9_])_([^_]+)_(?![A-Za-z0-9_])/g, (_m, p, c) => `${p}<em>${c}</em>`);
|
|
110
131
|
// restore the stashed HTML — loop so a placeholder nested inside another
|
|
111
132
|
// stashed fragment (e.g. a code span inside a link's text) is also resolved.
|
|
112
133
|
let guard = 0;
|
|
@@ -373,14 +394,25 @@
|
|
|
373
394
|
return out;
|
|
374
395
|
}
|
|
375
396
|
|
|
376
|
-
// A line-number gutter
|
|
377
|
-
//
|
|
378
|
-
|
|
397
|
+
// A line-number gutter for `text` (non-selectable so a select-to-comment grabs
|
|
398
|
+
// only the code, not the numbers). When annotation is on, each number becomes a
|
|
399
|
+
// hover/click target to comment on that exact line (kind:'code-line'); the
|
|
400
|
+
// numbers still don't enter a text selection. `startAt` default 1.
|
|
401
|
+
function lineGutter(text, startAt, ctx, blockId, filename) {
|
|
379
402
|
const n = Math.max(1, String(text).replace(/\n+$/, '').split('\n').length);
|
|
380
|
-
const
|
|
381
|
-
|
|
382
|
-
for (let i = 0; i < n; i++)
|
|
383
|
-
|
|
403
|
+
const annotate = ctx && ctx.annotate;
|
|
404
|
+
const g = el('div', { class: 'blk-gutter' + (annotate ? ' blk-gutter-c' : ''), 'aria-hidden': annotate ? null : 'true' });
|
|
405
|
+
for (let i = 0; i < n; i++) {
|
|
406
|
+
const ln = (startAt || 1) + i;
|
|
407
|
+
const lineEl = el('div', { class: 'blk-gutterline' }, String(ln));
|
|
408
|
+
if (annotate) {
|
|
409
|
+
annotate.register(lineEl, {
|
|
410
|
+
blockId, questionId: ctx.questionId,
|
|
411
|
+
target: { kind: 'code-line', line: ln, file: filename || '' },
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
g.append(lineEl);
|
|
415
|
+
}
|
|
384
416
|
return g;
|
|
385
417
|
}
|
|
386
418
|
|
|
@@ -391,7 +423,7 @@
|
|
|
391
423
|
const code = el('code');
|
|
392
424
|
code.innerHTML = tintCode(raw, block.lang);
|
|
393
425
|
const pre = el('pre', { class: 'blk-pre', 'data-lang': block.lang || '' }, code);
|
|
394
|
-
const row = el('div', { class: 'blk-coderow' }, lineGutter(raw), pre);
|
|
426
|
+
const row = el('div', { class: 'blk-coderow' }, lineGutter(raw, 1, ctx, blockId, block.filename), pre);
|
|
395
427
|
const wrap = el('div', { class: 'blk-codewrap' });
|
|
396
428
|
// Optional file-name + language header above the code.
|
|
397
429
|
if (block.filename || block.lang) {
|
|
@@ -513,18 +545,62 @@
|
|
|
513
545
|
return el('table', { class: 'blk-difftable blk-difftable-split' }, tbody);
|
|
514
546
|
}
|
|
515
547
|
|
|
548
|
+
// Split a (possibly multi-file) unified diff into per-file chunks. Each
|
|
549
|
+
// `diff --git a/x b/x` starts a new file; the name comes from that line (or a
|
|
550
|
+
// following `+++ b/...`). A plain hunk-only diff (no `diff --git`) is one
|
|
551
|
+
// unnamed file, preserving the single-file path that uses block.filename.
|
|
552
|
+
function splitDiffFiles(text) {
|
|
553
|
+
const lines = String(text || '').replace(/\r\n?/g, '\n').split('\n');
|
|
554
|
+
const files = [];
|
|
555
|
+
let cur = null;
|
|
556
|
+
for (const line of lines) {
|
|
557
|
+
if (/^diff --git /.test(line)) {
|
|
558
|
+
const m = line.match(/^diff --git a\/(.+?) b\/(.+)$/);
|
|
559
|
+
cur = { name: m ? m[2] : 'file', lines: [line] };
|
|
560
|
+
files.push(cur);
|
|
561
|
+
} else {
|
|
562
|
+
if (!cur) { cur = { name: null, lines: [] }; files.push(cur); }
|
|
563
|
+
cur.lines.push(line);
|
|
564
|
+
if (cur.name === null) {
|
|
565
|
+
const pm = line.match(/^\+\+\+ b?\/(.+)$/);
|
|
566
|
+
if (pm) cur.name = pm[1];
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
return files.map((f) => ({ name: f.name, text: f.lines.join('\n') }));
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// Per-file header lines are redundant once we render a filename header bar.
|
|
574
|
+
const DIFF_NOISE = /^(diff --git |index |--- |\+\+\+ )/;
|
|
575
|
+
|
|
516
576
|
function renderDiff(block, ctx, blockId) {
|
|
517
|
-
const
|
|
577
|
+
const files = splitDiffFiles(block.diff);
|
|
578
|
+
const named = files.filter((f) => f.name);
|
|
579
|
+
const multi = named.length > 1;
|
|
518
580
|
let view = block.view === 'split' ? 'split' : 'unified';
|
|
519
581
|
const scroll = el('div', { class: 'blk-diffscroll' });
|
|
582
|
+
const sections = [];
|
|
583
|
+
|
|
520
584
|
const paint = () => {
|
|
521
|
-
scroll.
|
|
522
|
-
|
|
585
|
+
scroll.replaceChildren();
|
|
586
|
+
sections.length = 0;
|
|
587
|
+
files.forEach((f) => {
|
|
588
|
+
const name = f.name || (files.length === 1 ? (block.filename || '') : '');
|
|
589
|
+
let rows = parseDiff(f.text);
|
|
590
|
+
// drop the pure file-header noise when we show a filename bar
|
|
591
|
+
if (name) rows = rows.filter((r) => !(r.kind === 'meta' && DIFF_NOISE.test(r.text)));
|
|
592
|
+
if (!rows.length) return;
|
|
593
|
+
const sec = el('div', { class: 'diff-file' });
|
|
594
|
+
if (name) sec.append(el('div', { class: 'diff-filehead' }, name));
|
|
595
|
+
sec.append(view === 'split' ? buildSplitDiff(rows, block.lang) : buildUnifiedDiff(rows, block.lang));
|
|
596
|
+
scroll.append(sec);
|
|
597
|
+
sections.push({ name, el: sec });
|
|
598
|
+
});
|
|
523
599
|
};
|
|
524
600
|
paint();
|
|
525
601
|
|
|
526
602
|
const wrap = el('div', { class: 'blk-codewrap blk-diffwrap' });
|
|
527
|
-
// header: file name (left) + a Unified/Split view toggle (right)
|
|
603
|
+
// header: file count / single name (left) + a Unified/Split view toggle (right)
|
|
528
604
|
const toggle = el('button', { class: 'blk-difftoggle', type: 'button', title: 'Toggle side-by-side view' });
|
|
529
605
|
const syncToggle = () => { toggle.textContent = view === 'split' ? 'Unified view' : 'Split view'; };
|
|
530
606
|
syncToggle();
|
|
@@ -537,10 +613,26 @@
|
|
|
537
613
|
syncToggle();
|
|
538
614
|
paint();
|
|
539
615
|
});
|
|
616
|
+
const headLabel = multi ? `${named.length} files changed` : (named[0] && named[0].name) || block.filename || '';
|
|
540
617
|
wrap.append(el('div', { class: 'blk-codehead' },
|
|
541
|
-
el('span', { class: 'blk-codename' },
|
|
618
|
+
el('span', { class: 'blk-codename' }, headLabel),
|
|
542
619
|
toggle
|
|
543
620
|
));
|
|
621
|
+
// jump bar of file chips for a multi-file diff (click scrolls to that file)
|
|
622
|
+
if (multi) {
|
|
623
|
+
const bar = el('div', { class: 'diff-files' });
|
|
624
|
+
named.forEach((f) => {
|
|
625
|
+
const chip = el('button', { type: 'button', class: 'diff-file-chip' }, f.name);
|
|
626
|
+
chip.addEventListener('mousedown', (e) => e.stopPropagation());
|
|
627
|
+
chip.addEventListener('click', (e) => {
|
|
628
|
+
e.stopPropagation();
|
|
629
|
+
const s = sections.find((x) => x.name === f.name);
|
|
630
|
+
if (s) s.el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
631
|
+
});
|
|
632
|
+
bar.append(chip);
|
|
633
|
+
});
|
|
634
|
+
wrap.append(bar);
|
|
635
|
+
}
|
|
544
636
|
wrap.classList.toggle('is-split', view === 'split');
|
|
545
637
|
wrap.append(scroll);
|
|
546
638
|
// select-to-comment stays bound to the stable scroll container across toggles
|
|
@@ -615,10 +707,32 @@
|
|
|
615
707
|
return String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: 'base' });
|
|
616
708
|
}
|
|
617
709
|
|
|
710
|
+
// CSV-encode a value (quote when it contains a comma/quote/newline).
|
|
711
|
+
function csvCell(v) {
|
|
712
|
+
const s = v === undefined || v === null ? '' : String(v);
|
|
713
|
+
return /[",\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
|
|
714
|
+
}
|
|
715
|
+
// Trigger a client-side download of `text` as `filename` (browser board). In a
|
|
716
|
+
// sandboxed/MCP host this may be blocked — best effort, no error if it no-ops.
|
|
717
|
+
function downloadText(filename, text, mime) {
|
|
718
|
+
try {
|
|
719
|
+
const blob = new Blob([text], { type: mime || 'text/plain' });
|
|
720
|
+
const url = URL.createObjectURL(blob);
|
|
721
|
+
const a = el('a', { href: url, download: filename });
|
|
722
|
+
document.body.append(a);
|
|
723
|
+
a.click();
|
|
724
|
+
a.remove();
|
|
725
|
+
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
726
|
+
} catch (_) { /* sandbox without download permission */ }
|
|
727
|
+
}
|
|
728
|
+
|
|
618
729
|
function renderTable(block, ctx, blockId) {
|
|
619
730
|
const cols = normalizeColumns(block.columns);
|
|
620
731
|
const rows = block.rows || [];
|
|
621
732
|
const sortable = block.sortable === true;
|
|
733
|
+
const filterable = block.filterable === true;
|
|
734
|
+
const exportable = block.exportable === true;
|
|
735
|
+
let filterText = '';
|
|
622
736
|
// sortState: column index in cols (-1 none) + dir
|
|
623
737
|
let sortCol = -1;
|
|
624
738
|
let sortDir = 0; // 0 none, 1 asc, -1 desc
|
|
@@ -647,9 +761,17 @@
|
|
|
647
761
|
const tbody = el('tbody');
|
|
648
762
|
table.append(thead, tbody);
|
|
649
763
|
|
|
650
|
-
|
|
651
|
-
|
|
764
|
+
// rows (as original indices) passing the current filter — used by both the
|
|
765
|
+
// table body and the CSV export so export honors the visible/filtered set.
|
|
766
|
+
function filteredOrder() {
|
|
652
767
|
let order = rows.map((_r, idx) => idx);
|
|
768
|
+
if (filterText) {
|
|
769
|
+
const q = filterText.toLowerCase();
|
|
770
|
+
order = order.filter((idx) => cols.some((col) => {
|
|
771
|
+
const v = cellValue(rows[idx], col);
|
|
772
|
+
return v !== undefined && v !== null && String(v).toLowerCase().includes(q);
|
|
773
|
+
}));
|
|
774
|
+
}
|
|
653
775
|
if (sortable && sortCol >= 0 && sortDir !== 0) {
|
|
654
776
|
const col = cols[sortCol];
|
|
655
777
|
order.sort((ia, ib) => {
|
|
@@ -657,6 +779,11 @@
|
|
|
657
779
|
return sortDir === 1 ? r : -r;
|
|
658
780
|
});
|
|
659
781
|
}
|
|
782
|
+
return order;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
function rebuild() {
|
|
786
|
+
const order = filteredOrder();
|
|
660
787
|
headCells.forEach((hc, ci) => {
|
|
661
788
|
hc.arrow.textContent = sortable && ci === sortCol && sortDir !== 0 ? (sortDir === 1 ? ' ↑' : ' ↓') : '';
|
|
662
789
|
});
|
|
@@ -681,7 +808,32 @@
|
|
|
681
808
|
rebuild();
|
|
682
809
|
// Tables are visual too: wrap so they get the full-screen viewer like
|
|
683
810
|
// every other visual block (wide tables squeeze in the column).
|
|
684
|
-
const wrap = el('div', { class: 'blk-tablewrap' }
|
|
811
|
+
const wrap = el('div', { class: 'blk-tablewrap' });
|
|
812
|
+
// Optional toolbar: live filter box + CSV export of the filtered rows.
|
|
813
|
+
if (filterable || exportable) {
|
|
814
|
+
const bar = el('div', { class: 'blk-tablebar' });
|
|
815
|
+
if (filterable) {
|
|
816
|
+
const input = el('input', { type: 'text', class: 'blk-tablefilter', placeholder: 'Filter rows…', 'aria-label': 'Filter table rows' });
|
|
817
|
+
input.addEventListener('input', () => { filterText = input.value.trim(); rebuild(); });
|
|
818
|
+
// don't let a click in the filter start a drag-pan / full-screen
|
|
819
|
+
input.addEventListener('mousedown', (e) => e.stopPropagation());
|
|
820
|
+
bar.append(input);
|
|
821
|
+
}
|
|
822
|
+
if (exportable) {
|
|
823
|
+
const btn = el('button', { type: 'button', class: 'blk-tableexport', title: 'Download the filtered rows as CSV' }, 'CSV');
|
|
824
|
+
btn.addEventListener('mousedown', (e) => e.stopPropagation());
|
|
825
|
+
btn.addEventListener('click', (e) => {
|
|
826
|
+
e.stopPropagation();
|
|
827
|
+
const order = filteredOrder();
|
|
828
|
+
const head = cols.map((c) => csvCell(c.label)).join(',');
|
|
829
|
+
const body = order.map((idx) => cols.map((c) => csvCell(cellValue(rows[idx], c))).join(',')).join('\n');
|
|
830
|
+
downloadText('table.csv', head + '\n' + body + '\n', 'text/csv');
|
|
831
|
+
});
|
|
832
|
+
bar.append(btn);
|
|
833
|
+
}
|
|
834
|
+
wrap.append(bar);
|
|
835
|
+
}
|
|
836
|
+
wrap.append(table);
|
|
685
837
|
attachViewer(wrap, { zoomEl: null });
|
|
686
838
|
return wrap;
|
|
687
839
|
}
|
|
@@ -1401,10 +1553,12 @@
|
|
|
1401
1553
|
alt: block.alt || 'image',
|
|
1402
1554
|
loading: 'lazy',
|
|
1403
1555
|
});
|
|
1404
|
-
//
|
|
1405
|
-
//
|
|
1406
|
-
|
|
1407
|
-
|
|
1556
|
+
// Inline default: the image fills its full width (container width, never
|
|
1557
|
+
// upscaled past natural) so it's readable without manual zoom; the CONTAINER
|
|
1558
|
+
// caps the height (default 800 via CSS, or block.height) and SCROLLS — we
|
|
1559
|
+
// never cap the image's own height, which would shrink its width. Full-screen
|
|
1560
|
+
// lifts the cap (the .blk-full rule).
|
|
1561
|
+
if (block.height != null) container.style.maxHeight = clampHeight(block.height, 800) + 'px';
|
|
1408
1562
|
img.addEventListener('error', () => {
|
|
1409
1563
|
container.replaceChildren(el('div', { class: 'blk-error' }, 'Image failed to load'));
|
|
1410
1564
|
});
|
|
@@ -1415,11 +1569,12 @@
|
|
|
1415
1569
|
natural: () => (img.naturalWidth > 0 ? { w: img.naturalWidth, h: img.naturalHeight } : null),
|
|
1416
1570
|
label: 'image',
|
|
1417
1571
|
comment: wholeBlockComment(ctx, blockId, 'image'),
|
|
1418
|
-
fitMaxHeight,
|
|
1419
1572
|
});
|
|
1420
1573
|
if (img.complete && img.naturalWidth > 0) attachImgViewer();
|
|
1421
1574
|
else img.addEventListener('load', attachImgViewer, { once: true });
|
|
1422
|
-
if (ctx.annotate) {
|
|
1575
|
+
if (ctx.annotate && block.pins === true) {
|
|
1576
|
+
enableImagePins(container, img, ctx, blockId, block.alt || 'Image');
|
|
1577
|
+
} else if (ctx.annotate) {
|
|
1423
1578
|
ctx.annotate.register(img, {
|
|
1424
1579
|
blockId,
|
|
1425
1580
|
questionId: ctx.questionId,
|
|
@@ -1429,6 +1584,56 @@
|
|
|
1429
1584
|
return container;
|
|
1430
1585
|
}
|
|
1431
1586
|
|
|
1587
|
+
// Coordinate pin-comments on an image (block.pins): click any point to drop a
|
|
1588
|
+
// comment anchored to that (x,y) fraction; a numbered pin marks each one and
|
|
1589
|
+
// tracks the image as it scales/scrolls (positions are % of the displayed img,
|
|
1590
|
+
// so they ride zoom/full-screen/pan for free). Mirrors the chart-badge model.
|
|
1591
|
+
function enableImagePins(container, img, ctx, blockId, label) {
|
|
1592
|
+
const layer = el('div', { class: 'blk-imgpins' });
|
|
1593
|
+
container.append(layer);
|
|
1594
|
+
img.classList.add('blk-img-pinnable');
|
|
1595
|
+
let down = null; // {x,y} at pointerdown — used to reject drags (pan) as clicks
|
|
1596
|
+
img.addEventListener('pointerdown', (e) => { down = { x: e.clientX, y: e.clientY }; });
|
|
1597
|
+
img.addEventListener('click', (e) => {
|
|
1598
|
+
if (down && (Math.abs(e.clientX - down.x) > 4 || Math.abs(e.clientY - down.y) > 4)) { down = null; return; }
|
|
1599
|
+
const r = img.getBoundingClientRect();
|
|
1600
|
+
if (!r.width || !r.height) return;
|
|
1601
|
+
const x = Math.min(1, Math.max(0, (e.clientX - r.left) / r.width));
|
|
1602
|
+
const y = Math.min(1, Math.max(0, (e.clientY - r.top) / r.height));
|
|
1603
|
+
ctx.annotate.openExternal(
|
|
1604
|
+
{ blockId, questionId: ctx.questionId, target: { kind: 'image-point', x: Math.round(x * 1000) / 1000, y: Math.round(y * 1000) / 1000, label } },
|
|
1605
|
+
img
|
|
1606
|
+
);
|
|
1607
|
+
});
|
|
1608
|
+
const syncPins = () => {
|
|
1609
|
+
layer.replaceChildren();
|
|
1610
|
+
const groups = new Map(); // "x:y" -> {target, count}
|
|
1611
|
+
let n = 0;
|
|
1612
|
+
for (const a of ctx.annotate.list()) {
|
|
1613
|
+
if (a.blockId !== blockId) continue;
|
|
1614
|
+
const t = a.target || {};
|
|
1615
|
+
if (t.kind !== 'image-point') continue;
|
|
1616
|
+
const k = t.x + ':' + t.y;
|
|
1617
|
+
const g = groups.get(k) || { target: t, count: 0 };
|
|
1618
|
+
g.count++; groups.set(k, g);
|
|
1619
|
+
}
|
|
1620
|
+
for (const { target, count } of groups.values()) {
|
|
1621
|
+
n++;
|
|
1622
|
+
const pin = el('button', { class: 'blk-imgpin', type: 'button', title: count + (count === 1 ? ' comment' : ' comments') }, String(count));
|
|
1623
|
+
pin.style.left = (target.x * 100) + '%';
|
|
1624
|
+
pin.style.top = (target.y * 100) + '%';
|
|
1625
|
+
pin.addEventListener('mousedown', (e) => e.stopPropagation());
|
|
1626
|
+
pin.addEventListener('click', (e) => {
|
|
1627
|
+
e.stopPropagation(); e.preventDefault();
|
|
1628
|
+
ctx.annotate.openExternal({ blockId, questionId: ctx.questionId, target }, pin);
|
|
1629
|
+
});
|
|
1630
|
+
layer.append(pin);
|
|
1631
|
+
}
|
|
1632
|
+
};
|
|
1633
|
+
if (ctx.annotate.onBadgeRefresh) ctx.annotate.onBadgeRefresh(syncPins);
|
|
1634
|
+
syncPins();
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1432
1637
|
// ---------- palette ----------
|
|
1433
1638
|
// Color palettes as swatch cards: hover a swatch to reveal its hex, click to
|
|
1434
1639
|
// copy it. One palette may be {featured:true} → a larger spotlight row. Lets
|
|
@@ -1488,6 +1693,104 @@
|
|
|
1488
1693
|
return wrap;
|
|
1489
1694
|
}
|
|
1490
1695
|
|
|
1696
|
+
// ---------- kpi (stat cards) ----------
|
|
1697
|
+
function renderKpi(block, ctx, blockId) {
|
|
1698
|
+
const wrap = el('div', { class: 'blk-kpi' });
|
|
1699
|
+
if (block.title) wrap.append(el('div', { class: 'kpi-title' }, block.title));
|
|
1700
|
+
const grid = el('div', { class: 'kpi-grid' });
|
|
1701
|
+
for (const it of block.items || []) {
|
|
1702
|
+
const card = el('div', { class: 'kpi-card' });
|
|
1703
|
+
if (it.label) card.append(el('div', { class: 'kpi-label' }, it.label));
|
|
1704
|
+
card.append(el('div', { class: 'kpi-value' }, it.value || ''));
|
|
1705
|
+
if (it.delta) {
|
|
1706
|
+
const glyph = it.dir === 'up' ? '▲ ' : it.dir === 'down' ? '▼ ' : it.dir === 'flat' ? '▬ ' : '';
|
|
1707
|
+
card.append(el('div', { class: 'kpi-delta' + (it.dir ? ' kpi-' + it.dir : '') }, glyph + it.delta));
|
|
1708
|
+
}
|
|
1709
|
+
if (it.sub) card.append(el('div', { class: 'kpi-sub' }, it.sub));
|
|
1710
|
+
if (ctx.annotate) {
|
|
1711
|
+
ctx.annotate.register(card, {
|
|
1712
|
+
blockId, questionId: ctx.questionId,
|
|
1713
|
+
target: { kind: 'html-element', label: ((it.label ? it.label + ' · ' : '') + (it.value || 'KPI')) },
|
|
1714
|
+
});
|
|
1715
|
+
}
|
|
1716
|
+
grid.append(card);
|
|
1717
|
+
}
|
|
1718
|
+
wrap.append(grid);
|
|
1719
|
+
return wrap;
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
// ---------- typography (type specimens) ----------
|
|
1723
|
+
function renderTypography(block, ctx, blockId) {
|
|
1724
|
+
const wrap = el('div', { class: 'blk-typography' });
|
|
1725
|
+
if (block.title) wrap.append(el('div', { class: 'typo-title' }, block.title));
|
|
1726
|
+
for (const s of block.specimens || []) {
|
|
1727
|
+
const meta = [];
|
|
1728
|
+
if (s.label) meta.push(s.label);
|
|
1729
|
+
if (s.size) meta.push(s.size);
|
|
1730
|
+
if (s.weight) meta.push('w' + s.weight);
|
|
1731
|
+
const sample = el('div', { class: 'typo-sample' }, s.text);
|
|
1732
|
+
// set via DOM style props (browser ignores invalid values — no injection)
|
|
1733
|
+
if (s.size) sample.style.fontSize = s.size;
|
|
1734
|
+
if (s.weight) sample.style.fontWeight = s.weight;
|
|
1735
|
+
if (s.font || block.font) sample.style.fontFamily = s.font || block.font;
|
|
1736
|
+
if (s.lineHeight) sample.style.lineHeight = s.lineHeight;
|
|
1737
|
+
if (s.letterSpacing) sample.style.letterSpacing = s.letterSpacing;
|
|
1738
|
+
wrap.append(el('div', { class: 'typo-row' },
|
|
1739
|
+
meta.length ? el('div', { class: 'typo-meta' }, meta.join(' · ')) : null,
|
|
1740
|
+
sample
|
|
1741
|
+
));
|
|
1742
|
+
}
|
|
1743
|
+
ctx.annotate && ctx.annotate.enableTextSelection(wrap, { blockId, questionId: ctx.questionId });
|
|
1744
|
+
return wrap;
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1747
|
+
// ---------- compare (before/after image slider) ----------
|
|
1748
|
+
// The "after" image is the base layer (defines the frame size); the "before"
|
|
1749
|
+
// image sits in an overflow-clipped layer whose width the divider drives, so
|
|
1750
|
+
// dragging reveals more of one image and less of the other.
|
|
1751
|
+
function renderCompare(block, ctx, blockId) {
|
|
1752
|
+
const wrap = el('div', { class: 'blk-comparewrap' });
|
|
1753
|
+
const frame = el('div', { class: 'cmp-frame' });
|
|
1754
|
+
if (block.height) frame.style.maxHeight = clampHeight(block.height, 1200) + 'px';
|
|
1755
|
+
const afterImg = el('img', { class: 'cmp-img', src: block.after, alt: block.afterLabel || 'after', draggable: 'false', loading: 'lazy' });
|
|
1756
|
+
const beforeImg = el('img', { class: 'cmp-img cmp-img-before', src: block.before, alt: block.beforeLabel || 'before', draggable: 'false', loading: 'lazy' });
|
|
1757
|
+
const beforeLayer = el('div', { class: 'cmp-before' }, beforeImg);
|
|
1758
|
+
const handle = el('div', { class: 'cmp-handle', tabindex: '0', role: 'slider', 'aria-label': 'Compare before and after' },
|
|
1759
|
+
el('div', { class: 'cmp-handle-grip' }, '⟺'));
|
|
1760
|
+
frame.append(
|
|
1761
|
+
afterImg, beforeLayer, handle,
|
|
1762
|
+
el('span', { class: 'cmp-tag cmp-tag-before' }, block.beforeLabel || 'Before'),
|
|
1763
|
+
el('span', { class: 'cmp-tag cmp-tag-after' }, block.afterLabel || 'After')
|
|
1764
|
+
);
|
|
1765
|
+
wrap.append(frame);
|
|
1766
|
+
|
|
1767
|
+
let pos = 50;
|
|
1768
|
+
const apply = () => { beforeLayer.style.width = pos + '%'; handle.style.left = pos + '%'; };
|
|
1769
|
+
// pin the before image to the frame's pixel width so the clip reveals it in
|
|
1770
|
+
// place (rather than squishing it as the clip narrows)
|
|
1771
|
+
const sizeBefore = () => { beforeImg.style.width = frame.clientWidth + 'px'; };
|
|
1772
|
+
const refresh = () => { sizeBefore(); apply(); };
|
|
1773
|
+
if (afterImg.complete && afterImg.naturalWidth) refresh();
|
|
1774
|
+
else afterImg.addEventListener('load', refresh, { once: true });
|
|
1775
|
+
window.addEventListener('resize', sizeBefore);
|
|
1776
|
+
|
|
1777
|
+
let dragging = false;
|
|
1778
|
+
const setFromX = (clientX) => {
|
|
1779
|
+
const r = frame.getBoundingClientRect();
|
|
1780
|
+
if (r.width) { pos = Math.max(0, Math.min(100, ((clientX - r.left) / r.width) * 100)); apply(); }
|
|
1781
|
+
};
|
|
1782
|
+
frame.addEventListener('pointerdown', (e) => { dragging = true; setFromX(e.clientX); try { frame.setPointerCapture(e.pointerId); } catch (_) {} e.preventDefault(); });
|
|
1783
|
+
frame.addEventListener('pointermove', (e) => { if (dragging) setFromX(e.clientX); });
|
|
1784
|
+
frame.addEventListener('pointerup', () => { dragging = false; });
|
|
1785
|
+
frame.addEventListener('pointercancel', () => { dragging = false; });
|
|
1786
|
+
handle.addEventListener('keydown', (e) => {
|
|
1787
|
+
if (e.key === 'ArrowLeft') { pos = Math.max(0, pos - 2); apply(); e.preventDefault(); }
|
|
1788
|
+
else if (e.key === 'ArrowRight') { pos = Math.min(100, pos + 2); apply(); e.preventDefault(); }
|
|
1789
|
+
});
|
|
1790
|
+
attachViewer(wrap, { zoomEl: null, label: 'comparison', comment: wholeBlockComment(ctx, blockId, 'comparison') });
|
|
1791
|
+
return wrap;
|
|
1792
|
+
}
|
|
1793
|
+
|
|
1491
1794
|
// ---------- viewer controls (zoom / fit / full-screen) ----------
|
|
1492
1795
|
// Large diagrams get squeezed to the column width; these controls let the
|
|
1493
1796
|
// user zoom (buttons or cmd/ctrl+wheel) and expand any visual block into a
|
|
@@ -1525,6 +1828,91 @@
|
|
|
1525
1828
|
if (e.key === 'Escape' && fullOpen) exitFull();
|
|
1526
1829
|
});
|
|
1527
1830
|
|
|
1831
|
+
// Enter full-screen for a viewer container (shared by the toolbar button and
|
|
1832
|
+
// the markdown reference-link → modal path).
|
|
1833
|
+
function enterFull(container) {
|
|
1834
|
+
if (fullOpen === container) return;
|
|
1835
|
+
exitFull();
|
|
1836
|
+
container.classList.add('blk-full');
|
|
1837
|
+
document.body.classList.add('blk-full-open');
|
|
1838
|
+
fullOpen = container;
|
|
1839
|
+
const btn = container.querySelector('.blk-tools .tool-full');
|
|
1840
|
+
if (btn) btn.textContent = '✕';
|
|
1841
|
+
window.dispatchEvent(new Event('resize'));
|
|
1842
|
+
if (container._rlyToolsSync) container._rlyToolsSync();
|
|
1843
|
+
if (container._rlyZoom) container._rlyZoom.reapply();
|
|
1844
|
+
}
|
|
1845
|
+
|
|
1846
|
+
// CSS.escape fallback for attribute-selector building from a block id/ref.
|
|
1847
|
+
function cssEsc(s) {
|
|
1848
|
+
return window.CSS && CSS.escape ? CSS.escape(String(s)) : String(s).replace(/["\\\]]/g, '\\$&');
|
|
1849
|
+
}
|
|
1850
|
+
|
|
1851
|
+
// Open a block as a modal: full-screen its viewer if it has one (chart, image,
|
|
1852
|
+
// diagram, table, compare, code…); otherwise scroll to it and flash. `host` is
|
|
1853
|
+
// the .blk[data-block-id] wrapper. Returns true if it found something to show.
|
|
1854
|
+
function openFullEl(host) {
|
|
1855
|
+
if (!host) return false;
|
|
1856
|
+
const viewer = host.classList.contains('blk-viewer') ? host : host.querySelector('.blk-viewer');
|
|
1857
|
+
// Visuals open as a full-screen modal — context-safe (an overlay; × returns
|
|
1858
|
+
// to the exact spot). Non-modal blocks (kpi/markdown/table-less) have no
|
|
1859
|
+
// viewer, so we jump + highlight AND show a "↩ Back" button so the user can
|
|
1860
|
+
// return after the jump — no lost context across the data↔question distance.
|
|
1861
|
+
if (viewer && viewer._rlyTools) { enterFull(viewer); return true; }
|
|
1862
|
+
const fromY = window.scrollY || window.pageYOffset || 0;
|
|
1863
|
+
host.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
1864
|
+
host.classList.remove('rly-refflash');
|
|
1865
|
+
void host.offsetWidth;
|
|
1866
|
+
host.classList.add('rly-refflash');
|
|
1867
|
+
setTimeout(() => host.classList.remove('rly-refflash'), 1200);
|
|
1868
|
+
showRefBack(fromY);
|
|
1869
|
+
return true;
|
|
1870
|
+
}
|
|
1871
|
+
|
|
1872
|
+
// A floating "↩ Back" pill that returns to the pre-jump scroll position. Self-
|
|
1873
|
+
// removes once the user is back near where they started, or after a timeout.
|
|
1874
|
+
let refBackBtn = null;
|
|
1875
|
+
function showRefBack(fromY) {
|
|
1876
|
+
if (refBackBtn) refBackBtn.remove();
|
|
1877
|
+
const btn = el('button', { class: 'rly-refback', type: 'button', title: 'Back to where you were' }, '↩ Back');
|
|
1878
|
+
const cleanup = () => { btn.remove(); window.removeEventListener('scroll', onScroll); if (refBackBtn === btn) refBackBtn = null; };
|
|
1879
|
+
const onScroll = () => { if (Math.abs((window.scrollY || 0) - fromY) < 120) cleanup(); };
|
|
1880
|
+
btn.addEventListener('click', () => { window.scrollTo({ top: fromY, behavior: 'smooth' }); cleanup(); });
|
|
1881
|
+
window.addEventListener('scroll', onScroll, { passive: true });
|
|
1882
|
+
document.body.append(btn);
|
|
1883
|
+
refBackBtn = btn;
|
|
1884
|
+
setTimeout(() => { if (refBackBtn === btn) cleanup(); }, 12000);
|
|
1885
|
+
}
|
|
1886
|
+
function openFull(blockId) {
|
|
1887
|
+
return openFullEl(document.querySelector('[data-block-id="' + cssEsc(blockId) + '"]'));
|
|
1888
|
+
}
|
|
1889
|
+
|
|
1890
|
+
// Delegated handler for markdown reference links (data-rly-ref / data-rly-refid).
|
|
1891
|
+
function initRefLinks() {
|
|
1892
|
+
if (window.__relayRefLinksReady) return;
|
|
1893
|
+
window.__relayRefLinksReady = true;
|
|
1894
|
+
const open = (a) => {
|
|
1895
|
+
const host = a.dataset.rlyRefid
|
|
1896
|
+
? document.querySelector('[data-block-id="' + cssEsc(a.dataset.rlyRefid) + '"]')
|
|
1897
|
+
: document.querySelector('[data-block-ref="' + cssEsc(a.dataset.rlyRef) + '"]');
|
|
1898
|
+
if (host) openFullEl(host);
|
|
1899
|
+
else fileToast('Referenced visual not found on this board', 'err');
|
|
1900
|
+
};
|
|
1901
|
+
document.addEventListener('click', (e) => {
|
|
1902
|
+
const a = e.target.closest ? e.target.closest('a.rly-reflink') : null;
|
|
1903
|
+
if (!a) return;
|
|
1904
|
+
e.preventDefault();
|
|
1905
|
+
open(a);
|
|
1906
|
+
});
|
|
1907
|
+
document.addEventListener('keydown', (e) => {
|
|
1908
|
+
if (e.key !== 'Enter' && e.key !== ' ' && e.key !== 'Spacebar') return;
|
|
1909
|
+
const a = e.target.closest ? e.target.closest('a.rly-reflink') : null;
|
|
1910
|
+
if (!a) return;
|
|
1911
|
+
e.preventDefault();
|
|
1912
|
+
open(a);
|
|
1913
|
+
});
|
|
1914
|
+
}
|
|
1915
|
+
|
|
1528
1916
|
// Drag-to-pan: grab anywhere on a scrollable visual (a tall/zoomed diagram or
|
|
1529
1917
|
// oversized image) and drag to move across it, instead of hunting for the
|
|
1530
1918
|
// scrollbars. Self-gates on overflow, so charts and html iframes — which are
|
|
@@ -1651,7 +2039,9 @@
|
|
|
1651
2039
|
// (contain — enlarge a small one, shrink a big one) so it's readable at
|
|
1652
2040
|
// a glance without reaching for the zoom buttons. CSS margin:auto centers
|
|
1653
2041
|
// it; it stays pannable once the user zooms past this fit.
|
|
1654
|
-
|
|
2042
|
+
// subtract the docked comment rail (≥980px) so the fit doesn't run under it
|
|
2043
|
+
const railInset = (document.body.classList.contains('ann-rail-open') && window.innerWidth >= 980) ? 340 : 0;
|
|
2044
|
+
const availW = window.innerWidth - railInset - FULL_PAD_X * 2;
|
|
1655
2045
|
const availH = window.innerHeight - FULL_PAD_TOP - FULL_PAD_BOTTOM;
|
|
1656
2046
|
const scale = Math.min(availW / nat.w, availH / nat.h);
|
|
1657
2047
|
target.style.maxWidth = 'none';
|
|
@@ -1733,15 +2123,8 @@
|
|
|
1733
2123
|
fullBtn.innerHTML = ICON_EXPAND;
|
|
1734
2124
|
fullBtn.addEventListener('click', (e) => {
|
|
1735
2125
|
e.stopPropagation();
|
|
1736
|
-
if (fullOpen === container)
|
|
1737
|
-
|
|
1738
|
-
container.classList.add('blk-full');
|
|
1739
|
-
document.body.classList.add('blk-full-open');
|
|
1740
|
-
fullOpen = container;
|
|
1741
|
-
fullBtn.textContent = '✕';
|
|
1742
|
-
window.dispatchEvent(new Event('resize'));
|
|
1743
|
-
container._rlyToolsSync();
|
|
1744
|
-
if (container._rlyZoom) container._rlyZoom.reapply(); // lift any inline height cap
|
|
2126
|
+
if (fullOpen === container) exitFull();
|
|
2127
|
+
else enterFull(container);
|
|
1745
2128
|
});
|
|
1746
2129
|
tools.append(fullBtn);
|
|
1747
2130
|
|
|
@@ -1782,6 +2165,7 @@
|
|
|
1782
2165
|
function renderBlock(block, ctx) {
|
|
1783
2166
|
const blockId = block.id;
|
|
1784
2167
|
const wrapper = el('div', { class: 'blk blk-' + block.type, 'data-block-id': blockId });
|
|
2168
|
+
if (block.ref) wrapper.setAttribute('data-block-ref', block.ref);
|
|
1785
2169
|
let inner = null;
|
|
1786
2170
|
switch (block.type) {
|
|
1787
2171
|
case 'markdown': {
|
|
@@ -1834,6 +2218,18 @@
|
|
|
1834
2218
|
inner = renderPalette(block, ctx, blockId);
|
|
1835
2219
|
wrapper.append(inner);
|
|
1836
2220
|
break;
|
|
2221
|
+
case 'kpi':
|
|
2222
|
+
inner = renderKpi(block, ctx, blockId);
|
|
2223
|
+
wrapper.append(inner);
|
|
2224
|
+
break;
|
|
2225
|
+
case 'typography':
|
|
2226
|
+
inner = renderTypography(block, ctx, blockId);
|
|
2227
|
+
wrapper.append(inner);
|
|
2228
|
+
break;
|
|
2229
|
+
case 'compare':
|
|
2230
|
+
inner = renderCompare(block, ctx, blockId);
|
|
2231
|
+
wrapper.append(inner);
|
|
2232
|
+
break;
|
|
1837
2233
|
default:
|
|
1838
2234
|
wrapper.append(el('div', { class: 'blk-error' }, 'Unknown block type: ' + esc(String(block.type))));
|
|
1839
2235
|
}
|
|
@@ -1919,6 +2315,7 @@
|
|
|
1919
2315
|
});
|
|
1920
2316
|
}
|
|
1921
2317
|
initFileLinks();
|
|
2318
|
+
initRefLinks();
|
|
1922
2319
|
|
|
1923
|
-
window.RelayBlocks = { render, onThemeChange, renderMarkdown };
|
|
2320
|
+
window.RelayBlocks = { render, onThemeChange, renderMarkdown, openFull };
|
|
1924
2321
|
})();
|