@khanglvm/relay 0.14.2 → 0.16.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 +18 -3
- package/docs/AGENT.md +66 -23
- package/package.json +1 -1
- package/skills/relay/SKILL.md +48 -12
- package/src/cli.js +42 -21
- package/src/mcp-ui/board.js +46 -8
- package/src/mcp.js +9 -3
- package/src/server.js +310 -95
- package/src/spec.js +5 -0
- package/src/store.js +15 -0
- package/src/ui/annotate.js +73 -26
- package/src/ui/app.js +100 -36
- package/src/ui/blocks.css +63 -11
- package/src/ui/blocks.js +353 -48
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
|
}
|
|
@@ -1883,7 +1901,9 @@
|
|
|
1883
1901
|
src,
|
|
1884
1902
|
alt: block.alt || 'image',
|
|
1885
1903
|
loading: 'lazy',
|
|
1904
|
+
title: ctx.canComment === false ? '' : 'Hold briefly, then drag to comment on an area',
|
|
1886
1905
|
});
|
|
1906
|
+
const stage = el('div', { class: 'blk-imgstage' }, img);
|
|
1887
1907
|
// Inline default: the image fills its full width (container width, never
|
|
1888
1908
|
// upscaled past natural) so it's readable without manual zoom; the CONTAINER
|
|
1889
1909
|
// caps the height (default 800 via CSS, or block.height) and SCROLLS — we
|
|
@@ -1893,18 +1913,30 @@
|
|
|
1893
1913
|
img.addEventListener('error', () => {
|
|
1894
1914
|
container.replaceChildren(el('div', { class: 'blk-error' }, 'Image failed to load'));
|
|
1895
1915
|
});
|
|
1896
|
-
container.append(
|
|
1916
|
+
container.append(stage);
|
|
1897
1917
|
const attachImgViewer = () =>
|
|
1898
1918
|
attachViewer(container, {
|
|
1899
|
-
zoomEl:
|
|
1919
|
+
zoomEl: stage,
|
|
1900
1920
|
natural: () => (img.naturalWidth > 0 ? { w: img.naturalWidth, h: img.naturalHeight } : null),
|
|
1901
1921
|
label: 'image',
|
|
1902
1922
|
comment: wholeBlockComment(ctx, blockId, 'image'),
|
|
1903
1923
|
});
|
|
1904
1924
|
if (img.complete && img.naturalWidth > 0) attachImgViewer();
|
|
1905
1925
|
else img.addEventListener('load', attachImgViewer, { once: true });
|
|
1926
|
+
if (ctx.annotate && ctx.canComment !== false) {
|
|
1927
|
+
enableImageRegions({
|
|
1928
|
+
host: stage,
|
|
1929
|
+
surface: img,
|
|
1930
|
+
panHost: container,
|
|
1931
|
+
sourceForSide: () => img,
|
|
1932
|
+
sideAtPoint: () => null,
|
|
1933
|
+
ctx,
|
|
1934
|
+
blockId,
|
|
1935
|
+
label: block.alt || 'Image',
|
|
1936
|
+
});
|
|
1937
|
+
}
|
|
1906
1938
|
if (ctx.annotate && block.pins === true) {
|
|
1907
|
-
enableImagePins(
|
|
1939
|
+
enableImagePins(stage, img, ctx, blockId, block.alt || 'Image');
|
|
1908
1940
|
} else if (ctx.annotate) {
|
|
1909
1941
|
ctx.annotate.register(img, {
|
|
1910
1942
|
blockId,
|
|
@@ -1922,10 +1954,12 @@
|
|
|
1922
1954
|
function enableImagePins(container, img, ctx, blockId, label) {
|
|
1923
1955
|
const layer = el('div', { class: 'blk-imgpins' });
|
|
1924
1956
|
container.append(layer);
|
|
1925
|
-
img.classList.add('blk-img-pinnable');
|
|
1957
|
+
if (ctx.canComment !== false) img.classList.add('blk-img-pinnable');
|
|
1926
1958
|
let down = null; // {x,y} at pointerdown — used to reject drags (pan) as clicks
|
|
1927
1959
|
img.addEventListener('pointerdown', (e) => { down = { x: e.clientX, y: e.clientY }; });
|
|
1928
1960
|
img.addEventListener('click', (e) => {
|
|
1961
|
+
if (ctx.canComment === false) return;
|
|
1962
|
+
if (img._rlySuppressPointClick) { img._rlySuppressPointClick = false; down = null; return; }
|
|
1929
1963
|
if (down && (Math.abs(e.clientX - down.x) > 4 || Math.abs(e.clientY - down.y) > 4)) { down = null; return; }
|
|
1930
1964
|
const r = img.getBoundingClientRect();
|
|
1931
1965
|
if (!r.width || !r.height) return;
|
|
@@ -1965,6 +1999,192 @@
|
|
|
1965
1999
|
syncPins();
|
|
1966
2000
|
}
|
|
1967
2001
|
|
|
2002
|
+
// Hold, then drag, to select a rectangular image area. A short move remains
|
|
2003
|
+
// native viewer pan / comparison-slider input; the hold threshold makes the
|
|
2004
|
+
// two gestures coexist without a mode switch. Region coordinates are stored
|
|
2005
|
+
// as normalized fractions, while a pixel crop is uploaded through the host
|
|
2006
|
+
// context so the result gives the agent an actual viewable image artifact.
|
|
2007
|
+
function enableImageRegions({ host, surface, panHost, sourceForSide, sideAtPoint, ctx, blockId, label }) {
|
|
2008
|
+
const layer = el('div', { class: 'blk-region-layer' });
|
|
2009
|
+
const selection = el('div', { class: 'blk-region-selection' });
|
|
2010
|
+
layer.append(selection);
|
|
2011
|
+
host.append(layer);
|
|
2012
|
+
let pending = null;
|
|
2013
|
+
let active = false;
|
|
2014
|
+
let holdTimer = 0;
|
|
2015
|
+
let status = null;
|
|
2016
|
+
const HOLD_MS = 280;
|
|
2017
|
+
const MIN_REGION = 0.012;
|
|
2018
|
+
|
|
2019
|
+
const localPoint = (e) => {
|
|
2020
|
+
const r = host.getBoundingClientRect();
|
|
2021
|
+
return {
|
|
2022
|
+
x: r.width ? Math.max(0, Math.min(1, (e.clientX - r.left) / r.width)) : 0,
|
|
2023
|
+
y: r.height ? Math.max(0, Math.min(1, (e.clientY - r.top) / r.height)) : 0,
|
|
2024
|
+
};
|
|
2025
|
+
};
|
|
2026
|
+
const clearStatus = () => { if (status) status.remove(); status = null; };
|
|
2027
|
+
const showStatus = (text) => {
|
|
2028
|
+
clearStatus();
|
|
2029
|
+
status = el('div', { class: 'blk-region-status', role: 'status' }, text);
|
|
2030
|
+
host.append(status);
|
|
2031
|
+
};
|
|
2032
|
+
const reset = () => {
|
|
2033
|
+
clearTimeout(holdTimer);
|
|
2034
|
+
holdTimer = 0;
|
|
2035
|
+
active = false;
|
|
2036
|
+
pending = null;
|
|
2037
|
+
panHost._rlyRegionSelecting = false;
|
|
2038
|
+
host.classList.remove('blk-region-arming', 'blk-region-selecting');
|
|
2039
|
+
selection.style.display = 'none';
|
|
2040
|
+
clearStatus();
|
|
2041
|
+
};
|
|
2042
|
+
const draw = (a, b) => {
|
|
2043
|
+
const x = Math.min(a.x, b.x), y = Math.min(a.y, b.y);
|
|
2044
|
+
const w = Math.abs(a.x - b.x), h = Math.abs(a.y - b.y);
|
|
2045
|
+
selection.style.left = (x * 100) + '%';
|
|
2046
|
+
selection.style.top = (y * 100) + '%';
|
|
2047
|
+
selection.style.width = (w * 100) + '%';
|
|
2048
|
+
selection.style.height = (h * 100) + '%';
|
|
2049
|
+
return { x, y, w, h };
|
|
2050
|
+
};
|
|
2051
|
+
const beginSelection = (pointerId) => {
|
|
2052
|
+
if (!pending || active) return;
|
|
2053
|
+
active = true;
|
|
2054
|
+
panHost._rlyRegionSelecting = true;
|
|
2055
|
+
host.classList.remove('blk-region-arming');
|
|
2056
|
+
host.classList.add('blk-region-selecting');
|
|
2057
|
+
selection.style.display = 'block';
|
|
2058
|
+
draw(pending.start, pending.start);
|
|
2059
|
+
showStatus((pending.side ? pending.side.charAt(0).toUpperCase() + pending.side.slice(1) + ' · ' : '') + 'drag to select an area');
|
|
2060
|
+
try { surface.setPointerCapture(pointerId); } catch (_) {}
|
|
2061
|
+
};
|
|
2062
|
+
|
|
2063
|
+
async function cropRegion(img, region) {
|
|
2064
|
+
if (!img || !img.naturalWidth || !img.naturalHeight) throw new Error('image is not ready');
|
|
2065
|
+
const sx = Math.max(0, Math.floor(region.x * img.naturalWidth));
|
|
2066
|
+
const sy = Math.max(0, Math.floor(region.y * img.naturalHeight));
|
|
2067
|
+
const sw = Math.max(1, Math.min(img.naturalWidth - sx, Math.round(region.w * img.naturalWidth)));
|
|
2068
|
+
const sh = Math.max(1, Math.min(img.naturalHeight - sy, Math.round(region.h * img.naturalHeight)));
|
|
2069
|
+
const scale = Math.min(1, 1400 / sw, 1400 / sh);
|
|
2070
|
+
const width = Math.max(1, Math.round(sw * scale));
|
|
2071
|
+
const height = Math.max(1, Math.round(sh * scale));
|
|
2072
|
+
const canvas = document.createElement('canvas');
|
|
2073
|
+
canvas.width = width;
|
|
2074
|
+
canvas.height = height;
|
|
2075
|
+
const g = canvas.getContext('2d');
|
|
2076
|
+
if (!g) throw new Error('canvas unavailable');
|
|
2077
|
+
g.drawImage(img, sx, sy, sw, sh, 0, 0, width, height);
|
|
2078
|
+
const dataUrl = canvas.toDataURL('image/png');
|
|
2079
|
+
if (!/^data:image\/png;base64,/.test(dataUrl)) throw new Error('crop unavailable');
|
|
2080
|
+
if (ctx.saveArtifact) return ctx.saveArtifact({ dataUrl, mime: 'image/png', width, height, blockId });
|
|
2081
|
+
return { data: dataUrl.slice(dataUrl.indexOf(',') + 1), mime: 'image/png', width, height };
|
|
2082
|
+
}
|
|
2083
|
+
|
|
2084
|
+
const finishRegion = async (region, side) => {
|
|
2085
|
+
const target = {
|
|
2086
|
+
kind: 'image-region',
|
|
2087
|
+
x: Math.round(region.x * 10000) / 10000,
|
|
2088
|
+
y: Math.round(region.y * 10000) / 10000,
|
|
2089
|
+
w: Math.round(region.w * 10000) / 10000,
|
|
2090
|
+
h: Math.round(region.h * 10000) / 10000,
|
|
2091
|
+
side: side || undefined,
|
|
2092
|
+
label,
|
|
2093
|
+
};
|
|
2094
|
+
showStatus('Saving selected area…');
|
|
2095
|
+
try {
|
|
2096
|
+
target.crop = await cropRegion(sourceForSide(side), region);
|
|
2097
|
+
} catch {
|
|
2098
|
+
target.cropUnavailable = true;
|
|
2099
|
+
}
|
|
2100
|
+
clearStatus();
|
|
2101
|
+
ctx.annotate.openExternal({ blockId, questionId: ctx.questionId, target }, host);
|
|
2102
|
+
};
|
|
2103
|
+
|
|
2104
|
+
surface.addEventListener('pointerdown', (e) => {
|
|
2105
|
+
if (e.button !== 0 || ctx.canComment === false) return;
|
|
2106
|
+
if (e.target.closest && e.target.closest('.cmp-handle, .blk-imgregion, .blk-tools')) return;
|
|
2107
|
+
const p = localPoint(e);
|
|
2108
|
+
pending = {
|
|
2109
|
+
start: p,
|
|
2110
|
+
last: p,
|
|
2111
|
+
clientX: e.clientX,
|
|
2112
|
+
clientY: e.clientY,
|
|
2113
|
+
pointerId: e.pointerId,
|
|
2114
|
+
startedAt: performance.now(),
|
|
2115
|
+
side: sideAtPoint(p.x),
|
|
2116
|
+
};
|
|
2117
|
+
host.classList.add('blk-region-arming');
|
|
2118
|
+
holdTimer = setTimeout(() => beginSelection(e.pointerId), HOLD_MS);
|
|
2119
|
+
});
|
|
2120
|
+
surface.addEventListener('pointermove', (e) => {
|
|
2121
|
+
if (!pending) return;
|
|
2122
|
+
const moved = Math.hypot(e.clientX - pending.clientX, e.clientY - pending.clientY);
|
|
2123
|
+
// Timers can be throttled while an automation host or background tab owns
|
|
2124
|
+
// the event loop. The first post-hold move is also authoritative, so a
|
|
2125
|
+
// genuine hold still enters area mode even if setTimeout fired late.
|
|
2126
|
+
if (!active && performance.now() - pending.startedAt >= HOLD_MS) beginSelection(pending.pointerId);
|
|
2127
|
+
if (!active) {
|
|
2128
|
+
if (moved > 5) reset();
|
|
2129
|
+
return;
|
|
2130
|
+
}
|
|
2131
|
+
e.preventDefault();
|
|
2132
|
+
pending.last = localPoint(e);
|
|
2133
|
+
draw(pending.start, pending.last);
|
|
2134
|
+
});
|
|
2135
|
+
const end = (e) => {
|
|
2136
|
+
if (!pending) return;
|
|
2137
|
+
clearTimeout(holdTimer);
|
|
2138
|
+
if (!active) { reset(); return; }
|
|
2139
|
+
e.preventDefault();
|
|
2140
|
+
const region = draw(pending.start, pending.last || localPoint(e));
|
|
2141
|
+
const side = pending.side;
|
|
2142
|
+
surface._rlySuppressPointClick = true;
|
|
2143
|
+
reset();
|
|
2144
|
+
clearStatus();
|
|
2145
|
+
if (region.w >= MIN_REGION && region.h >= MIN_REGION) finishRegion(region, side);
|
|
2146
|
+
};
|
|
2147
|
+
surface.addEventListener('pointerup', end);
|
|
2148
|
+
// Some Chromium/CDP paths emit pointercancel immediately after granting
|
|
2149
|
+
// capture on the first dragged frame. Once area mode is active, keep the
|
|
2150
|
+
// last drawn rectangle instead of throwing the user's deliberate hold away.
|
|
2151
|
+
surface.addEventListener('pointercancel', (e) => active ? end(e) : reset());
|
|
2152
|
+
|
|
2153
|
+
const syncRegions = () => {
|
|
2154
|
+
for (const node of Array.from(layer.querySelectorAll('.blk-imgregion'))) node.remove();
|
|
2155
|
+
const groups = new Map();
|
|
2156
|
+
for (const a of ctx.annotate.list()) {
|
|
2157
|
+
if (a.blockId !== blockId || !a.target || a.target.kind !== 'image-region') continue;
|
|
2158
|
+
const t = a.target;
|
|
2159
|
+
const key = [t.side || '', t.x, t.y, t.w, t.h].join(':');
|
|
2160
|
+
const group = groups.get(key) || { target: t, count: 0 };
|
|
2161
|
+
group.count++;
|
|
2162
|
+
groups.set(key, group);
|
|
2163
|
+
}
|
|
2164
|
+
for (const { target, count } of groups.values()) {
|
|
2165
|
+
const region = el('button', {
|
|
2166
|
+
class: 'blk-imgregion',
|
|
2167
|
+
type: 'button',
|
|
2168
|
+
'data-count': String(count),
|
|
2169
|
+
title: `${target.side ? target.side + ' · ' : ''}${count} ${count === 1 ? 'comment' : 'comments'}`,
|
|
2170
|
+
'aria-label': `${target.side ? target.side + ' image area' : 'image area'} with ${count} ${count === 1 ? 'comment' : 'comments'}`,
|
|
2171
|
+
});
|
|
2172
|
+
region.style.left = (target.x * 100) + '%';
|
|
2173
|
+
region.style.top = (target.y * 100) + '%';
|
|
2174
|
+
region.style.width = (target.w * 100) + '%';
|
|
2175
|
+
region.style.height = (target.h * 100) + '%';
|
|
2176
|
+
region.addEventListener('pointerdown', (e) => e.stopPropagation());
|
|
2177
|
+
region.addEventListener('click', (e) => {
|
|
2178
|
+
e.stopPropagation();
|
|
2179
|
+
ctx.annotate.openExternal({ blockId, questionId: ctx.questionId, target }, region);
|
|
2180
|
+
});
|
|
2181
|
+
layer.append(region);
|
|
2182
|
+
}
|
|
2183
|
+
};
|
|
2184
|
+
if (ctx.annotate.onBadgeRefresh) ctx.annotate.onBadgeRefresh(syncRegions);
|
|
2185
|
+
syncRegions();
|
|
2186
|
+
}
|
|
2187
|
+
|
|
1968
2188
|
// ---------- palette ----------
|
|
1969
2189
|
// Color palettes as swatch cards: hover a swatch to reveal its hex, click to
|
|
1970
2190
|
// copy it. One palette may be {featured:true} → a larger spotlight row. Lets
|
|
@@ -2106,18 +2326,46 @@
|
|
|
2106
2326
|
window.addEventListener('resize', sizeBefore);
|
|
2107
2327
|
|
|
2108
2328
|
let dragging = false;
|
|
2329
|
+
let dragMoved = false;
|
|
2330
|
+
let dragStartX = 0;
|
|
2109
2331
|
const setFromX = (clientX) => {
|
|
2110
2332
|
const r = frame.getBoundingClientRect();
|
|
2111
2333
|
if (r.width) { pos = Math.max(0, Math.min(100, ((clientX - r.left) / r.width) * 100)); apply(); }
|
|
2112
2334
|
};
|
|
2113
|
-
frame.addEventListener('pointerdown', (e) => {
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2335
|
+
frame.addEventListener('pointerdown', (e) => {
|
|
2336
|
+
if (e.target.closest && e.target.closest('.blk-imgregion')) return;
|
|
2337
|
+
dragging = true;
|
|
2338
|
+
dragMoved = false;
|
|
2339
|
+
dragStartX = e.clientX;
|
|
2340
|
+
try { frame.setPointerCapture(e.pointerId); } catch (_) {}
|
|
2341
|
+
});
|
|
2342
|
+
frame.addEventListener('pointermove', (e) => {
|
|
2343
|
+
if (!dragging || frame._rlyRegionSelecting) return;
|
|
2344
|
+
if (Math.abs(e.clientX - dragStartX) > 3) dragMoved = true;
|
|
2345
|
+
if (dragMoved) { setFromX(e.clientX); e.preventDefault(); }
|
|
2346
|
+
});
|
|
2347
|
+
frame.addEventListener('pointerup', (e) => {
|
|
2348
|
+
if (dragging && !dragMoved && !frame._rlyRegionSelecting) setFromX(e.clientX);
|
|
2349
|
+
dragging = false;
|
|
2350
|
+
});
|
|
2351
|
+
frame.addEventListener('pointercancel', () => { dragging = false; dragMoved = false; });
|
|
2117
2352
|
handle.addEventListener('keydown', (e) => {
|
|
2118
2353
|
if (e.key === 'ArrowLeft') { pos = Math.max(0, pos - 2); apply(); e.preventDefault(); }
|
|
2119
2354
|
else if (e.key === 'ArrowRight') { pos = Math.min(100, pos + 2); apply(); e.preventDefault(); }
|
|
2120
2355
|
});
|
|
2356
|
+
if (ctx.annotate && ctx.canComment !== false) {
|
|
2357
|
+
frame.title = 'Hold briefly, then drag to comment on the visible Before or After image';
|
|
2358
|
+
enableImageRegions({
|
|
2359
|
+
host: frame,
|
|
2360
|
+
surface: frame,
|
|
2361
|
+
panHost: frame,
|
|
2362
|
+
sourceForSide: (side) => side === 'before' ? beforeImg : afterImg,
|
|
2363
|
+
sideAtPoint: (x) => (x * 100 <= pos ? 'before' : 'after'),
|
|
2364
|
+
ctx,
|
|
2365
|
+
blockId,
|
|
2366
|
+
label: 'Comparison',
|
|
2367
|
+
});
|
|
2368
|
+
}
|
|
2121
2369
|
attachViewer(wrap, { zoomEl: null, label: 'comparison', comment: wholeBlockComment(ctx, blockId, 'comparison') });
|
|
2122
2370
|
return wrap;
|
|
2123
2371
|
}
|
|
@@ -2132,6 +2380,15 @@
|
|
|
2132
2380
|
// full-screen content insets — must mirror the .blk-full padding in blocks.css
|
|
2133
2381
|
// so the fit-to-screen scale clears the fixed toolbar + leaves a small margin.
|
|
2134
2382
|
const FULL_PAD_X = 24, FULL_PAD_TOP = 44, FULL_PAD_BOTTOM = 24;
|
|
2383
|
+
// Wheel/pinch zoom is delta-proportional and animated. The old fixed 15% jump
|
|
2384
|
+
// ran once for every wheel event, so a high-frequency trackpad gesture could
|
|
2385
|
+
// compound to several hundred percent almost instantly. Cap the contribution
|
|
2386
|
+
// of one event (mouse wheels often report ±100px) and ease toward the gesture's
|
|
2387
|
+
// accumulated target at most once per animation frame.
|
|
2388
|
+
const WHEEL_ZOOM_SENSITIVITY = 0.003;
|
|
2389
|
+
const WHEEL_ZOOM_MAX_DELTA = 20;
|
|
2390
|
+
const WHEEL_ZOOM_EASE = 0.24;
|
|
2391
|
+
const WHEEL_ZOOM_EPSILON = 0.001;
|
|
2135
2392
|
|
|
2136
2393
|
// Toolbar icons: full-screen (4-corner expand) and a speech-bubble for the
|
|
2137
2394
|
// "comment on the whole block" button. Zoom is both cmd/ctrl+wheel AND
|
|
@@ -2146,13 +2403,13 @@
|
|
|
2146
2403
|
function exitFull() {
|
|
2147
2404
|
if (!fullOpen) return;
|
|
2148
2405
|
const c = fullOpen;
|
|
2406
|
+
if (c._rlyStopWheelZoom) c._rlyStopWheelZoom();
|
|
2149
2407
|
c.classList.remove('blk-full');
|
|
2150
2408
|
document.body.classList.remove('blk-full-open');
|
|
2151
2409
|
const btn = c.querySelector('.blk-tools .tool-full');
|
|
2152
2410
|
if (btn) btn.innerHTML = ICON_EXPAND;
|
|
2153
2411
|
fullOpen = null;
|
|
2154
2412
|
window.dispatchEvent(new Event('resize'));
|
|
2155
|
-
if (c._rlyToolsSync) c._rlyToolsSync(); // re-pin toolbar to its scrolled corner
|
|
2156
2413
|
if (c._rlyZoom) c._rlyZoom.reapply(); // restore the compact inline height cap
|
|
2157
2414
|
}
|
|
2158
2415
|
document.addEventListener('keydown', (e) => {
|
|
@@ -2164,13 +2421,13 @@
|
|
|
2164
2421
|
function enterFull(container) {
|
|
2165
2422
|
if (fullOpen === container) return;
|
|
2166
2423
|
exitFull();
|
|
2424
|
+
if (container._rlyStopWheelZoom) container._rlyStopWheelZoom();
|
|
2167
2425
|
container.classList.add('blk-full');
|
|
2168
2426
|
document.body.classList.add('blk-full-open');
|
|
2169
2427
|
fullOpen = container;
|
|
2170
2428
|
const btn = container.querySelector('.blk-tools .tool-full');
|
|
2171
2429
|
if (btn) btn.textContent = '✕';
|
|
2172
2430
|
window.dispatchEvent(new Event('resize'));
|
|
2173
|
-
if (container._rlyToolsSync) container._rlyToolsSync();
|
|
2174
2431
|
if (container._rlyZoom) container._rlyZoom.reapply();
|
|
2175
2432
|
}
|
|
2176
2433
|
|
|
@@ -2273,6 +2530,7 @@
|
|
|
2273
2530
|
});
|
|
2274
2531
|
scrollEl.addEventListener('pointermove', (e) => {
|
|
2275
2532
|
if (!pending) return;
|
|
2533
|
+
if (scrollEl._rlyRegionSelecting) { pending = false; active = false; return; }
|
|
2276
2534
|
const dx = e.clientX - sx, dy = e.clientY - sy;
|
|
2277
2535
|
if (!active) {
|
|
2278
2536
|
if (Math.abs(dx) < THRESH && Math.abs(dy) < THRESH) return;
|
|
@@ -2306,7 +2564,7 @@
|
|
|
2306
2564
|
// with a block-scoped target ("Whole chart/diagram/…") so a user can comment
|
|
2307
2565
|
// on the visual as a whole. null when annotation is off (omits the button).
|
|
2308
2566
|
function wholeBlockComment(ctx, blockId, label) {
|
|
2309
|
-
if (!ctx || !ctx.annotate) return null;
|
|
2567
|
+
if (!ctx || !ctx.annotate || ctx.canComment === false) return null;
|
|
2310
2568
|
const a = ctx.annotate;
|
|
2311
2569
|
return {
|
|
2312
2570
|
open: (anchorEl) =>
|
|
@@ -2333,25 +2591,13 @@
|
|
|
2333
2591
|
if (!container._rlyPan) container._rlyPan = enablePan(container);
|
|
2334
2592
|
const refreshPan = container._rlyPan;
|
|
2335
2593
|
|
|
2336
|
-
// The toolbar is absolute inside the scroll box, so it scrolls away when the
|
|
2337
|
-
// user pans/scrolls. Counter-translate it by the scroll offset to pin it to
|
|
2338
|
-
// the visible corner (off in full-screen, where it is position:fixed). Bound
|
|
2339
|
-
// once; always re-reads the current toolbar (mermaid rebuilds it on render).
|
|
2340
|
-
if (!container._rlyToolsSync) {
|
|
2341
|
-
const sync = () => {
|
|
2342
|
-
const tb = container._rlyTools;
|
|
2343
|
-
if (!tb) return;
|
|
2344
|
-
if (container.classList.contains('blk-full')) { tb.style.transform = ''; return; }
|
|
2345
|
-
const x = container.scrollLeft, y = container.scrollTop;
|
|
2346
|
-
tb.style.transform = x || y ? 'translate(' + x + 'px,' + y + 'px)' : '';
|
|
2347
|
-
};
|
|
2348
|
-
container.addEventListener('scroll', sync);
|
|
2349
|
-
container._rlyToolsSync = sync;
|
|
2350
|
-
}
|
|
2351
|
-
|
|
2352
2594
|
// zoom level persists across re-renders; the wheel handler (bound once)
|
|
2353
2595
|
// delegates through container._rlyZoom so it never holds a stale zoomEl
|
|
2354
2596
|
if (container._rlyZ === undefined) container._rlyZ = null; // null = fit-to-width
|
|
2597
|
+
// Fluid visuals (currently Chart.js) fit the live container width. Capture
|
|
2598
|
+
// that rendered size only when leaving fit mode so zoom remains proportional
|
|
2599
|
+
// without freezing later fit-mode viewport growth to an old width.
|
|
2600
|
+
let fluidZoomNatural = null;
|
|
2355
2601
|
const pct = el('span', { class: 'tool-pct' }, 'fit');
|
|
2356
2602
|
function apply() {
|
|
2357
2603
|
if (!zoomable) return;
|
|
@@ -2377,36 +2623,57 @@
|
|
|
2377
2623
|
const scale = Math.min(availW / nat.w, availH / nat.h);
|
|
2378
2624
|
target.style.maxWidth = 'none';
|
|
2379
2625
|
target.style.width = Math.max(1, Math.round(nat.w * scale)) + 'px';
|
|
2380
|
-
target.style.height = 'auto';
|
|
2626
|
+
target.style.height = opts.scaleHeight ? Math.max(1, Math.round(nat.h * scale)) + 'px' : 'auto';
|
|
2381
2627
|
pct.textContent = 'fit';
|
|
2382
2628
|
} else if (z === null || !nat || !nat.w) {
|
|
2383
2629
|
target.style.width = '100%';
|
|
2384
|
-
target.style.maxWidth = nat && nat.w ? Math.ceil(nat.w) + 'px' : '100%';
|
|
2385
|
-
target.style.height = 'auto';
|
|
2630
|
+
target.style.maxWidth = opts.fluidFit ? '100%' : (nat && nat.w ? Math.ceil(nat.w) + 'px' : '100%');
|
|
2631
|
+
target.style.height = opts.scaleHeight ? '100%' : 'auto';
|
|
2386
2632
|
pct.textContent = 'fit';
|
|
2387
2633
|
} else {
|
|
2634
|
+
const zoomNat = fluidZoomNatural || nat;
|
|
2388
2635
|
target.style.maxWidth = 'none';
|
|
2389
|
-
target.style.width = Math.round(
|
|
2390
|
-
target.style.height = 'auto';
|
|
2636
|
+
target.style.width = Math.round(zoomNat.w * z) + 'px';
|
|
2637
|
+
target.style.height = opts.scaleHeight && zoomNat && zoomNat.h ? Math.round(zoomNat.h * z) + 'px' : 'auto';
|
|
2391
2638
|
pct.textContent = Math.round(z * 100) + '%';
|
|
2392
2639
|
}
|
|
2393
2640
|
window.dispatchEvent(new Event('resize')); // annotation badges reposition
|
|
2394
2641
|
refreshPan(); // content size → grab affordance
|
|
2395
|
-
container._rlyToolsSync(); // keep the toolbar pinned
|
|
2396
2642
|
}
|
|
2397
2643
|
function currentZ() {
|
|
2398
2644
|
if (container._rlyZ !== null) return container._rlyZ;
|
|
2645
|
+
if (opts.fluidFit) return 1;
|
|
2399
2646
|
const nat = opts.natural();
|
|
2400
2647
|
if (!nat || !nat.w) return 1;
|
|
2401
2648
|
const shown = opts.zoomEl.getBoundingClientRect().width;
|
|
2402
2649
|
return shown > 0 ? shown / nat.w : 1;
|
|
2403
2650
|
}
|
|
2404
2651
|
function setZoom(next) {
|
|
2652
|
+
if (next === null) {
|
|
2653
|
+
fluidZoomNatural = null;
|
|
2654
|
+
} else if (opts.fluidFit && container._rlyZ === null) {
|
|
2655
|
+
const shown = opts.zoomEl.getBoundingClientRect();
|
|
2656
|
+
fluidZoomNatural = {
|
|
2657
|
+
w: Math.max(1, shown.width),
|
|
2658
|
+
h: Math.max(1, shown.height),
|
|
2659
|
+
};
|
|
2660
|
+
}
|
|
2405
2661
|
container._rlyZ = next === null ? null : Math.min(8, Math.max(0.2, next));
|
|
2406
2662
|
apply();
|
|
2407
2663
|
}
|
|
2408
2664
|
container._rlyZoom = { setZoom, currentZ, reapply: apply };
|
|
2409
2665
|
|
|
2666
|
+
// Stop any in-flight wheel easing before an explicit toolbar/full-screen
|
|
2667
|
+
// action. This prevents an older gesture target from overriding that action
|
|
2668
|
+
// on the next animation frame.
|
|
2669
|
+
if (!container._rlyStopWheelZoom) {
|
|
2670
|
+
container._rlyStopWheelZoom = () => {
|
|
2671
|
+
if (container._rlyWheelFrame) cancelAnimationFrame(container._rlyWheelFrame);
|
|
2672
|
+
container._rlyWheelFrame = null;
|
|
2673
|
+
container._rlyWheelTarget = null;
|
|
2674
|
+
};
|
|
2675
|
+
}
|
|
2676
|
+
|
|
2410
2677
|
const tools = el('div', { class: 'blk-tools' });
|
|
2411
2678
|
|
|
2412
2679
|
// comment on the whole block (leftmost) — opens the annotation popover with
|
|
@@ -2435,18 +2702,53 @@
|
|
|
2435
2702
|
const onWheel = (e) => {
|
|
2436
2703
|
if (!(e.ctrlKey || e.metaKey) || !container._rlyZoom) return;
|
|
2437
2704
|
e.preventDefault();
|
|
2438
|
-
|
|
2705
|
+
// Normalize line/page-mode devices to pixel-like units, then cap a
|
|
2706
|
+
// single event. Exponential scaling makes equal motion reversible and
|
|
2707
|
+
// lets tiny trackpad deltas stay tiny instead of becoming fixed jumps.
|
|
2708
|
+
const unit = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? window.innerHeight : 1;
|
|
2709
|
+
const delta = Math.max(-WHEEL_ZOOM_MAX_DELTA, Math.min(WHEEL_ZOOM_MAX_DELTA, e.deltaY * unit));
|
|
2710
|
+
const base = Number.isFinite(container._rlyWheelTarget)
|
|
2711
|
+
? container._rlyWheelTarget
|
|
2712
|
+
: container._rlyZoom.currentZ();
|
|
2713
|
+
container._rlyWheelTarget = Math.min(8, Math.max(0.2, base * Math.exp(-delta * WHEEL_ZOOM_SENSITIVITY)));
|
|
2714
|
+
|
|
2715
|
+
// Respect reduced-motion while retaining the gentler delta-based step.
|
|
2716
|
+
if (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
|
2717
|
+
container._rlyZoom.setZoom(container._rlyWheelTarget);
|
|
2718
|
+
container._rlyWheelTarget = null;
|
|
2719
|
+
return;
|
|
2720
|
+
}
|
|
2721
|
+
if (container._rlyWheelFrame) return; // coalesce bursty input to one rAF loop
|
|
2722
|
+
const tick = () => {
|
|
2723
|
+
const zoom = container._rlyZoom;
|
|
2724
|
+
const target = container._rlyWheelTarget;
|
|
2725
|
+
if (!zoom || !Number.isFinite(target)) {
|
|
2726
|
+
container._rlyWheelFrame = null;
|
|
2727
|
+
return;
|
|
2728
|
+
}
|
|
2729
|
+
const current = zoom.currentZ();
|
|
2730
|
+
const gap = target - current;
|
|
2731
|
+
if (Math.abs(gap) <= WHEEL_ZOOM_EPSILON) {
|
|
2732
|
+
zoom.setZoom(target);
|
|
2733
|
+
container._rlyWheelFrame = null;
|
|
2734
|
+
container._rlyWheelTarget = null;
|
|
2735
|
+
return;
|
|
2736
|
+
}
|
|
2737
|
+
zoom.setZoom(current + gap * WHEEL_ZOOM_EASE);
|
|
2738
|
+
container._rlyWheelFrame = requestAnimationFrame(tick);
|
|
2739
|
+
};
|
|
2740
|
+
container._rlyWheelFrame = requestAnimationFrame(tick);
|
|
2439
2741
|
};
|
|
2440
2742
|
container.addEventListener('wheel', onWheel, { passive: false });
|
|
2441
2743
|
container._rlyWheel = onWheel;
|
|
2442
2744
|
}
|
|
2443
2745
|
const zoomOut = el('button', { class: 'tool-zoom', type: 'button', title: 'Zoom out' }, '−');
|
|
2444
2746
|
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); });
|
|
2747
|
+
zoomOut.addEventListener('click', (e) => { e.stopPropagation(); container._rlyStopWheelZoom(); setZoom(currentZ() / 1.2); });
|
|
2748
|
+
zoomIn.addEventListener('click', (e) => { e.stopPropagation(); container._rlyStopWheelZoom(); setZoom(currentZ() * 1.2); });
|
|
2447
2749
|
pct.classList.add('is-btn');
|
|
2448
2750
|
pct.title = 'Reset to fit';
|
|
2449
|
-
pct.addEventListener('click', (e) => { e.stopPropagation(); setZoom(null); });
|
|
2751
|
+
pct.addEventListener('click', (e) => { e.stopPropagation(); container._rlyStopWheelZoom(); setZoom(null); });
|
|
2450
2752
|
tools.append(zoomOut, pct, zoomIn);
|
|
2451
2753
|
}
|
|
2452
2754
|
|
|
@@ -2459,12 +2761,11 @@
|
|
|
2459
2761
|
});
|
|
2460
2762
|
tools.append(fullBtn);
|
|
2461
2763
|
|
|
2462
|
-
container.
|
|
2764
|
+
container.prepend(tools);
|
|
2463
2765
|
container._rlyTools = tools;
|
|
2464
2766
|
if (zoomable) apply();
|
|
2465
2767
|
// non-zoomable diagrams (tall mermaid, oversized image) can still overflow
|
|
2466
2768
|
refreshPan();
|
|
2467
|
-
container._rlyToolsSync();
|
|
2468
2769
|
if (container._rlyCmtSync) container._rlyCmtSync(); // reflect existing comment
|
|
2469
2770
|
}
|
|
2470
2771
|
|
|
@@ -2586,6 +2887,10 @@
|
|
|
2586
2887
|
// editable-mermaid plumbing: edits maps blockId -> edited code; onBlockEdit
|
|
2587
2888
|
// reports an accepted change (or null to clear back to the original).
|
|
2588
2889
|
edits: ctx && ctx.edits ? ctx.edits : {},
|
|
2890
|
+
canComment: !ctx || ctx.canComment !== false,
|
|
2891
|
+
saveArtifact:
|
|
2892
|
+
ctx && typeof ctx.saveArtifact === 'function' ? ctx.saveArtifact : null,
|
|
2893
|
+
canEditBlocks: !ctx || ctx.canEditBlocks !== false,
|
|
2589
2894
|
onBlockEdit:
|
|
2590
2895
|
ctx && typeof ctx.onBlockEdit === 'function' ? ctx.onBlockEdit : () => {},
|
|
2591
2896
|
};
|