@khanglvm/relay 0.5.4 → 0.7.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/src/ui/kit.js CHANGED
@@ -32,13 +32,26 @@
32
32
  * sortable: click column header to sort ascending/descending.
33
33
  *
34
34
  * relayKit.commentable(el, label, detail?)
35
- * Hover: 1px dashed accent outline + cursor pointer + title "add a comment".
36
- * Click: parent.postMessage({relay:'annotate-request', label, detail}, '*').
35
+ * Make one element commentable: hover shows a comment pin, a per-element
36
+ * badge counts comments, click opens the board's annotation popover anchored
37
+ * to the element. Thin shim over relayKit.annotate.register().
38
+ *
39
+ * relayKit.annotate.auto()
40
+ * Injected automatically by the server into every custom-HTML iframe. With no
41
+ * explicit signals it makes a sensible set of content/interactive elements
42
+ * hover-commentable so the user can annotate ANY meaningful part of the HTML.
43
+ * If the author marks elements with data-relay-annotate[="Label"] (+ optional
44
+ * data-relay-detail) or calls commentable(), auto-mode backs off to just those.
45
+ * Opt out with data-relay-annotate="off" on <html> or <body>.
37
46
  */
38
47
 
39
48
  (function () {
40
49
  'use strict';
41
50
 
51
+ // Idempotent: the server may inject a /kit.js load on top of one the author
52
+ // already added. First load wins; a second execution no-ops.
53
+ if (window.relayKit) return;
54
+
42
55
  // ---------------------------------------------------------------------------
43
56
  // Theme
44
57
  // ---------------------------------------------------------------------------
@@ -357,39 +370,333 @@
357
370
  }
358
371
 
359
372
  // ---------------------------------------------------------------------------
360
- // commentable()
373
+ // annotate — element-level commenting inside the sandboxed iframe
361
374
  // ---------------------------------------------------------------------------
375
+ // The board's annotation engine lives in the PARENT page and can't reach into
376
+ // this cross-origin iframe (sandbox without allow-same-origin), so everything
377
+ // here runs iframe-side and talks to the parent over postMessage:
378
+ // iframe → parent {relay:'annotate-ready'} request counts
379
+ // iframe → parent {relay:'annotate-request', ref, label, detail?, rect}
380
+ // parent → iframe {relay:'annotate-counts', counts:{ref:n}} draw badges
381
+ //
382
+ // `ref` is a compact, reload-stable CSS path used as the element's identity so
383
+ // comments re-bind and badges count per element.
384
+
385
+ const annotate = (() => {
386
+ // Auto-mode picks anything the user would plausibly point at: every element
387
+ // with its OWN direct text (so div/span-based mockups work, not just
388
+ // semantic tags), plus meaningful leaves (icons, buttons, media) and a few
389
+ // semantic containers worth commenting whole. closest() resolves overlaps to
390
+ // the innermost target on hover.
391
+ const LEAF_TAGS = /^(IMG|BUTTON|A|INPUT|SELECT|TEXTAREA|svg|VIDEO|CANVAS|SUMMARY)$/;
392
+ const SKIP_TAGS = /^(SCRIPT|STYLE|HEAD|META|LINK|TITLE|BASE|NOSCRIPT|TEMPLATE)$/;
393
+ const CONTAINER_SELECTOR =
394
+ 'li,td,th,figure,blockquote,article,.card,[role="button"],[role="listitem"],[role="option"]';
395
+ const MAX_AUTO = 400;
396
+ // Replaced elements that can't host a badge child — overlay the badge instead.
397
+ const NO_CHILD = /^(IMG|INPUT|HR|BR|EMBED|CANVAS|VIDEO|svg)$/;
398
+
399
+ const byRef = new Map(); // ref -> element
400
+ let mode = null; // 'auto' | 'explicit' (decided on first scan)
401
+ let started = false;
402
+ let hot = null; // currently outlined element
403
+ let pin = null; // floating pin button
404
+ let hideTimer = 0;
405
+ let lastCounts = {};
406
+ let overlayBadges = []; // [{el, badge}] for replaced elements
407
+ let repoTimer = 0;
408
+
409
+ const accent = () => theme.colors.accent;
410
+
411
+ // Compact, reload-stable CSS path — the element's identity.
412
+ function refOf(el) {
413
+ if (el.__relayRef) return el.__relayRef;
414
+ const parts = [];
415
+ let n = el;
416
+ while (n && n.nodeType === 1 && n !== document.body && parts.length < 10) {
417
+ let seg = n.tagName.toLowerCase();
418
+ const p = n.parentNode;
419
+ if (p && p.children) {
420
+ const sibs = Array.prototype.filter.call(p.children, (c) => c.tagName === n.tagName);
421
+ if (sibs.length > 1) seg += ':nth-of-type(' + (sibs.indexOf(n) + 1) + ')';
422
+ }
423
+ parts.unshift(seg);
424
+ n = p;
425
+ }
426
+ const ref = parts.join('>') || el.tagName.toLowerCase();
427
+ el.__relayRef = ref;
428
+ return ref;
429
+ }
362
430
 
363
- function commentable(el, label, detail) {
364
- try {
365
- const lStr = String(label || '').slice(0, 200);
366
- const dStr = detail != null ? String(detail).slice(0, 500) : undefined;
367
- const accent = theme.colors.accent;
431
+ // Human label when the author didn't supply one.
432
+ function labelOf(el) {
433
+ const aria = el.getAttribute && el.getAttribute('aria-label');
434
+ if (aria) return aria.trim().slice(0, 80) || el.tagName.toLowerCase();
435
+ if (el.tagName === 'IMG') return (el.getAttribute('alt') || 'Image').trim().slice(0, 80) || 'Image';
436
+ const txt = (el.textContent || '').replace(/\s+/g, ' ').trim();
437
+ return txt ? txt.slice(0, 80) : el.tagName.toLowerCase();
438
+ }
368
439
 
369
- el.style.cursor = 'pointer';
370
- el.title = 'add a comment';
440
+ function injectStyle() {
441
+ if (document.getElementById('relay-ann-style')) return;
442
+ const s = document.createElement('style');
443
+ s.id = 'relay-ann-style';
444
+ s.textContent = [
445
+ '.relay-ann-hot{outline:2px solid ' + accent() + ' !important;outline-offset:1px !important;}',
446
+ '.relay-ann-badge{position:absolute;top:-7px;right:-7px;min-width:16px;height:16px;padding:0 4px;' +
447
+ 'box-sizing:border-box;border-radius:9px;background:' + accent() + ';color:#fff;font:600 10px/16px ' + SANS + ';' +
448
+ 'text-align:center;z-index:2147483646;cursor:pointer;box-shadow:0 1px 2px rgba(0,0,0,.25);}',
449
+ ].join('\n');
450
+ (document.head || document.documentElement).appendChild(s);
451
+ }
371
452
 
372
- el.addEventListener('mouseenter', () => {
373
- el.style.outline = `1px dashed ${accent}`;
374
- el.style.outlineOffset = '2px';
375
- });
376
- el.addEventListener('mouseleave', () => {
377
- el.style.outline = '';
378
- el.style.outlineOffset = '';
379
- });
380
- el.addEventListener('click', (e) => {
453
+ function ensurePin() {
454
+ if (pin || !document.body) return pin;
455
+ pin = document.createElement('button');
456
+ pin.type = 'button';
457
+ pin.setAttribute('aria-label', 'Add a comment');
458
+ pin.title = 'Add a comment';
459
+ pin.style.cssText = [
460
+ 'position:fixed', 'z-index:2147483647', 'display:none', 'width:22px', 'height:22px',
461
+ 'padding:0', 'border:none', 'border-radius:50%', 'cursor:pointer', 'background:' + accent(),
462
+ 'color:#fff', 'align-items:center', 'justify-content:center', 'line-height:0',
463
+ 'box-shadow:0 1px 4px rgba(0,0,0,.3)',
464
+ ].join(';');
465
+ pin.innerHTML =
466
+ '<svg viewBox="0 0 16 16" width="11" height="11" aria-hidden="true">' +
467
+ '<path d="M3 2.5h10A1.5 1.5 0 0 1 14.5 4v5a1.5 1.5 0 0 1-1.5 1.5H8.4L5 13.4v-2.9H3A1.5 1.5 0 0 1 1.5 9V4A1.5 1.5 0 0 1 3 2.5Z" fill="currentColor"/></svg>';
468
+ pin.addEventListener('mouseenter', () => clearTimeout(hideTimer));
469
+ pin.addEventListener('mouseleave', scheduleHide);
470
+ pin.addEventListener('click', (e) => {
471
+ e.preventDefault();
381
472
  e.stopPropagation();
382
- const msg = { relay: 'annotate-request', label: lStr };
383
- if (dStr !== undefined) msg.detail = dStr;
384
- try {
385
- parent.postMessage(msg, '*');
386
- } catch (postErr) {
387
- console.warn('[relayKit] commentable postMessage error:', postErr);
473
+ if (hot) request(hot);
474
+ hidePin();
475
+ });
476
+ document.body.appendChild(pin);
477
+ return pin;
478
+ }
479
+
480
+ function showPinFor(el) {
481
+ if (!ensurePin()) return;
482
+ clearTimeout(hideTimer);
483
+ if (hot && hot !== el) hot.classList.remove('relay-ann-hot');
484
+ hot = el;
485
+ el.classList.add('relay-ann-hot');
486
+ const r = el.getBoundingClientRect();
487
+ const w = 22;
488
+ pin.style.display = 'flex';
489
+ pin.style.left = Math.max(2, Math.min(r.right - w / 2, window.innerWidth - w - 2)) + 'px';
490
+ pin.style.top = Math.max(2, Math.min(r.top - w / 2, window.innerHeight - w - 2)) + 'px';
491
+ }
492
+
493
+ function scheduleHide() {
494
+ clearTimeout(hideTimer);
495
+ hideTimer = setTimeout(hidePin, 220); // grace so the pin itself stays clickable
496
+ }
497
+
498
+ function hidePin() {
499
+ clearTimeout(hideTimer);
500
+ if (hot) hot.classList.remove('relay-ann-hot');
501
+ hot = null;
502
+ if (pin) pin.style.display = 'none';
503
+ }
504
+
505
+ // Ask the parent to open its annotation popover for this element.
506
+ function request(el) {
507
+ const r = el.getBoundingClientRect();
508
+ const msg = {
509
+ relay: 'annotate-request',
510
+ ref: refOf(el),
511
+ label: String(el.__relayLabel || labelOf(el)).slice(0, 200),
512
+ rect: { left: r.left, top: r.top, width: r.width, height: r.height },
513
+ };
514
+ if (el.__relayDetail != null) msg.detail = String(el.__relayDetail).slice(0, 500);
515
+ try { parent.postMessage(msg, '*'); } catch (_) {}
516
+ }
517
+
518
+ function positionOverlay(el, badge) {
519
+ const r = el.getBoundingClientRect();
520
+ badge.style.left = (r.right + window.scrollX - 9) + 'px';
521
+ badge.style.top = (r.top + window.scrollY - 7) + 'px';
522
+ badge.style.right = 'auto';
523
+ }
524
+
525
+ function repositionOverlays() {
526
+ for (const o of overlayBadges) if (o.el.isConnected) positionOverlay(o.el, o.badge);
527
+ }
528
+
529
+ function renderBadges(counts) {
530
+ lastCounts = counts || {};
531
+ for (const b of document.querySelectorAll('.relay-ann-badge')) b.remove();
532
+ overlayBadges = [];
533
+ for (const ref of Object.keys(lastCounts)) {
534
+ const n = lastCounts[ref];
535
+ if (!n) continue;
536
+ const el = byRef.get(ref);
537
+ if (!el || !el.isConnected) continue;
538
+ const badge = document.createElement('span');
539
+ badge.className = 'relay-ann-badge';
540
+ badge.textContent = String(n);
541
+ badge.title = n + (n === 1 ? ' comment' : ' comments');
542
+ badge.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); request(el); });
543
+ if (NO_CHILD.test(el.tagName)) {
544
+ badge.style.position = 'absolute';
545
+ document.body.appendChild(badge);
546
+ overlayBadges.push({ el, badge });
547
+ positionOverlay(el, badge);
548
+ } else {
549
+ if (getComputedStyle(el).position === 'static') el.style.position = 'relative';
550
+ el.appendChild(badge);
388
551
  }
552
+ }
553
+ }
554
+
555
+ function add(el, label, detail) {
556
+ if (!el || el.nodeType !== 1) return;
557
+ byRef.set(refOf(el), el);
558
+ el.classList.add('relay-annotatable');
559
+ if (label != null && label !== '') el.__relayLabel = String(label);
560
+ if (detail != null) el.__relayDetail = detail;
561
+ }
562
+
563
+ // True when the element holds non-whitespace text of its OWN (a direct text
564
+ // node), not just text inherited from descendants.
565
+ function hasDirectText(el) {
566
+ for (const n of el.childNodes) {
567
+ if (n.nodeType === 3 && n.nodeValue && n.nodeValue.trim()) return true;
568
+ }
569
+ return false;
570
+ }
571
+
572
+ function autoPick() {
573
+ let all;
574
+ try { all = document.body.querySelectorAll('*'); } catch (_) { return []; }
575
+ let containers;
576
+ try { containers = new Set(document.querySelectorAll(CONTAINER_SELECTOR)); } catch (_) { containers = new Set(); }
577
+ const out = [];
578
+ for (const el of all) {
579
+ if (out.length >= MAX_AUTO) break;
580
+ if (SKIP_TAGS.test(el.tagName)) continue;
581
+ if (pin && pin.contains(el)) continue; // our own pin
582
+ if (el.classList && el.classList.contains('relay-ann-badge')) continue;
583
+ if (LEAF_TAGS.test(el.tagName) || containers.has(el) || hasDirectText(el)) out.push(el);
584
+ }
585
+ return out;
586
+ }
587
+
588
+ function scan() {
589
+ const signalled = Array.prototype.filter.call(
590
+ document.querySelectorAll('[data-relay-annotate]'),
591
+ (el) => el.getAttribute('data-relay-annotate') !== 'off'
592
+ );
593
+ // ANY explicit signal (attribute or a prior commentable() call) → the
594
+ // author scopes what's annotatable; auto-mode stays off.
595
+ if (mode === null) mode = signalled.length > 0 || byRef.size > 0 ? 'explicit' : 'auto';
596
+ if (mode === 'explicit') {
597
+ for (const el of signalled) add(el, el.getAttribute('data-relay-annotate') || null, el.getAttribute('data-relay-detail'));
598
+ return;
599
+ }
600
+ for (const el of autoPick()) add(el, null, undefined);
601
+ }
602
+
603
+ function optedOut() {
604
+ const v = (document.body && document.body.getAttribute('data-relay-annotate')) ||
605
+ document.documentElement.getAttribute('data-relay-annotate');
606
+ return v === 'off';
607
+ }
608
+
609
+ function announce() {
610
+ try { parent.postMessage({ relay: 'annotate-ready' }, '*'); } catch (_) {}
611
+ }
612
+
613
+ function start() {
614
+ if (started) return;
615
+ if (!document.body) { document.addEventListener('DOMContentLoaded', start, { once: true }); return; }
616
+ started = true;
617
+ injectStyle();
618
+ ensurePin();
619
+ // Delegated hover — closest() resolves nested targets to the innermost.
620
+ document.addEventListener('mouseover', (e) => {
621
+ const t = e.target && e.target.closest && e.target.closest('.relay-annotatable');
622
+ if (t) showPinFor(t);
623
+ }, true);
624
+ document.addEventListener('mouseout', (e) => {
625
+ const t = e.target && e.target.closest && e.target.closest('.relay-annotatable');
626
+ if (t) scheduleHide();
627
+ }, true);
628
+ window.addEventListener('scroll', () => {
629
+ hidePin();
630
+ if (overlayBadges.length) { cancelAnimationFrame(repoTimer); repoTimer = requestAnimationFrame(repositionOverlays); }
631
+ }, true);
632
+ window.addEventListener('resize', () => { hidePin(); repositionOverlays(); });
633
+ window.addEventListener('message', (e) => {
634
+ const m = e.data;
635
+ if (m && typeof m === 'object' && m.relay === 'annotate-counts') renderBadges(m.counts || {});
389
636
  });
390
- } catch (err) {
391
- console.warn('[relayKit] commentable error:', err);
637
+ // Re-pick up DOM that author JS builds after load. We observe childList
638
+ // only (not attributes, so our own hover-class toggles don't fire it) and
639
+ // ignore mutations that only touch our own pin/badge nodes — otherwise
640
+ // rendering a badge would re-trigger the observer in a tight loop.
641
+ try {
642
+ const ours = (n) =>
643
+ n.nodeType === 1 &&
644
+ (n === pin || (pin && pin.contains(n)) || n.id === 'relay-ann-style' ||
645
+ (n.classList && n.classList.contains('relay-ann-badge')));
646
+ const mo = new MutationObserver((muts) => {
647
+ let relevant = false;
648
+ for (const m of muts) {
649
+ for (const n of m.addedNodes) if (!ours(n)) { relevant = true; break; }
650
+ if (relevant) break;
651
+ for (const n of m.removedNodes) if (!ours(n)) { relevant = true; break; }
652
+ if (relevant) break;
653
+ }
654
+ if (!relevant) return;
655
+ clearTimeout(mo._t);
656
+ mo._t = setTimeout(() => { scan(); renderBadges(lastCounts); announce(); }, 250);
657
+ });
658
+ mo.observe(document.body, { childList: true, subtree: true });
659
+ } catch (_) {}
660
+ announce();
661
+ }
662
+
663
+ // Server-injected entrypoint. Idempotent.
664
+ function auto() {
665
+ try {
666
+ if (optedOut()) return;
667
+ if (!document.body) { document.addEventListener('DOMContentLoaded', auto, { once: true }); return; }
668
+ scan();
669
+ start();
670
+ } catch (err) {
671
+ console.warn('[relayKit] annotate.auto error:', err);
672
+ }
673
+ }
674
+
675
+ // Explicit per-element registration (also used by commentable()).
676
+ function register(el, label, detail) {
677
+ try {
678
+ add(el, label, detail);
679
+ if (started) renderBadges(lastCounts);
680
+ else start();
681
+ } catch (err) {
682
+ console.warn('[relayKit] annotate.register error:', err);
683
+ }
392
684
  }
685
+
686
+ return { auto, register };
687
+ })();
688
+
689
+ // ---------------------------------------------------------------------------
690
+ // commentable() — back-compat shim over annotate.register()
691
+ // ---------------------------------------------------------------------------
692
+
693
+ function commentable(el, label, detail) {
694
+ if (!el) return;
695
+ annotate.register(
696
+ el,
697
+ label != null ? String(label).slice(0, 200) : null,
698
+ detail != null ? String(detail).slice(0, 500) : undefined
699
+ );
393
700
  }
394
701
 
395
702
  // ---------------------------------------------------------------------------
@@ -403,6 +710,7 @@
403
710
  mermaid,
404
711
  table,
405
712
  commentable,
713
+ annotate,
406
714
  };
407
715
 
408
716
  window.relayKit = relayKit;
package/src/ui/style.css CHANGED
@@ -73,7 +73,7 @@ body {
73
73
  color: var(--fg);
74
74
  font: 16px/1.6 var(--sans);
75
75
  -webkit-font-smoothing: antialiased;
76
- transition: background 200ms var(--ease), color 200ms var(--ease);
76
+ transition: background 200ms var(--ease), color 200ms var(--ease), padding 240ms var(--ease);
77
77
  }
78
78
  .wrap { max-width: 860px; margin: 0 auto; padding: 32px 20px 28px; }
79
79
 
@@ -193,7 +193,7 @@ input[type="text"]:focus, textarea:focus {
193
193
  textarea { min-height: 90px; resize: vertical; }
194
194
 
195
195
  .qnotewrap { margin-top: 10px; }
196
- .qnotewrap .qnote { font-size: 0.88rem; padding: 8px 12px; }
196
+ .qnotewrap .qnote { font-size: 0.88rem; padding: 8px 12px; min-height: 52px; resize: vertical; }
197
197
 
198
198
  .errmsg { color: var(--danger); font-size: 0.85rem; margin: 8px 0 0; display: none; }
199
199
  .card.error .errmsg { display: block; }
@@ -220,8 +220,14 @@ textarea { min-height: 90px; resize: vertical; }
220
220
  position: sticky; top: 0;
221
221
  background: var(--danger); color: var(--danger-fg);
222
222
  text-align: center; padding: 9px 16px; font-size: 0.9rem;
223
+ line-height: 1.45;
223
224
  z-index: 10; display: none;
225
+ border-bottom: 1px solid transparent;
224
226
  }
227
+ /* Calm status notes (timeout hand-back / lost connection). Never the red error
228
+ state — the board stays usable, so these only inform. */
229
+ .banner.info { background: var(--accent-soft); color: var(--accent); }
230
+ .banner.warn { background: var(--bg-sunken); color: var(--fg-2); border-bottom-color: var(--border-strong); }
225
231
 
226
232
  /* Live-update toast: flagged after a `rly update` reload (see app.js).
227
233
  Fixed top-center, accent-soft bg, accent text; JS auto-removes it. */