@khanglvm/relay 0.2.0 → 0.4.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 +83 -5
- package/docs/AGENT.md +117 -4
- package/docs/assets/annotations.png +0 -0
- package/docs/assets/board-dark.png +0 -0
- package/docs/assets/board-light.png +0 -0
- package/docs/assets/demo.gif +0 -0
- package/docs/assets/mobile.png +0 -0
- package/package.json +1 -1
- package/skills/relay/SKILL.md +79 -24
- package/skills/relay/examples/blocks-showcase.json +9 -0
- package/skills/relay/examples/diagram-coedit.json +15 -0
- package/src/cli.js +287 -15
- package/src/server.js +148 -11
- package/src/spec.js +37 -4
- package/src/ui/annotate.css +57 -5
- package/src/ui/annotate.js +130 -5
- package/src/ui/app.js +117 -2
- package/src/ui/blocks.css +97 -0
- package/src/ui/blocks.js +281 -19
- package/src/ui/style.css +9 -0
- package/vendor/VERSIONS.json +2 -1
- package/vendor/viz-standalone.js +9 -0
package/src/ui/app.js
CHANGED
|
@@ -6,6 +6,10 @@
|
|
|
6
6
|
const QS = spec.questions || [];
|
|
7
7
|
const app = document.getElementById('app');
|
|
8
8
|
const banner = document.getElementById('banner');
|
|
9
|
+
// Live-update baseline: the server's rev at page-build time. The heartbeat
|
|
10
|
+
// compares /api/status.rev to this and reloads the board when it advances
|
|
11
|
+
// (an agent ran `rly update`).
|
|
12
|
+
const bootRev = typeof boot.rev === 'number' ? boot.rev : null;
|
|
9
13
|
|
|
10
14
|
// ---------- helpers ----------
|
|
11
15
|
function el(tag, attrs = {}, ...children) {
|
|
@@ -22,6 +26,14 @@
|
|
|
22
26
|
return n;
|
|
23
27
|
}
|
|
24
28
|
|
|
29
|
+
// Small, self-dismissing toast pinned to the top-center of the viewport.
|
|
30
|
+
// Used to flag a live `rly update` after the reload (accent-soft bg).
|
|
31
|
+
function showToast(message) {
|
|
32
|
+
const toast = el('div', { class: 'toast' }, message);
|
|
33
|
+
document.body.append(toast);
|
|
34
|
+
setTimeout(() => toast.remove(), 4000);
|
|
35
|
+
}
|
|
36
|
+
|
|
25
37
|
// ---------- theme (auto -> light -> dark) ----------
|
|
26
38
|
// Server-side pref wins: boards run on random ports, so localStorage alone
|
|
27
39
|
// can't carry the choice across boards. The server persists it globally.
|
|
@@ -70,6 +82,10 @@
|
|
|
70
82
|
notes: {},
|
|
71
83
|
comment: '',
|
|
72
84
|
annotations: (boot.prefill && boot.prefill.annotations) || [],
|
|
85
|
+
// Editable-mermaid edits: blockId -> edited source. Seeded from the live
|
|
86
|
+
// draft so a reload/reopen restores the user's edited diagram. Mutated via
|
|
87
|
+
// the blocks ctx.onBlockEdit callback below; returned in payload().
|
|
88
|
+
blockEdits: (boot.prefill && boot.prefill.blockEdits) || {},
|
|
73
89
|
};
|
|
74
90
|
let submitted = false;
|
|
75
91
|
|
|
@@ -135,7 +151,13 @@
|
|
|
135
151
|
const n = typeof state.notes[q.id] === 'string' ? state.notes[q.id].trim() : '';
|
|
136
152
|
if (n) notes[q.id] = n;
|
|
137
153
|
}
|
|
138
|
-
return {
|
|
154
|
+
return {
|
|
155
|
+
answers,
|
|
156
|
+
comment: (state.comment || '').trim(),
|
|
157
|
+
notes,
|
|
158
|
+
annotations: state.annotations,
|
|
159
|
+
blockEdits: state.blockEdits,
|
|
160
|
+
};
|
|
139
161
|
}
|
|
140
162
|
|
|
141
163
|
// ---------- real-time autosave ----------
|
|
@@ -162,6 +184,44 @@
|
|
|
162
184
|
}
|
|
163
185
|
}
|
|
164
186
|
|
|
187
|
+
// ---------- presence / awareness ----------
|
|
188
|
+
// Track the last time the user interacted with the page so the agent (via
|
|
189
|
+
// /api/presence) can tell whether someone is still actively viewing the board
|
|
190
|
+
// and keep waiting instead of timing out. Every existing 3s heartbeat tick
|
|
191
|
+
// also POSTs /api/ping {visible, focused, idleMs}; pinging stops after submit.
|
|
192
|
+
let lastInteractionAt = Date.now();
|
|
193
|
+
let lastMoveAt = 0;
|
|
194
|
+
function noteInteraction() {
|
|
195
|
+
lastInteractionAt = Date.now();
|
|
196
|
+
}
|
|
197
|
+
window.addEventListener('pointerdown', noteInteraction, { passive: true });
|
|
198
|
+
window.addEventListener('keydown', noteInteraction, { passive: true });
|
|
199
|
+
window.addEventListener('scroll', noteInteraction, { passive: true });
|
|
200
|
+
window.addEventListener('touchstart', noteInteraction, { passive: true });
|
|
201
|
+
// pointermove fires continuously — throttle to at most once per second.
|
|
202
|
+
window.addEventListener('pointermove', () => {
|
|
203
|
+
const now = Date.now();
|
|
204
|
+
if (now - lastMoveAt >= 1000) {
|
|
205
|
+
lastMoveAt = now;
|
|
206
|
+
lastInteractionAt = now;
|
|
207
|
+
}
|
|
208
|
+
}, { passive: true });
|
|
209
|
+
|
|
210
|
+
function pingPresence() {
|
|
211
|
+
if (submitted) return;
|
|
212
|
+
fetch('/api/ping', {
|
|
213
|
+
method: 'POST',
|
|
214
|
+
headers: { 'content-type': 'application/json' },
|
|
215
|
+
body: JSON.stringify({
|
|
216
|
+
visible: !document.hidden,
|
|
217
|
+
focused: document.hasFocus(),
|
|
218
|
+
idleMs: Date.now() - lastInteractionAt,
|
|
219
|
+
}),
|
|
220
|
+
}).catch(() => {
|
|
221
|
+
// presence is best-effort — a failed ping must never surface to the user
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
165
225
|
// ---------- annotations ----------
|
|
166
226
|
// RelayAnnotate owns the live annotation list; mirror it into state on every
|
|
167
227
|
// change so payload()/autosave/submit carry it exactly like answers.
|
|
@@ -176,13 +236,25 @@
|
|
|
176
236
|
});
|
|
177
237
|
}
|
|
178
238
|
|
|
179
|
-
//
|
|
239
|
+
// Editable-mermaid: record (or clear) the user's edit for a block, then
|
|
240
|
+
// autosave. A null/empty code clears the entry (block matches the original
|
|
241
|
+
// again — e.g. after Reset), so payload()/draft carry only real divergences.
|
|
242
|
+
function onBlockEdit(blockId, codeOrNull) {
|
|
243
|
+
if (codeOrNull === null || codeOrNull === undefined) delete state.blockEdits[blockId];
|
|
244
|
+
else state.blockEdits[blockId] = codeOrNull;
|
|
245
|
+
scheduleSave();
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// ctx for RelayBlocks.render — theme()/htmlSrc per the shared contract, plus
|
|
249
|
+
// the editable-mermaid plumbing (edits map + onBlockEdit callback).
|
|
180
250
|
function blockCtx(questionId) {
|
|
181
251
|
return {
|
|
182
252
|
theme: effectiveTheme,
|
|
183
253
|
htmlSrc: (blockId) => '/html/b/' + encodeURIComponent(blockId) + '?theme=' + effectiveTheme(),
|
|
184
254
|
questionId: questionId == null ? null : questionId,
|
|
185
255
|
annotate: Annotate,
|
|
256
|
+
edits: state.blockEdits,
|
|
257
|
+
onBlockEdit,
|
|
186
258
|
};
|
|
187
259
|
}
|
|
188
260
|
|
|
@@ -413,6 +485,16 @@
|
|
|
413
485
|
});
|
|
414
486
|
applyTheme();
|
|
415
487
|
|
|
488
|
+
// Just reloaded after a live `rly update`? Flag set before reload below.
|
|
489
|
+
try {
|
|
490
|
+
if (sessionStorage.getItem('relay-updated') === '1') {
|
|
491
|
+
sessionStorage.removeItem('relay-updated');
|
|
492
|
+
showToast('Board updated by the agent');
|
|
493
|
+
}
|
|
494
|
+
} catch {
|
|
495
|
+
// sessionStorage may be unavailable (privacy mode) — non-fatal
|
|
496
|
+
}
|
|
497
|
+
|
|
416
498
|
app.append(el('header', { class: 'qb-header' }, el('h1', {}, spec.title), themeBtn));
|
|
417
499
|
if (spec.intro) {
|
|
418
500
|
const intro = el('p', { class: 'intro' }, spec.intro);
|
|
@@ -495,6 +577,15 @@
|
|
|
495
577
|
|
|
496
578
|
function showDone(closing) {
|
|
497
579
|
stopHeartbeat();
|
|
580
|
+
// Annotation pins/badges/popover float on <body> with elevated z-index —
|
|
581
|
+
// remove them so they don't leak over the submitted screen.
|
|
582
|
+
if (window.RelayAnnotate && typeof RelayAnnotate.teardown === 'function') {
|
|
583
|
+
try {
|
|
584
|
+
RelayAnnotate.teardown();
|
|
585
|
+
} catch {
|
|
586
|
+
// best effort
|
|
587
|
+
}
|
|
588
|
+
}
|
|
498
589
|
app.replaceChildren(
|
|
499
590
|
el('div', { class: 'done' },
|
|
500
591
|
el('div', { class: 'mark' }, '✓'),
|
|
@@ -584,11 +675,35 @@
|
|
|
584
675
|
|
|
585
676
|
// ---------- heartbeat ----------
|
|
586
677
|
let misses = 0;
|
|
678
|
+
let reloading = false;
|
|
587
679
|
let hb = setInterval(async () => {
|
|
680
|
+
// Piggyback presence on the heartbeat (best-effort; no-ops after submit).
|
|
681
|
+
pingPresence();
|
|
588
682
|
try {
|
|
589
683
|
const r = await fetch('/api/status', { cache: 'no-store' });
|
|
590
684
|
if (!r.ok) throw new Error('bad status');
|
|
591
685
|
misses = 0;
|
|
686
|
+
// Live update: the agent ran `rly update`, advancing the server rev.
|
|
687
|
+
// Flush whatever the user has typed so far (the reload re-prefills from
|
|
688
|
+
// the live draft — answers for now-removed question ids are ignored),
|
|
689
|
+
// then reload to render the new spec. Guarded so it fires once.
|
|
690
|
+
const body = await r.json().catch(() => null);
|
|
691
|
+
if (body && typeof body.rev === 'number' && bootRev !== null && body.rev !== bootRev && !submitted && !reloading) {
|
|
692
|
+
reloading = true;
|
|
693
|
+
stopHeartbeat();
|
|
694
|
+
clearTimeout(saveTimer);
|
|
695
|
+
try {
|
|
696
|
+
await saveDraft();
|
|
697
|
+
} catch {
|
|
698
|
+
// a failed final save shouldn't block the reload to the new spec
|
|
699
|
+
}
|
|
700
|
+
try {
|
|
701
|
+
sessionStorage.setItem('relay-updated', '1');
|
|
702
|
+
} catch {
|
|
703
|
+
// sessionStorage may be unavailable — the reload still applies the update
|
|
704
|
+
}
|
|
705
|
+
location.reload();
|
|
706
|
+
}
|
|
592
707
|
} catch {
|
|
593
708
|
if (++misses >= 2 && !submitted) {
|
|
594
709
|
banner.textContent = 'This board is closed — the server has stopped. Answers up to your last edit were autosaved.';
|
package/src/ui/blocks.css
CHANGED
|
@@ -168,3 +168,100 @@
|
|
|
168
168
|
}
|
|
169
169
|
.blk-mermaid .node,
|
|
170
170
|
.blk-mermaid .edgeLabel { cursor: default; }
|
|
171
|
+
|
|
172
|
+
/* ---------- editable mermaid (diagram editor) ---------- */
|
|
173
|
+
.blk-mermaid-edit { margin-top: 8px; }
|
|
174
|
+
.blk-edit-btn {
|
|
175
|
+
appearance: none;
|
|
176
|
+
background: transparent;
|
|
177
|
+
border: 1px solid var(--border);
|
|
178
|
+
border-radius: 7px;
|
|
179
|
+
color: var(--muted);
|
|
180
|
+
font-family: var(--sans);
|
|
181
|
+
font-size: 0.78rem;
|
|
182
|
+
padding: 4px 10px;
|
|
183
|
+
cursor: pointer;
|
|
184
|
+
transition: color 150ms var(--ease), border-color 150ms var(--ease);
|
|
185
|
+
}
|
|
186
|
+
.blk-edit-btn:hover,
|
|
187
|
+
.blk-edit-btn.is-open { color: var(--accent); border-color: var(--accent); }
|
|
188
|
+
.blk-editor { margin-top: 8px; }
|
|
189
|
+
.blk-editor[hidden] { display: none; }
|
|
190
|
+
.blk-editor-ta {
|
|
191
|
+
display: block;
|
|
192
|
+
width: 100%;
|
|
193
|
+
box-sizing: border-box;
|
|
194
|
+
min-height: 120px;
|
|
195
|
+
resize: vertical;
|
|
196
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
197
|
+
font-size: 13px;
|
|
198
|
+
line-height: 1.5;
|
|
199
|
+
color: var(--fg);
|
|
200
|
+
background: var(--bg-sunken);
|
|
201
|
+
border: 1px solid var(--border);
|
|
202
|
+
border-radius: 8px;
|
|
203
|
+
padding: 10px 12px;
|
|
204
|
+
}
|
|
205
|
+
.blk-editor-ta:focus {
|
|
206
|
+
outline: none;
|
|
207
|
+
border-color: var(--accent);
|
|
208
|
+
}
|
|
209
|
+
.blk-editor-row {
|
|
210
|
+
display: flex;
|
|
211
|
+
align-items: center;
|
|
212
|
+
gap: 12px;
|
|
213
|
+
margin-top: 8px;
|
|
214
|
+
}
|
|
215
|
+
.blk-editor-reset {
|
|
216
|
+
appearance: none;
|
|
217
|
+
background: transparent;
|
|
218
|
+
border: 1px solid var(--border);
|
|
219
|
+
border-radius: 7px;
|
|
220
|
+
color: var(--muted);
|
|
221
|
+
font-family: var(--sans);
|
|
222
|
+
font-size: 0.78rem;
|
|
223
|
+
padding: 4px 10px;
|
|
224
|
+
cursor: pointer;
|
|
225
|
+
transition: color 150ms var(--ease), border-color 150ms var(--ease);
|
|
226
|
+
}
|
|
227
|
+
.blk-editor-reset:hover { color: var(--accent); border-color: var(--accent); }
|
|
228
|
+
.blk-editor-status {
|
|
229
|
+
font-family: var(--sans);
|
|
230
|
+
font-size: 0.78rem;
|
|
231
|
+
color: var(--muted);
|
|
232
|
+
}
|
|
233
|
+
.blk-editor-status.has-error { color: var(--muted); }
|
|
234
|
+
|
|
235
|
+
/* ---------- graphviz (offline, vendored Viz.js) ---------- */
|
|
236
|
+
.blk-graphviz {
|
|
237
|
+
overflow: auto;
|
|
238
|
+
max-height: 1200px;
|
|
239
|
+
text-align: center;
|
|
240
|
+
/* graphviz draws on its own white canvas; frame it as a deliberate card so
|
|
241
|
+
it reads intentional in dark mode instead of a stray white rectangle */
|
|
242
|
+
background: #fff;
|
|
243
|
+
border: 1px solid var(--border);
|
|
244
|
+
border-radius: 10px;
|
|
245
|
+
padding: 10px;
|
|
246
|
+
}
|
|
247
|
+
.blk-graphviz svg {
|
|
248
|
+
max-width: 100%;
|
|
249
|
+
height: auto;
|
|
250
|
+
}
|
|
251
|
+
.blk-graphviz g.node,
|
|
252
|
+
.blk-graphviz g.edge { cursor: default; }
|
|
253
|
+
|
|
254
|
+
/* ---------- plantuml (server-rendered image) ---------- */
|
|
255
|
+
.blk-plantuml {
|
|
256
|
+
overflow: auto;
|
|
257
|
+
max-height: 1200px;
|
|
258
|
+
text-align: center;
|
|
259
|
+
background: #fff;
|
|
260
|
+
border: 1px solid var(--border);
|
|
261
|
+
border-radius: 10px;
|
|
262
|
+
padding: 10px;
|
|
263
|
+
}
|
|
264
|
+
.blk-plantuml .blk-plantuml-img {
|
|
265
|
+
max-width: 100%;
|
|
266
|
+
height: auto;
|
|
267
|
+
}
|
package/src/ui/blocks.js
CHANGED
|
@@ -371,6 +371,20 @@
|
|
|
371
371
|
return mermaidPromise;
|
|
372
372
|
}
|
|
373
373
|
|
|
374
|
+
let vizPromise = null;
|
|
375
|
+
function loadViz() {
|
|
376
|
+
if (window.Viz) return Promise.resolve(window.Viz);
|
|
377
|
+
if (vizPromise) return vizPromise;
|
|
378
|
+
vizPromise = new Promise((resolve, reject) => {
|
|
379
|
+
const s = document.createElement('script');
|
|
380
|
+
s.src = '/vendor/viz-standalone.js';
|
|
381
|
+
s.onload = () => (window.Viz ? resolve(window.Viz) : reject(new Error('Graphviz failed to load')));
|
|
382
|
+
s.onerror = () => reject(new Error('Graphviz failed to load'));
|
|
383
|
+
document.head.appendChild(s);
|
|
384
|
+
});
|
|
385
|
+
return vizPromise;
|
|
386
|
+
}
|
|
387
|
+
|
|
374
388
|
// ---------- chart ----------
|
|
375
389
|
function clampHeight(h, def) {
|
|
376
390
|
const n = Number(h);
|
|
@@ -540,16 +554,133 @@
|
|
|
540
554
|
const mermaidRegistry = [];
|
|
541
555
|
const chartRegistry = [];
|
|
542
556
|
|
|
557
|
+
// Effective code = the user's edit if present, else the authored block.code.
|
|
558
|
+
// Used by the initial render, the editor's live preview, and the theme
|
|
559
|
+
// re-render registry path so an edit survives a theme toggle.
|
|
560
|
+
function effectiveMermaidCode(entry) {
|
|
561
|
+
const { block, ctx, blockId } = entry;
|
|
562
|
+
const edited = ctx.edits ? ctx.edits[blockId] : undefined;
|
|
563
|
+
if (edited !== undefined && edited !== null) return edited;
|
|
564
|
+
// The live editor keeps entry.code as the source of truth; if the host's
|
|
565
|
+
// ctx.edits hasn't been updated yet but the user has diverged from the
|
|
566
|
+
// original, preserve that divergence (don't snap back to block.code).
|
|
567
|
+
if (entry.code !== undefined && entry.code !== (block.code || '')) return entry.code;
|
|
568
|
+
return block.code || '';
|
|
569
|
+
}
|
|
570
|
+
|
|
543
571
|
function renderMermaid(block, ctx, blockId) {
|
|
544
572
|
const container = el('div', { class: 'blk-mermaid' });
|
|
545
573
|
const entry = { container, block, ctx, blockId };
|
|
574
|
+
entry.code = effectiveMermaidCode(entry);
|
|
546
575
|
mermaidRegistry.push(entry);
|
|
576
|
+
|
|
577
|
+
if (!block.editable) {
|
|
578
|
+
drawMermaid(entry);
|
|
579
|
+
return container;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// Editable: wrap the diagram + an editor below it. The wrapper is what the
|
|
583
|
+
// dispatcher appends; the registry still tracks `container` (the diagram).
|
|
584
|
+
const wrap = el('div', { class: 'blk-mermaid-wrap' });
|
|
585
|
+
wrap.append(container);
|
|
586
|
+
wrap.append(buildMermaidEditor(entry));
|
|
547
587
|
drawMermaid(entry);
|
|
548
|
-
return
|
|
588
|
+
return wrap;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// Editor: toggle button + collapsible panel (textarea + Reset + status line).
|
|
592
|
+
// Live re-render is debounced 600ms; errors show inline WITHOUT destroying the
|
|
593
|
+
// last good diagram. Each accepted change calls ctx.onBlockEdit(blockId, code)
|
|
594
|
+
// (null when the value matches the original block.code again).
|
|
595
|
+
function buildMermaidEditor(entry) {
|
|
596
|
+
const { block, ctx, blockId, container } = entry;
|
|
597
|
+
const original = block.code || '';
|
|
598
|
+
|
|
599
|
+
const toggleBtn = el('button', { type: 'button', class: 'blk-edit-btn' }, 'Edit diagram');
|
|
600
|
+
const panel = el('div', { class: 'blk-editor', hidden: '' });
|
|
601
|
+
|
|
602
|
+
const ta = el('textarea', { class: 'blk-editor-ta', spellcheck: 'false' });
|
|
603
|
+
ta.value = entry.code;
|
|
604
|
+
|
|
605
|
+
const status = el('div', { class: 'blk-editor-status' }, '');
|
|
606
|
+
const resetBtn = el('button', { type: 'button', class: 'blk-editor-reset' }, 'Reset');
|
|
607
|
+
const row = el('div', { class: 'blk-editor-row' }, resetBtn, status);
|
|
608
|
+
panel.append(ta, row);
|
|
609
|
+
|
|
610
|
+
let open = false;
|
|
611
|
+
toggleBtn.addEventListener('click', () => {
|
|
612
|
+
open = !open;
|
|
613
|
+
panel.hidden = !open;
|
|
614
|
+
toggleBtn.classList.toggle('is-open', open);
|
|
615
|
+
if (open) ta.focus();
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
let timer = null;
|
|
619
|
+
function reportEdit(code) {
|
|
620
|
+
// Only report a real divergence; equal-to-original clears the edit (null).
|
|
621
|
+
try {
|
|
622
|
+
if (code === original) ctx.onBlockEdit(blockId, null);
|
|
623
|
+
else ctx.onBlockEdit(blockId, code);
|
|
624
|
+
} catch {
|
|
625
|
+
// host callback failures must not break editing
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// Try to render `code`; on success swap the diagram + re-register annotation
|
|
630
|
+
// targets (via drawMermaid) and report the edit; on failure keep the last
|
|
631
|
+
// good diagram and show the error inline.
|
|
632
|
+
function applyCode(code) {
|
|
633
|
+
previewMermaid(entry, code)
|
|
634
|
+
.then(() => {
|
|
635
|
+
status.textContent = '';
|
|
636
|
+
status.classList.remove('has-error');
|
|
637
|
+
entry.code = code;
|
|
638
|
+
reportEdit(code);
|
|
639
|
+
})
|
|
640
|
+
.catch((err) => {
|
|
641
|
+
status.textContent = 'diagram error: ' + (err && err.message ? err.message : String(err));
|
|
642
|
+
status.classList.add('has-error');
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
ta.addEventListener('input', () => {
|
|
647
|
+
if (timer) clearTimeout(timer);
|
|
648
|
+
const code = ta.value;
|
|
649
|
+
timer = setTimeout(() => applyCode(code), 600);
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
resetBtn.addEventListener('click', () => {
|
|
653
|
+
if (timer) { clearTimeout(timer); timer = null; }
|
|
654
|
+
ta.value = original;
|
|
655
|
+
applyCode(original);
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
return el('div', { class: 'blk-mermaid-edit' }, toggleBtn, panel);
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// Render `code` for the editor preview WITHOUT mutating entry.code on failure.
|
|
662
|
+
// Resolves once the diagram is swapped into the container (annotation targets
|
|
663
|
+
// re-registered by drawMermaid); rejects with the render error so the caller
|
|
664
|
+
// can show it inline and keep the last good diagram.
|
|
665
|
+
function previewMermaid(entry, code) {
|
|
666
|
+
const prev = entry.code;
|
|
667
|
+
entry.code = code;
|
|
668
|
+
return new Promise((resolve, reject) => {
|
|
669
|
+
drawMermaid(entry, { onDone: resolve, onError: reject });
|
|
670
|
+
}).catch((err) => {
|
|
671
|
+
entry.code = prev;
|
|
672
|
+
throw err;
|
|
673
|
+
});
|
|
549
674
|
}
|
|
550
675
|
|
|
551
|
-
function drawMermaid(entry) {
|
|
552
|
-
const { container,
|
|
676
|
+
function drawMermaid(entry, hooks) {
|
|
677
|
+
const { container, ctx, blockId } = entry;
|
|
678
|
+
const onError = hooks && hooks.onError ? hooks.onError : null;
|
|
679
|
+
const onDone = hooks && hooks.onDone ? hooks.onDone : null;
|
|
680
|
+
const fail = (err) => {
|
|
681
|
+
if (onError) onError(err);
|
|
682
|
+
else showMermaidErr(container, err);
|
|
683
|
+
};
|
|
553
684
|
loadMermaid().then((mermaid) => {
|
|
554
685
|
try {
|
|
555
686
|
mermaid.initialize({
|
|
@@ -561,7 +692,7 @@
|
|
|
561
692
|
// ignore re-init issues
|
|
562
693
|
}
|
|
563
694
|
const id = 'rly-mmd-' + (++mermaidSeq);
|
|
564
|
-
const code =
|
|
695
|
+
const code = entry.code !== undefined ? entry.code : effectiveMermaidCode(entry);
|
|
565
696
|
const onSvg = (svg) => {
|
|
566
697
|
container.innerHTML = svg;
|
|
567
698
|
const svgEl = container.querySelector('svg');
|
|
@@ -579,24 +710,26 @@
|
|
|
579
710
|
svgEl.style.maxWidth = '100%';
|
|
580
711
|
}
|
|
581
712
|
}
|
|
582
|
-
if (
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
713
|
+
if (ctx.annotate) {
|
|
714
|
+
const nodes = container.querySelectorAll('.node, .edgeLabel');
|
|
715
|
+
nodes.forEach((g) => {
|
|
716
|
+
ctx.annotate.register(g, {
|
|
717
|
+
blockId,
|
|
718
|
+
questionId: ctx.questionId,
|
|
719
|
+
target: {
|
|
720
|
+
kind: 'mermaid-node',
|
|
721
|
+
nodeId: g.id || '',
|
|
722
|
+
text: (g.textContent || '').trim().slice(0, 120),
|
|
723
|
+
},
|
|
724
|
+
});
|
|
593
725
|
});
|
|
594
|
-
}
|
|
726
|
+
}
|
|
727
|
+
if (onDone) onDone();
|
|
595
728
|
};
|
|
596
729
|
try {
|
|
597
730
|
const ret = mermaid.render(id, code);
|
|
598
731
|
if (ret && typeof ret.then === 'function') {
|
|
599
|
-
ret.then((r) => onSvg(r.svg)).catch((err) =>
|
|
732
|
+
ret.then((r) => onSvg(r.svg)).catch((err) => fail(err));
|
|
600
733
|
} else if (ret && ret.svg) {
|
|
601
734
|
onSvg(ret.svg);
|
|
602
735
|
} else if (typeof ret === 'string') {
|
|
@@ -606,9 +739,9 @@
|
|
|
606
739
|
mermaid.render(id, code, (svg) => onSvg(svg));
|
|
607
740
|
}
|
|
608
741
|
} catch (err) {
|
|
609
|
-
|
|
742
|
+
fail(err);
|
|
610
743
|
}
|
|
611
|
-
}).catch((err) =>
|
|
744
|
+
}).catch((err) => fail(err));
|
|
612
745
|
}
|
|
613
746
|
|
|
614
747
|
// Live theme toggle: re-render mermaid diagrams with the new mermaid theme
|
|
@@ -616,6 +749,8 @@
|
|
|
616
749
|
function onThemeChange() {
|
|
617
750
|
for (const entry of mermaidRegistry) {
|
|
618
751
|
try {
|
|
752
|
+
// re-render with the effective code so an edit survives the toggle
|
|
753
|
+
entry.code = effectiveMermaidCode(entry);
|
|
619
754
|
drawMermaid(entry);
|
|
620
755
|
} catch {
|
|
621
756
|
// keep the previous svg on failure
|
|
@@ -648,6 +783,120 @@
|
|
|
648
783
|
);
|
|
649
784
|
}
|
|
650
785
|
|
|
786
|
+
// ---------- graphviz (offline, vendored Viz.js -> SVG) ----------
|
|
787
|
+
// Same sizing rule as mermaid: never upscale past the diagram's natural
|
|
788
|
+
// width; shrink on narrow screens. Authors set their own colors so there is
|
|
789
|
+
// no theme re-render.
|
|
790
|
+
function sizeDiagramSvg(svgEl) {
|
|
791
|
+
if (!svgEl) return;
|
|
792
|
+
svgEl.removeAttribute('height');
|
|
793
|
+
svgEl.removeAttribute('width');
|
|
794
|
+
const vb = svgEl.viewBox && svgEl.viewBox.baseVal;
|
|
795
|
+
if (vb && vb.width > 0) {
|
|
796
|
+
svgEl.style.width = '100%';
|
|
797
|
+
svgEl.style.maxWidth = Math.ceil(vb.width) + 'px';
|
|
798
|
+
svgEl.style.height = 'auto';
|
|
799
|
+
} else {
|
|
800
|
+
svgEl.style.maxWidth = '100%';
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
function renderGraphviz(block, ctx, blockId) {
|
|
805
|
+
const container = el('div', { class: 'blk-graphviz' });
|
|
806
|
+
loadViz()
|
|
807
|
+
.then((Viz) => Viz.instance())
|
|
808
|
+
.then((viz) => {
|
|
809
|
+
const svgEl = viz.renderSVGElement(block.dot || '');
|
|
810
|
+
sizeDiagramSvg(svgEl);
|
|
811
|
+
container.replaceChildren(svgEl);
|
|
812
|
+
if (!ctx.annotate) return;
|
|
813
|
+
const parts = svgEl.querySelectorAll('g.node, g.edge');
|
|
814
|
+
parts.forEach((g) => {
|
|
815
|
+
const titleEl = g.querySelector('title');
|
|
816
|
+
const nodeId = (g.id || (titleEl && titleEl.textContent) || '').trim();
|
|
817
|
+
// Label text lives in <text> elements; g.textContent would also
|
|
818
|
+
// include the <title> child and duplicate the label.
|
|
819
|
+
const labels = Array.from(g.querySelectorAll('text')).map((t) => t.textContent.trim()).filter(Boolean);
|
|
820
|
+
const text = (labels.join(' ') || (titleEl && titleEl.textContent) || '').trim().slice(0, 120);
|
|
821
|
+
ctx.annotate.register(g, {
|
|
822
|
+
blockId,
|
|
823
|
+
questionId: ctx.questionId,
|
|
824
|
+
target: {
|
|
825
|
+
kind: 'graphviz-node',
|
|
826
|
+
nodeId,
|
|
827
|
+
text,
|
|
828
|
+
},
|
|
829
|
+
});
|
|
830
|
+
});
|
|
831
|
+
})
|
|
832
|
+
.catch((err) => {
|
|
833
|
+
container.replaceChildren(
|
|
834
|
+
el('div', { class: 'blk-error' }, 'Graphviz error: ' + (err && err.message ? err.message : String(err)))
|
|
835
|
+
);
|
|
836
|
+
});
|
|
837
|
+
return container;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
// ---------- plantuml (server-rendered; client deflate-raw + base64 variant) ----------
|
|
841
|
+
const PLANTUML_ALPHABET = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-_';
|
|
842
|
+
|
|
843
|
+
// PlantUML's base64 variant: 3 bytes -> 4 chars using PLANTUML_ALPHABET.
|
|
844
|
+
function encode64(bytes) {
|
|
845
|
+
let out = '';
|
|
846
|
+
for (let i = 0; i < bytes.length; i += 3) {
|
|
847
|
+
const b1 = bytes[i];
|
|
848
|
+
const b2 = i + 1 < bytes.length ? bytes[i + 1] : 0;
|
|
849
|
+
const b3 = i + 2 < bytes.length ? bytes[i + 2] : 0;
|
|
850
|
+
out += PLANTUML_ALPHABET[b1 >> 2];
|
|
851
|
+
out += PLANTUML_ALPHABET[((b1 & 0x3) << 4) | (b2 >> 4)];
|
|
852
|
+
if (i + 1 < bytes.length) out += PLANTUML_ALPHABET[((b2 & 0xf) << 2) | (b3 >> 6)];
|
|
853
|
+
if (i + 2 < bytes.length) out += PLANTUML_ALPHABET[b3 & 0x3f];
|
|
854
|
+
}
|
|
855
|
+
return out;
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
async function encodePlantUml(code) {
|
|
859
|
+
if (typeof CompressionStream === 'undefined') {
|
|
860
|
+
throw new Error('CompressionStream unavailable');
|
|
861
|
+
}
|
|
862
|
+
const bytes = new TextEncoder().encode(String(code));
|
|
863
|
+
const blob = new Blob([bytes]);
|
|
864
|
+
const compressed = await new Response(
|
|
865
|
+
blob.stream().pipeThrough(new CompressionStream('deflate-raw'))
|
|
866
|
+
).arrayBuffer();
|
|
867
|
+
return encode64(new Uint8Array(compressed));
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
function renderPlantuml(block, ctx, blockId) {
|
|
871
|
+
const container = el('div', { class: 'blk-plantuml' });
|
|
872
|
+
const fail = () =>
|
|
873
|
+
container.replaceChildren(
|
|
874
|
+
el('div', { class: 'blk-error' }, 'PlantUML needs network access and a modern browser')
|
|
875
|
+
);
|
|
876
|
+
encodePlantUml(block.code || '')
|
|
877
|
+
.then((encoded) => {
|
|
878
|
+
const server = block.server || 'https://www.plantuml.com/plantuml';
|
|
879
|
+
const img = el('img', {
|
|
880
|
+
class: 'blk-plantuml-img',
|
|
881
|
+
src: server + '/svg/' + encoded,
|
|
882
|
+
alt: 'PlantUML diagram',
|
|
883
|
+
loading: 'lazy',
|
|
884
|
+
});
|
|
885
|
+
if (block.height) img.style.height = clampHeight(block.height, 360) + 'px';
|
|
886
|
+
img.addEventListener('error', fail);
|
|
887
|
+
container.replaceChildren(img);
|
|
888
|
+
if (ctx.annotate) {
|
|
889
|
+
ctx.annotate.register(img, {
|
|
890
|
+
blockId,
|
|
891
|
+
questionId: ctx.questionId,
|
|
892
|
+
target: { kind: 'image', label: 'PlantUML diagram' },
|
|
893
|
+
});
|
|
894
|
+
}
|
|
895
|
+
})
|
|
896
|
+
.catch(fail);
|
|
897
|
+
return container;
|
|
898
|
+
}
|
|
899
|
+
|
|
651
900
|
// ---------- html (sandboxed iframe) ----------
|
|
652
901
|
function renderHtml(block, ctx, blockId) {
|
|
653
902
|
const height = clampHeight(block.height, 360);
|
|
@@ -690,6 +939,14 @@
|
|
|
690
939
|
inner = renderMermaid(block, ctx, blockId);
|
|
691
940
|
wrapper.append(inner);
|
|
692
941
|
break;
|
|
942
|
+
case 'graphviz':
|
|
943
|
+
inner = renderGraphviz(block, ctx, blockId);
|
|
944
|
+
wrapper.append(inner);
|
|
945
|
+
break;
|
|
946
|
+
case 'plantuml':
|
|
947
|
+
inner = renderPlantuml(block, ctx, blockId);
|
|
948
|
+
wrapper.append(inner);
|
|
949
|
+
break;
|
|
693
950
|
case 'html':
|
|
694
951
|
inner = renderHtml(block, ctx, blockId);
|
|
695
952
|
wrapper.append(inner);
|
|
@@ -708,6 +965,11 @@
|
|
|
708
965
|
htmlSrc: ctx && ctx.htmlSrc ? ctx.htmlSrc : (id) => '/html/b/' + id,
|
|
709
966
|
questionId: ctx && ctx.questionId !== undefined ? ctx.questionId : null,
|
|
710
967
|
annotate: ctx && ctx.annotate ? ctx.annotate : null,
|
|
968
|
+
// editable-mermaid plumbing: edits maps blockId -> edited code; onBlockEdit
|
|
969
|
+
// reports an accepted change (or null to clear back to the original).
|
|
970
|
+
edits: ctx && ctx.edits ? ctx.edits : {},
|
|
971
|
+
onBlockEdit:
|
|
972
|
+
ctx && typeof ctx.onBlockEdit === 'function' ? ctx.onBlockEdit : () => {},
|
|
711
973
|
};
|
|
712
974
|
for (const block of list) {
|
|
713
975
|
if (!block || !block.type) continue;
|
package/src/ui/style.css
CHANGED
|
@@ -206,6 +206,15 @@ textarea { min-height: 90px; resize: vertical; }
|
|
|
206
206
|
z-index: 10; display: none;
|
|
207
207
|
}
|
|
208
208
|
|
|
209
|
+
/* Live-update toast: flagged after a `rly update` reload (see app.js).
|
|
210
|
+
Fixed top-center, accent-soft bg, accent text; JS auto-removes it. */
|
|
211
|
+
.toast {
|
|
212
|
+
position: fixed; top: 16px; left: 50%; transform: translateX(-50%);
|
|
213
|
+
background: var(--accent-soft); color: var(--accent);
|
|
214
|
+
padding: 10px 18px; border-radius: 10px; font-size: 0.9rem;
|
|
215
|
+
box-shadow: var(--shadow-lift); z-index: 50;
|
|
216
|
+
}
|
|
217
|
+
|
|
209
218
|
.done { text-align: center; padding: 80px 18px; }
|
|
210
219
|
.done .mark {
|
|
211
220
|
width: 64px; height: 64px; border-radius: 50%;
|