@khanglvm/relay 0.14.2 → 0.15.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 +3 -2
- package/docs/AGENT.md +14 -9
- package/package.json +1 -1
- package/skills/relay/SKILL.md +11 -5
- package/src/cli.js +20 -10
- package/src/server.js +205 -89
- package/src/ui/annotate.js +16 -7
- package/src/ui/app.js +77 -32
- package/src/ui/blocks.css +5 -3
- package/src/ui/blocks.js +113 -20
package/src/ui/app.js
CHANGED
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
canComment: true,
|
|
12
12
|
canEditComments: true,
|
|
13
13
|
canDeleteComments: true,
|
|
14
|
+
canEditBlocks: true,
|
|
15
|
+
canFinalize: true,
|
|
14
16
|
};
|
|
15
17
|
if (access.role) document.documentElement.dataset.accessRole = access.role;
|
|
16
18
|
const QS = spec.questions || [];
|
|
@@ -23,8 +25,11 @@
|
|
|
23
25
|
let seenDraftRev = typeof boot.draftRev === 'number' ? boot.draftRev : 0;
|
|
24
26
|
|
|
25
27
|
function authHeaders(extra = {}) {
|
|
26
|
-
|
|
28
|
+
const headers = access.token ? { ...extra, 'x-relay-share-token': access.token } : { ...extra };
|
|
29
|
+
if (access.reviewSessionId) headers['x-relay-review-session'] = access.reviewSessionId;
|
|
30
|
+
return headers;
|
|
27
31
|
}
|
|
32
|
+
const canPersistFeedback = Boolean(access.canEditAnswers || access.canComment || access.canEditBlocks);
|
|
28
33
|
|
|
29
34
|
// ---------- helpers ----------
|
|
30
35
|
function el(tag, attrs = {}, ...children) {
|
|
@@ -133,14 +138,17 @@
|
|
|
133
138
|
applyFontScale();
|
|
134
139
|
|
|
135
140
|
// ---------- localStorage draft mirror ----------
|
|
136
|
-
// Every autosave is ALSO written to localStorage, keyed by board id
|
|
141
|
+
// Every autosave is ALSO written to localStorage, keyed by board id + access
|
|
142
|
+
// role/session. This keeps a reviewer side draft from leaking into a
|
|
143
|
+
// collaborator/read-only tab on the same LAN origin.
|
|
137
144
|
// the durability layer the server file alone can't provide: if the connection
|
|
138
145
|
// drops and the user keeps typing, the in-memory state is mirrored locally, so
|
|
139
146
|
// even a tab reload / browser restart / a freshly opened tab on the same board
|
|
140
147
|
// prefills the LATEST input instead of a blank board or a stale server save.
|
|
141
148
|
// Guards (per design): newest-of-(local,server) wins; the mirror is discarded
|
|
142
149
|
// if the board's spec rev changed (agent edited it); cleared on submit.
|
|
143
|
-
const LOCAL_DRAFT_KEY = 'relay-draft-' + (boot.boardId || 'unknown')
|
|
150
|
+
const LOCAL_DRAFT_KEY = 'relay-draft-' + (boot.boardId || 'unknown') + '-' +
|
|
151
|
+
(access.role || 'owner') + (access.reviewSessionId ? '-' + access.reviewSessionId : '');
|
|
144
152
|
function writeLocalDraft(p, updatedAt) {
|
|
145
153
|
try {
|
|
146
154
|
localStorage.setItem(LOCAL_DRAFT_KEY, JSON.stringify({
|
|
@@ -352,7 +360,7 @@
|
|
|
352
360
|
// to detect recovery from the Retry button / heartbeat.
|
|
353
361
|
async function probeServer() {
|
|
354
362
|
try {
|
|
355
|
-
const r = await fetch('/api/status', { cache: 'no-store' });
|
|
363
|
+
const r = await fetch('/api/status', { cache: 'no-store', headers: authHeaders() });
|
|
356
364
|
return r.ok;
|
|
357
365
|
} catch {
|
|
358
366
|
return false;
|
|
@@ -407,6 +415,7 @@
|
|
|
407
415
|
for (const node of app.querySelectorAll('input, textarea, button, select')) {
|
|
408
416
|
node.disabled = false;
|
|
409
417
|
}
|
|
418
|
+
applyAccessRestrictions();
|
|
410
419
|
if (lostNote) lostNote.remove();
|
|
411
420
|
// Re-arm the heartbeat (it stops itself when it confirms loss) and persist
|
|
412
421
|
// everything typed during the outage. saveDraft() updates the save label.
|
|
@@ -454,7 +463,7 @@
|
|
|
454
463
|
// probe → block. Any success resets it.
|
|
455
464
|
let saveFailures = 0;
|
|
456
465
|
function scheduleSave() {
|
|
457
|
-
if (submitted) return;
|
|
466
|
+
if (submitted || !canPersistFeedback) return;
|
|
458
467
|
// Mirror to localStorage SYNCHRONOUSLY on every edit, before (and regardless
|
|
459
468
|
// of) the network save. This is what survives a tab reload / crash / a new
|
|
460
469
|
// tab during a connection outage — it must happen even while blocked.
|
|
@@ -465,6 +474,7 @@
|
|
|
465
474
|
saveTimer = setTimeout(saveDraft, 450);
|
|
466
475
|
}
|
|
467
476
|
async function saveDraft() {
|
|
477
|
+
if (!canPersistFeedback) return;
|
|
468
478
|
const seq = ++saveSeq;
|
|
469
479
|
// Keep the local mirror current on every flush too (covers programmatic
|
|
470
480
|
// saveDraft() calls that don't go through scheduleSave, e.g. recovery flush).
|
|
@@ -516,7 +526,7 @@
|
|
|
516
526
|
if (submitted) return;
|
|
517
527
|
fetch('/api/ping', {
|
|
518
528
|
method: 'POST',
|
|
519
|
-
headers: { 'content-type': 'application/json' },
|
|
529
|
+
headers: authHeaders({ 'content-type': 'application/json' }),
|
|
520
530
|
body: JSON.stringify({
|
|
521
531
|
visible: !document.hidden,
|
|
522
532
|
focused: document.hasFocus(),
|
|
@@ -570,11 +580,17 @@
|
|
|
570
580
|
function blockCtx(questionId) {
|
|
571
581
|
return {
|
|
572
582
|
theme: effectiveTheme,
|
|
573
|
-
htmlSrc: (blockId) =>
|
|
583
|
+
htmlSrc: (blockId) => {
|
|
584
|
+
const params = new URLSearchParams({ theme: effectiveTheme() });
|
|
585
|
+
if (access.token) params.set('token', access.token);
|
|
586
|
+
return '/html/b/' + encodeURIComponent(blockId) + '?' + params.toString();
|
|
587
|
+
},
|
|
574
588
|
questionId: questionId == null ? null : questionId,
|
|
575
589
|
annotate: Annotate,
|
|
590
|
+
canComment: access.canComment !== false,
|
|
576
591
|
edits: state.blockEdits,
|
|
577
592
|
onBlockEdit,
|
|
593
|
+
canEditBlocks: access.canEditBlocks !== false,
|
|
578
594
|
};
|
|
579
595
|
}
|
|
580
596
|
|
|
@@ -807,6 +823,7 @@
|
|
|
807
823
|
const byVal = new Map(q.options.map((o) => [o.value, o]));
|
|
808
824
|
let dragFrom = null;
|
|
809
825
|
function move(from, to) {
|
|
826
|
+
if (!access.canEditAnswers) return;
|
|
810
827
|
const arr = state.answers[q.id];
|
|
811
828
|
if (!Array.isArray(arr) || to < 0 || to >= arr.length || from === to) return;
|
|
812
829
|
const [x] = arr.splice(from, 1);
|
|
@@ -827,7 +844,7 @@
|
|
|
827
844
|
down.disabled = i === order.length - 1;
|
|
828
845
|
up.addEventListener('click', () => move(i, i - 1));
|
|
829
846
|
down.addEventListener('click', () => move(i, i + 1));
|
|
830
|
-
const item = el('div', { class: 'rank-item', draggable: 'true' },
|
|
847
|
+
const item = el('div', { class: 'rank-item', draggable: access.canEditAnswers ? 'true' : 'false' },
|
|
831
848
|
el('span', { class: 'rank-badge', 'aria-hidden': 'true' }, String(i + 1)),
|
|
832
849
|
el('div', { class: 'rank-body' },
|
|
833
850
|
el('div', { class: 'ol' }, o.label),
|
|
@@ -835,6 +852,7 @@
|
|
|
835
852
|
el('div', { class: 'rank-ctrls' }, up, down)
|
|
836
853
|
);
|
|
837
854
|
item.addEventListener('dragstart', (e) => {
|
|
855
|
+
if (!access.canEditAnswers) { e.preventDefault(); return; }
|
|
838
856
|
dragFrom = i; item.classList.add('dragging');
|
|
839
857
|
try { e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', String(i)); } catch (_) {}
|
|
840
858
|
});
|
|
@@ -1182,7 +1200,11 @@
|
|
|
1182
1200
|
),
|
|
1183
1201
|
el('button', { class: 'share-choice', type: 'button', 'data-role': 'review' },
|
|
1184
1202
|
el('span', { class: 'share-choice-title' }, 'Reviewer'),
|
|
1185
|
-
el('span', { class: 'share-choice-sub' }, 'Can
|
|
1203
|
+
el('span', { class: 'share-choice-sub' }, 'Can answer, comment, and submit a reference-only side review. Does not finalize the board.')
|
|
1204
|
+
),
|
|
1205
|
+
el('button', { class: 'share-choice', type: 'button', 'data-role': 'read' },
|
|
1206
|
+
el('span', { class: 'share-choice-title' }, 'Read only'),
|
|
1207
|
+
el('span', { class: 'share-choice-sub' }, 'Can view the board and existing feedback. Cannot answer, comment, edit, or submit.')
|
|
1186
1208
|
),
|
|
1187
1209
|
el('div', { class: 'share-result' })
|
|
1188
1210
|
);
|
|
@@ -1207,7 +1229,7 @@
|
|
|
1207
1229
|
const copy = el('button', { class: 'share-copy', type: 'button' }, 'Copy link');
|
|
1208
1230
|
copy.addEventListener('click', () => copyText(body.url, copy));
|
|
1209
1231
|
result.replaceChildren(
|
|
1210
|
-
el('div', { class: 'share-ready' }, role === 'collab' ? 'Collaborator link active' : 'Reviewer link active'),
|
|
1232
|
+
el('div', { class: 'share-ready' }, role === 'collab' ? 'Collaborator link active' : role === 'review' ? 'Reviewer link active' : 'Read-only link active'),
|
|
1211
1233
|
el('a', { class: 'share-url', href: body.url, target: '_blank', rel: 'noreferrer' }, body.url),
|
|
1212
1234
|
copy
|
|
1213
1235
|
);
|
|
@@ -1226,18 +1248,33 @@
|
|
|
1226
1248
|
return footer;
|
|
1227
1249
|
}
|
|
1228
1250
|
|
|
1229
|
-
|
|
1230
|
-
submitbar.style.display = 'none';
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1251
|
+
function applyAccessRestrictions() {
|
|
1252
|
+
if (!access.canSubmit) submitbar.style.display = 'none';
|
|
1253
|
+
if (!access.canEditAnswers) {
|
|
1254
|
+
for (const node of app.querySelectorAll('.card input, .card textarea, .card button, .card select')) {
|
|
1255
|
+
// Viewer controls inside question/option blocks remain useful in a
|
|
1256
|
+
// read-only board (zoom, fit, full-screen, filtering, diff view). Block
|
|
1257
|
+
// mutation controls are independently gated by canEditBlocks.
|
|
1258
|
+
if (node.closest('.blocks')) continue;
|
|
1259
|
+
node.disabled = true;
|
|
1260
|
+
}
|
|
1261
|
+
for (const item of app.querySelectorAll('.rank-item')) {
|
|
1262
|
+
item.draggable = false;
|
|
1263
|
+
item.setAttribute('aria-disabled', 'true');
|
|
1264
|
+
}
|
|
1239
1265
|
}
|
|
1266
|
+
document.documentElement.classList.toggle('relay-review', access.canEditComments === false || access.canDeleteComments === false);
|
|
1267
|
+
}
|
|
1268
|
+
if (access.role === 'review') {
|
|
1269
|
+
submitBtn.textContent = 'Submit side review';
|
|
1270
|
+
hint.textContent = 'Reference only — the owner still submits the final answer.';
|
|
1271
|
+
showNotice('Reviewer mode: your answers and comments are saved separately as a reference-only side review. Submitting here does not finalize the board or notify the waiting agent.', 'info');
|
|
1272
|
+
} else if (access.role === 'read') {
|
|
1273
|
+
showNotice('Read-only mode: you can view the board and existing feedback, but answers, comments, edits, and submission are disabled.', 'info');
|
|
1274
|
+
} else if (access.role === 'collab') {
|
|
1275
|
+
showNotice('Editor mode: answers, comments, and final submission are enabled for this shared board.', 'info');
|
|
1240
1276
|
}
|
|
1277
|
+
applyAccessRestrictions();
|
|
1241
1278
|
app.append(buildFooter());
|
|
1242
1279
|
|
|
1243
1280
|
// ---------- validation & submit ----------
|
|
@@ -1253,7 +1290,7 @@
|
|
|
1253
1290
|
return !firstBad;
|
|
1254
1291
|
}
|
|
1255
1292
|
|
|
1256
|
-
function showDone(closing) {
|
|
1293
|
+
function showDone(closing, sideReview = false) {
|
|
1257
1294
|
stopHeartbeat();
|
|
1258
1295
|
// Annotation pins/badges/popover float on <body> with elevated z-index —
|
|
1259
1296
|
// remove them so they don't leak over the submitted screen.
|
|
@@ -1267,7 +1304,9 @@
|
|
|
1267
1304
|
// If the agent already stopped waiting (soft timeout / dropped connection),
|
|
1268
1305
|
// the submission won't be picked up automatically — tell the user to nudge
|
|
1269
1306
|
// the agent. Otherwise the normal hand-back copy applies.
|
|
1270
|
-
const note =
|
|
1307
|
+
const note = sideReview
|
|
1308
|
+
? 'Saved for reference. This did not finalize the board or notify the waiting agent; the owner still needs to submit the final answer.'
|
|
1309
|
+
: handedBack
|
|
1271
1310
|
? 'Saved. Your agent had stopped waiting — send it a message so it picks up your answers.'
|
|
1272
1311
|
: closing
|
|
1273
1312
|
? 'Handing back to your agent — this tab will close itself…'
|
|
@@ -1275,7 +1314,7 @@
|
|
|
1275
1314
|
app.replaceChildren(
|
|
1276
1315
|
el('div', { class: 'done' },
|
|
1277
1316
|
el('div', { class: 'mark' }, '✓'),
|
|
1278
|
-
el('h2', {}, QS.length ? 'Submitted' : 'Acknowledged'),
|
|
1317
|
+
el('h2', {}, sideReview ? 'Side review saved' : QS.length ? 'Submitted' : 'Acknowledged'),
|
|
1279
1318
|
el('p', { id: 'done-note' }, note)
|
|
1280
1319
|
)
|
|
1281
1320
|
);
|
|
@@ -1302,14 +1341,16 @@
|
|
|
1302
1341
|
throw netErr;
|
|
1303
1342
|
}
|
|
1304
1343
|
if (!res.ok) throw new Error('submit rejected');
|
|
1344
|
+
const submitResult = await res.json().catch(() => null);
|
|
1345
|
+
const sideReview = Boolean(submitResult && submitResult.sideReview === true && submitResult.final === false);
|
|
1305
1346
|
submitted = true;
|
|
1306
1347
|
// Submitted successfully → the local mirror is no longer needed and would
|
|
1307
1348
|
// otherwise resurrect stale answers on a future reopen. Clear it.
|
|
1308
1349
|
clearLocalDraft();
|
|
1309
1350
|
// Don't auto-close when the agent had stopped waiting — the user needs to
|
|
1310
1351
|
// read the "send your agent a message" note and act on it.
|
|
1311
|
-
const autoClose = spec.autoClose && !handedBack;
|
|
1312
|
-
showDone(autoClose);
|
|
1352
|
+
const autoClose = !sideReview && spec.autoClose && !handedBack;
|
|
1353
|
+
showDone(autoClose, sideReview);
|
|
1313
1354
|
if (autoClose) {
|
|
1314
1355
|
setTimeout(() => {
|
|
1315
1356
|
window.close();
|
|
@@ -1324,7 +1365,7 @@
|
|
|
1324
1365
|
} catch {
|
|
1325
1366
|
// Restore the button so the user can retry.
|
|
1326
1367
|
submitBtn.disabled = false;
|
|
1327
|
-
submitBtn.textContent = spec.submitLabel;
|
|
1368
|
+
submitBtn.textContent = access.role === 'review' ? 'Submit side review' : spec.submitLabel;
|
|
1328
1369
|
if (!reached) {
|
|
1329
1370
|
// The connection is gone — the submit (and any further input) can't be
|
|
1330
1371
|
// persisted. Block hard so the user stops adding feedback that would be
|
|
@@ -1362,7 +1403,7 @@
|
|
|
1362
1403
|
// the server had nothing — e.g. a freshly reopened board the user had typed
|
|
1363
1404
|
// into in another tab during an outage), the server doesn't yet have this
|
|
1364
1405
|
// input. Flush it once so a brand-new tab's view is also the server's truth.
|
|
1365
|
-
if (initialPrefill && initialPrefill.__from === 'local' && !submitted) {
|
|
1406
|
+
if (initialPrefill && initialPrefill.__from === 'local' && !submitted && canPersistFeedback) {
|
|
1366
1407
|
saveDraft();
|
|
1367
1408
|
}
|
|
1368
1409
|
|
|
@@ -1456,7 +1497,7 @@
|
|
|
1456
1497
|
// Piggyback presence on the heartbeat (best-effort; no-ops after submit).
|
|
1457
1498
|
pingPresence();
|
|
1458
1499
|
try {
|
|
1459
|
-
const r = await fetch('/api/status', { cache: 'no-store' });
|
|
1500
|
+
const r = await fetch('/api/status', { cache: 'no-store', headers: authHeaders() });
|
|
1460
1501
|
if (!r.ok) throw new Error('bad status');
|
|
1461
1502
|
misses = 0;
|
|
1462
1503
|
// The heartbeat reaching the server is itself proof persistence is back —
|
|
@@ -1476,10 +1517,14 @@
|
|
|
1476
1517
|
// user knows to prompt the agent after submitting.
|
|
1477
1518
|
if (body && body.softTimedOut && !submitted) {
|
|
1478
1519
|
handedBack = true;
|
|
1479
|
-
|
|
1480
|
-
'
|
|
1481
|
-
|
|
1482
|
-
|
|
1520
|
+
if (access.role === 'review') {
|
|
1521
|
+
showNotice('This remains a reference-only side review. Submit when ready; the owner still needs to provide the final answer.', 'info');
|
|
1522
|
+
} else if (access.role !== 'read') {
|
|
1523
|
+
showNotice(
|
|
1524
|
+
'You’ve had this open a while, so the agent stopped waiting. Your changes save automatically — submit when you’re ready, then prompt the agent to pick them up.',
|
|
1525
|
+
'info'
|
|
1526
|
+
);
|
|
1527
|
+
}
|
|
1483
1528
|
}
|
|
1484
1529
|
if (body && typeof body.rev === 'number' && bootRev !== null && body.rev !== bootRev && !submitted && !reloading) {
|
|
1485
1530
|
// Don't yank the board out from under someone mid-comment: an open
|
package/src/ui/blocks.css
CHANGED
|
@@ -498,6 +498,7 @@
|
|
|
498
498
|
position: relative;
|
|
499
499
|
width: 100%;
|
|
500
500
|
}
|
|
501
|
+
.blk-chart-stage { position: relative; width: 100%; height: 100%; min-width: 0; }
|
|
501
502
|
.blk-chart canvas { display: block; }
|
|
502
503
|
.blk-chart-badge {
|
|
503
504
|
position: absolute;
|
|
@@ -736,9 +737,10 @@ body.blk-full-open { overflow: hidden; }
|
|
|
736
737
|
border: 1px solid var(--border);
|
|
737
738
|
border-radius: 10px;
|
|
738
739
|
}
|
|
739
|
-
/*
|
|
740
|
-
|
|
741
|
-
|
|
740
|
+
/* Charts now use the same zoom/pan viewer as diagrams and images. Keep the
|
|
741
|
+
rounded frame while allowing the responsive chart stage to overflow once
|
|
742
|
+
the user zooms past fit. */
|
|
743
|
+
.blk-chart.blk-viewer:not(.blk-full) { overflow: auto; }
|
|
742
744
|
|
|
743
745
|
/* drag-to-pan affordance: grab when a visual overflows its box, grabbing while
|
|
744
746
|
dragging. During an active drag the inner svg/img/canvas ignore the pointer
|
package/src/ui/blocks.js
CHANGED
|
@@ -734,7 +734,7 @@
|
|
|
734
734
|
|
|
735
735
|
function renderDiff(block, ctx, blockId) {
|
|
736
736
|
const files = splitDiffFiles(block.diff);
|
|
737
|
-
if (block.review === true) return renderDiffReview(block, ctx, blockId, files);
|
|
737
|
+
if (block.review === true && ctx.canEditBlocks !== false) return renderDiffReview(block, ctx, blockId, files);
|
|
738
738
|
const named = files.filter((f) => f.name);
|
|
739
739
|
const multi = named.length > 1;
|
|
740
740
|
let view = block.view === 'split' ? 'split' : 'unified';
|
|
@@ -844,6 +844,7 @@
|
|
|
844
844
|
}
|
|
845
845
|
|
|
846
846
|
function renderGitConflict(block, ctx, blockId) {
|
|
847
|
+
const editable = ctx.canEditBlocks !== false;
|
|
847
848
|
const conflicts = Array.isArray(block.conflicts) ? block.conflicts : [];
|
|
848
849
|
const prior = ctx && ctx.edits && ctx.edits[blockId] && typeof ctx.edits[blockId] === 'object'
|
|
849
850
|
? ctx.edits[blockId]
|
|
@@ -891,6 +892,7 @@
|
|
|
891
892
|
spellcheck: 'false',
|
|
892
893
|
rows: '5',
|
|
893
894
|
placeholder: 'Write the resolved content for this hunk',
|
|
895
|
+
disabled: editable ? null : '',
|
|
894
896
|
});
|
|
895
897
|
const buttons = [];
|
|
896
898
|
const setChoice = (choice) => {
|
|
@@ -902,7 +904,7 @@
|
|
|
902
904
|
emit();
|
|
903
905
|
};
|
|
904
906
|
const makeButton = (choice, label) => {
|
|
905
|
-
const b = el('button', { type: 'button', class: 'gitconf-choice' }, label);
|
|
907
|
+
const b = el('button', { type: 'button', class: 'gitconf-choice', disabled: editable ? null : '' }, label);
|
|
906
908
|
b.addEventListener('mousedown', (e) => e.stopPropagation());
|
|
907
909
|
b.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); setChoice(choice); });
|
|
908
910
|
buttons.push({ choice, el: b });
|
|
@@ -1312,11 +1314,14 @@
|
|
|
1312
1314
|
const height = clampHeight(block.height, 320);
|
|
1313
1315
|
const wrap = el('div', { class: 'blk-chart' });
|
|
1314
1316
|
wrap.style.height = height + 'px';
|
|
1317
|
+
const stage = el('div', { class: 'blk-chart-stage' });
|
|
1315
1318
|
const canvas = el('canvas');
|
|
1316
|
-
|
|
1319
|
+
stage.append(canvas);
|
|
1320
|
+
wrap.append(stage);
|
|
1317
1321
|
|
|
1318
1322
|
let chartInst = null;
|
|
1319
1323
|
let chartBadges = [];
|
|
1324
|
+
let naturalSize = null;
|
|
1320
1325
|
|
|
1321
1326
|
const openFor = (target) => (anchor) =>
|
|
1322
1327
|
ctx.annotate.openExternal({ blockId, questionId: ctx.questionId, target }, anchor);
|
|
@@ -1358,7 +1363,7 @@
|
|
|
1358
1363
|
const badge = makeBadge(count, 'blk-chart-badge-pt', openFor(target));
|
|
1359
1364
|
badge.style.left = (canvas.offsetLeft + pos.x) + 'px';
|
|
1360
1365
|
badge.style.top = (canvas.offsetTop + pos.y) + 'px';
|
|
1361
|
-
|
|
1366
|
+
stage.append(badge);
|
|
1362
1367
|
chartBadges.push(badge);
|
|
1363
1368
|
}
|
|
1364
1369
|
}
|
|
@@ -1370,9 +1375,6 @@
|
|
|
1370
1375
|
}
|
|
1371
1376
|
|
|
1372
1377
|
if (ctx.annotate && ctx.annotate.onBadgeRefresh) ctx.annotate.onBadgeRefresh(syncChartBadges);
|
|
1373
|
-
// full-screen + whole-chart comment only; charts redraw responsively (no pixel zoom)
|
|
1374
|
-
attachViewer(wrap, { zoomEl: null, label: 'chart', comment: wholeBlockComment(ctx, blockId, 'chart') });
|
|
1375
|
-
|
|
1376
1378
|
loadChart().then((Chart) => {
|
|
1377
1379
|
let config = block.config
|
|
1378
1380
|
? JSON.parse(JSON.stringify(block.config))
|
|
@@ -1384,17 +1386,33 @@
|
|
|
1384
1386
|
try {
|
|
1385
1387
|
chartInst = new Chart(canvas.getContext('2d'), config);
|
|
1386
1388
|
chartRegistry.push({ chart: chartInst });
|
|
1389
|
+
// Chart.js stays responsive inside a stage whose dimensions the shared
|
|
1390
|
+
// viewer controls. This gives charts the same bounded, eased wheel zoom
|
|
1391
|
+
// as diagrams/images while retaining sharp redraws at every scale.
|
|
1392
|
+
naturalSize = {
|
|
1393
|
+
w: Math.max(1, Math.round(stage.getBoundingClientRect().width || wrap.clientWidth || 640)),
|
|
1394
|
+
h: height,
|
|
1395
|
+
};
|
|
1396
|
+
attachViewer(wrap, {
|
|
1397
|
+
zoomEl: stage,
|
|
1398
|
+
natural: () => naturalSize,
|
|
1399
|
+
fluidFit: true,
|
|
1400
|
+
scaleHeight: true,
|
|
1401
|
+
label: 'chart',
|
|
1402
|
+
comment: wholeBlockComment(ctx, blockId, 'chart'),
|
|
1403
|
+
});
|
|
1387
1404
|
} catch (err) {
|
|
1388
1405
|
wrap.replaceChildren(el('div', { class: 'blk-error' }, 'Chart error: ' + (err && err.message ? err.message : String(err))));
|
|
1389
1406
|
return;
|
|
1390
1407
|
}
|
|
1391
1408
|
// hover -> pointer cursor on a hit
|
|
1392
1409
|
canvas.addEventListener('mousemove', (e) => {
|
|
1410
|
+
if (ctx.canComment === false) { canvas.style.cursor = 'default'; return; }
|
|
1393
1411
|
const hits = chartInst.getElementsAtEventForMode(e, 'nearest', { intersect: true }, true);
|
|
1394
1412
|
canvas.style.cursor = hits.length ? 'pointer' : 'default';
|
|
1395
1413
|
});
|
|
1396
1414
|
canvas.addEventListener('click', (e) => {
|
|
1397
|
-
if (!ctx.annotate) return;
|
|
1415
|
+
if (!ctx.annotate || ctx.canComment === false) return;
|
|
1398
1416
|
const hits = chartInst.getElementsAtEventForMode(e, 'nearest', { intersect: true }, true);
|
|
1399
1417
|
if (!hits.length) return;
|
|
1400
1418
|
const { datasetIndex, index } = hits[0];
|
|
@@ -1449,7 +1467,7 @@
|
|
|
1449
1467
|
entry.code = effectiveMermaidCode(entry);
|
|
1450
1468
|
mermaidRegistry.push(entry);
|
|
1451
1469
|
|
|
1452
|
-
if (!block.editable) {
|
|
1470
|
+
if (!block.editable || ctx.canEditBlocks === false) {
|
|
1453
1471
|
drawMermaid(entry);
|
|
1454
1472
|
return container;
|
|
1455
1473
|
}
|
|
@@ -1922,10 +1940,11 @@
|
|
|
1922
1940
|
function enableImagePins(container, img, ctx, blockId, label) {
|
|
1923
1941
|
const layer = el('div', { class: 'blk-imgpins' });
|
|
1924
1942
|
container.append(layer);
|
|
1925
|
-
img.classList.add('blk-img-pinnable');
|
|
1943
|
+
if (ctx.canComment !== false) img.classList.add('blk-img-pinnable');
|
|
1926
1944
|
let down = null; // {x,y} at pointerdown — used to reject drags (pan) as clicks
|
|
1927
1945
|
img.addEventListener('pointerdown', (e) => { down = { x: e.clientX, y: e.clientY }; });
|
|
1928
1946
|
img.addEventListener('click', (e) => {
|
|
1947
|
+
if (ctx.canComment === false) return;
|
|
1929
1948
|
if (down && (Math.abs(e.clientX - down.x) > 4 || Math.abs(e.clientY - down.y) > 4)) { down = null; return; }
|
|
1930
1949
|
const r = img.getBoundingClientRect();
|
|
1931
1950
|
if (!r.width || !r.height) return;
|
|
@@ -2132,6 +2151,15 @@
|
|
|
2132
2151
|
// full-screen content insets — must mirror the .blk-full padding in blocks.css
|
|
2133
2152
|
// so the fit-to-screen scale clears the fixed toolbar + leaves a small margin.
|
|
2134
2153
|
const FULL_PAD_X = 24, FULL_PAD_TOP = 44, FULL_PAD_BOTTOM = 24;
|
|
2154
|
+
// Wheel/pinch zoom is delta-proportional and animated. The old fixed 15% jump
|
|
2155
|
+
// ran once for every wheel event, so a high-frequency trackpad gesture could
|
|
2156
|
+
// compound to several hundred percent almost instantly. Cap the contribution
|
|
2157
|
+
// of one event (mouse wheels often report ±100px) and ease toward the gesture's
|
|
2158
|
+
// accumulated target at most once per animation frame.
|
|
2159
|
+
const WHEEL_ZOOM_SENSITIVITY = 0.003;
|
|
2160
|
+
const WHEEL_ZOOM_MAX_DELTA = 20;
|
|
2161
|
+
const WHEEL_ZOOM_EASE = 0.24;
|
|
2162
|
+
const WHEEL_ZOOM_EPSILON = 0.001;
|
|
2135
2163
|
|
|
2136
2164
|
// Toolbar icons: full-screen (4-corner expand) and a speech-bubble for the
|
|
2137
2165
|
// "comment on the whole block" button. Zoom is both cmd/ctrl+wheel AND
|
|
@@ -2146,6 +2174,7 @@
|
|
|
2146
2174
|
function exitFull() {
|
|
2147
2175
|
if (!fullOpen) return;
|
|
2148
2176
|
const c = fullOpen;
|
|
2177
|
+
if (c._rlyStopWheelZoom) c._rlyStopWheelZoom();
|
|
2149
2178
|
c.classList.remove('blk-full');
|
|
2150
2179
|
document.body.classList.remove('blk-full-open');
|
|
2151
2180
|
const btn = c.querySelector('.blk-tools .tool-full');
|
|
@@ -2164,6 +2193,7 @@
|
|
|
2164
2193
|
function enterFull(container) {
|
|
2165
2194
|
if (fullOpen === container) return;
|
|
2166
2195
|
exitFull();
|
|
2196
|
+
if (container._rlyStopWheelZoom) container._rlyStopWheelZoom();
|
|
2167
2197
|
container.classList.add('blk-full');
|
|
2168
2198
|
document.body.classList.add('blk-full-open');
|
|
2169
2199
|
fullOpen = container;
|
|
@@ -2306,7 +2336,7 @@
|
|
|
2306
2336
|
// with a block-scoped target ("Whole chart/diagram/…") so a user can comment
|
|
2307
2337
|
// on the visual as a whole. null when annotation is off (omits the button).
|
|
2308
2338
|
function wholeBlockComment(ctx, blockId, label) {
|
|
2309
|
-
if (!ctx || !ctx.annotate) return null;
|
|
2339
|
+
if (!ctx || !ctx.annotate || ctx.canComment === false) return null;
|
|
2310
2340
|
const a = ctx.annotate;
|
|
2311
2341
|
return {
|
|
2312
2342
|
open: (anchorEl) =>
|
|
@@ -2352,6 +2382,10 @@
|
|
|
2352
2382
|
// zoom level persists across re-renders; the wheel handler (bound once)
|
|
2353
2383
|
// delegates through container._rlyZoom so it never holds a stale zoomEl
|
|
2354
2384
|
if (container._rlyZ === undefined) container._rlyZ = null; // null = fit-to-width
|
|
2385
|
+
// Fluid visuals (currently Chart.js) fit the live container width. Capture
|
|
2386
|
+
// that rendered size only when leaving fit mode so zoom remains proportional
|
|
2387
|
+
// without freezing later fit-mode viewport growth to an old width.
|
|
2388
|
+
let fluidZoomNatural = null;
|
|
2355
2389
|
const pct = el('span', { class: 'tool-pct' }, 'fit');
|
|
2356
2390
|
function apply() {
|
|
2357
2391
|
if (!zoomable) return;
|
|
@@ -2377,17 +2411,18 @@
|
|
|
2377
2411
|
const scale = Math.min(availW / nat.w, availH / nat.h);
|
|
2378
2412
|
target.style.maxWidth = 'none';
|
|
2379
2413
|
target.style.width = Math.max(1, Math.round(nat.w * scale)) + 'px';
|
|
2380
|
-
target.style.height = 'auto';
|
|
2414
|
+
target.style.height = opts.scaleHeight ? Math.max(1, Math.round(nat.h * scale)) + 'px' : 'auto';
|
|
2381
2415
|
pct.textContent = 'fit';
|
|
2382
2416
|
} else if (z === null || !nat || !nat.w) {
|
|
2383
2417
|
target.style.width = '100%';
|
|
2384
|
-
target.style.maxWidth = nat && nat.w ? Math.ceil(nat.w) + 'px' : '100%';
|
|
2385
|
-
target.style.height = 'auto';
|
|
2418
|
+
target.style.maxWidth = opts.fluidFit ? '100%' : (nat && nat.w ? Math.ceil(nat.w) + 'px' : '100%');
|
|
2419
|
+
target.style.height = opts.scaleHeight ? '100%' : 'auto';
|
|
2386
2420
|
pct.textContent = 'fit';
|
|
2387
2421
|
} else {
|
|
2422
|
+
const zoomNat = fluidZoomNatural || nat;
|
|
2388
2423
|
target.style.maxWidth = 'none';
|
|
2389
|
-
target.style.width = Math.round(
|
|
2390
|
-
target.style.height = 'auto';
|
|
2424
|
+
target.style.width = Math.round(zoomNat.w * z) + 'px';
|
|
2425
|
+
target.style.height = opts.scaleHeight && zoomNat && zoomNat.h ? Math.round(zoomNat.h * z) + 'px' : 'auto';
|
|
2391
2426
|
pct.textContent = Math.round(z * 100) + '%';
|
|
2392
2427
|
}
|
|
2393
2428
|
window.dispatchEvent(new Event('resize')); // annotation badges reposition
|
|
@@ -2396,17 +2431,38 @@
|
|
|
2396
2431
|
}
|
|
2397
2432
|
function currentZ() {
|
|
2398
2433
|
if (container._rlyZ !== null) return container._rlyZ;
|
|
2434
|
+
if (opts.fluidFit) return 1;
|
|
2399
2435
|
const nat = opts.natural();
|
|
2400
2436
|
if (!nat || !nat.w) return 1;
|
|
2401
2437
|
const shown = opts.zoomEl.getBoundingClientRect().width;
|
|
2402
2438
|
return shown > 0 ? shown / nat.w : 1;
|
|
2403
2439
|
}
|
|
2404
2440
|
function setZoom(next) {
|
|
2441
|
+
if (next === null) {
|
|
2442
|
+
fluidZoomNatural = null;
|
|
2443
|
+
} else if (opts.fluidFit && container._rlyZ === null) {
|
|
2444
|
+
const shown = opts.zoomEl.getBoundingClientRect();
|
|
2445
|
+
fluidZoomNatural = {
|
|
2446
|
+
w: Math.max(1, shown.width),
|
|
2447
|
+
h: Math.max(1, shown.height),
|
|
2448
|
+
};
|
|
2449
|
+
}
|
|
2405
2450
|
container._rlyZ = next === null ? null : Math.min(8, Math.max(0.2, next));
|
|
2406
2451
|
apply();
|
|
2407
2452
|
}
|
|
2408
2453
|
container._rlyZoom = { setZoom, currentZ, reapply: apply };
|
|
2409
2454
|
|
|
2455
|
+
// Stop any in-flight wheel easing before an explicit toolbar/full-screen
|
|
2456
|
+
// action. This prevents an older gesture target from overriding that action
|
|
2457
|
+
// on the next animation frame.
|
|
2458
|
+
if (!container._rlyStopWheelZoom) {
|
|
2459
|
+
container._rlyStopWheelZoom = () => {
|
|
2460
|
+
if (container._rlyWheelFrame) cancelAnimationFrame(container._rlyWheelFrame);
|
|
2461
|
+
container._rlyWheelFrame = null;
|
|
2462
|
+
container._rlyWheelTarget = null;
|
|
2463
|
+
};
|
|
2464
|
+
}
|
|
2465
|
+
|
|
2410
2466
|
const tools = el('div', { class: 'blk-tools' });
|
|
2411
2467
|
|
|
2412
2468
|
// comment on the whole block (leftmost) — opens the annotation popover with
|
|
@@ -2435,18 +2491,53 @@
|
|
|
2435
2491
|
const onWheel = (e) => {
|
|
2436
2492
|
if (!(e.ctrlKey || e.metaKey) || !container._rlyZoom) return;
|
|
2437
2493
|
e.preventDefault();
|
|
2438
|
-
|
|
2494
|
+
// Normalize line/page-mode devices to pixel-like units, then cap a
|
|
2495
|
+
// single event. Exponential scaling makes equal motion reversible and
|
|
2496
|
+
// lets tiny trackpad deltas stay tiny instead of becoming fixed jumps.
|
|
2497
|
+
const unit = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? window.innerHeight : 1;
|
|
2498
|
+
const delta = Math.max(-WHEEL_ZOOM_MAX_DELTA, Math.min(WHEEL_ZOOM_MAX_DELTA, e.deltaY * unit));
|
|
2499
|
+
const base = Number.isFinite(container._rlyWheelTarget)
|
|
2500
|
+
? container._rlyWheelTarget
|
|
2501
|
+
: container._rlyZoom.currentZ();
|
|
2502
|
+
container._rlyWheelTarget = Math.min(8, Math.max(0.2, base * Math.exp(-delta * WHEEL_ZOOM_SENSITIVITY)));
|
|
2503
|
+
|
|
2504
|
+
// Respect reduced-motion while retaining the gentler delta-based step.
|
|
2505
|
+
if (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
|
2506
|
+
container._rlyZoom.setZoom(container._rlyWheelTarget);
|
|
2507
|
+
container._rlyWheelTarget = null;
|
|
2508
|
+
return;
|
|
2509
|
+
}
|
|
2510
|
+
if (container._rlyWheelFrame) return; // coalesce bursty input to one rAF loop
|
|
2511
|
+
const tick = () => {
|
|
2512
|
+
const zoom = container._rlyZoom;
|
|
2513
|
+
const target = container._rlyWheelTarget;
|
|
2514
|
+
if (!zoom || !Number.isFinite(target)) {
|
|
2515
|
+
container._rlyWheelFrame = null;
|
|
2516
|
+
return;
|
|
2517
|
+
}
|
|
2518
|
+
const current = zoom.currentZ();
|
|
2519
|
+
const gap = target - current;
|
|
2520
|
+
if (Math.abs(gap) <= WHEEL_ZOOM_EPSILON) {
|
|
2521
|
+
zoom.setZoom(target);
|
|
2522
|
+
container._rlyWheelFrame = null;
|
|
2523
|
+
container._rlyWheelTarget = null;
|
|
2524
|
+
return;
|
|
2525
|
+
}
|
|
2526
|
+
zoom.setZoom(current + gap * WHEEL_ZOOM_EASE);
|
|
2527
|
+
container._rlyWheelFrame = requestAnimationFrame(tick);
|
|
2528
|
+
};
|
|
2529
|
+
container._rlyWheelFrame = requestAnimationFrame(tick);
|
|
2439
2530
|
};
|
|
2440
2531
|
container.addEventListener('wheel', onWheel, { passive: false });
|
|
2441
2532
|
container._rlyWheel = onWheel;
|
|
2442
2533
|
}
|
|
2443
2534
|
const zoomOut = el('button', { class: 'tool-zoom', type: 'button', title: 'Zoom out' }, '−');
|
|
2444
2535
|
const zoomIn = el('button', { class: 'tool-zoom', type: 'button', title: 'Zoom in' }, '+');
|
|
2445
|
-
zoomOut.addEventListener('click', (e) => { e.stopPropagation(); setZoom(currentZ() / 1.2); });
|
|
2446
|
-
zoomIn.addEventListener('click', (e) => { e.stopPropagation(); setZoom(currentZ() * 1.2); });
|
|
2536
|
+
zoomOut.addEventListener('click', (e) => { e.stopPropagation(); container._rlyStopWheelZoom(); setZoom(currentZ() / 1.2); });
|
|
2537
|
+
zoomIn.addEventListener('click', (e) => { e.stopPropagation(); container._rlyStopWheelZoom(); setZoom(currentZ() * 1.2); });
|
|
2447
2538
|
pct.classList.add('is-btn');
|
|
2448
2539
|
pct.title = 'Reset to fit';
|
|
2449
|
-
pct.addEventListener('click', (e) => { e.stopPropagation(); setZoom(null); });
|
|
2540
|
+
pct.addEventListener('click', (e) => { e.stopPropagation(); container._rlyStopWheelZoom(); setZoom(null); });
|
|
2450
2541
|
tools.append(zoomOut, pct, zoomIn);
|
|
2451
2542
|
}
|
|
2452
2543
|
|
|
@@ -2586,6 +2677,8 @@
|
|
|
2586
2677
|
// editable-mermaid plumbing: edits maps blockId -> edited code; onBlockEdit
|
|
2587
2678
|
// reports an accepted change (or null to clear back to the original).
|
|
2588
2679
|
edits: ctx && ctx.edits ? ctx.edits : {},
|
|
2680
|
+
canComment: !ctx || ctx.canComment !== false,
|
|
2681
|
+
canEditBlocks: !ctx || ctx.canEditBlocks !== false,
|
|
2589
2682
|
onBlockEdit:
|
|
2590
2683
|
ctx && typeof ctx.onBlockEdit === 'function' ? ctx.onBlockEdit : () => {},
|
|
2591
2684
|
};
|