@khanglvm/relay 0.15.0 → 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 +15 -1
- package/docs/AGENT.md +52 -14
- package/package.json +1 -1
- package/skills/relay/SKILL.md +37 -7
- package/src/cli.js +22 -11
- package/src/mcp-ui/board.js +46 -8
- package/src/mcp.js +9 -3
- package/src/server.js +110 -11
- package/src/spec.js +5 -0
- package/src/store.js +15 -0
- package/src/ui/annotate.js +57 -19
- package/src/ui/app.js +25 -6
- package/src/ui/blocks.css +58 -8
- package/src/ui/blocks.js +240 -28
package/src/ui/blocks.js
CHANGED
|
@@ -1901,7 +1901,9 @@
|
|
|
1901
1901
|
src,
|
|
1902
1902
|
alt: block.alt || 'image',
|
|
1903
1903
|
loading: 'lazy',
|
|
1904
|
+
title: ctx.canComment === false ? '' : 'Hold briefly, then drag to comment on an area',
|
|
1904
1905
|
});
|
|
1906
|
+
const stage = el('div', { class: 'blk-imgstage' }, img);
|
|
1905
1907
|
// Inline default: the image fills its full width (container width, never
|
|
1906
1908
|
// upscaled past natural) so it's readable without manual zoom; the CONTAINER
|
|
1907
1909
|
// caps the height (default 800 via CSS, or block.height) and SCROLLS — we
|
|
@@ -1911,18 +1913,30 @@
|
|
|
1911
1913
|
img.addEventListener('error', () => {
|
|
1912
1914
|
container.replaceChildren(el('div', { class: 'blk-error' }, 'Image failed to load'));
|
|
1913
1915
|
});
|
|
1914
|
-
container.append(
|
|
1916
|
+
container.append(stage);
|
|
1915
1917
|
const attachImgViewer = () =>
|
|
1916
1918
|
attachViewer(container, {
|
|
1917
|
-
zoomEl:
|
|
1919
|
+
zoomEl: stage,
|
|
1918
1920
|
natural: () => (img.naturalWidth > 0 ? { w: img.naturalWidth, h: img.naturalHeight } : null),
|
|
1919
1921
|
label: 'image',
|
|
1920
1922
|
comment: wholeBlockComment(ctx, blockId, 'image'),
|
|
1921
1923
|
});
|
|
1922
1924
|
if (img.complete && img.naturalWidth > 0) attachImgViewer();
|
|
1923
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
|
+
}
|
|
1924
1938
|
if (ctx.annotate && block.pins === true) {
|
|
1925
|
-
enableImagePins(
|
|
1939
|
+
enableImagePins(stage, img, ctx, blockId, block.alt || 'Image');
|
|
1926
1940
|
} else if (ctx.annotate) {
|
|
1927
1941
|
ctx.annotate.register(img, {
|
|
1928
1942
|
blockId,
|
|
@@ -1945,6 +1959,7 @@
|
|
|
1945
1959
|
img.addEventListener('pointerdown', (e) => { down = { x: e.clientX, y: e.clientY }; });
|
|
1946
1960
|
img.addEventListener('click', (e) => {
|
|
1947
1961
|
if (ctx.canComment === false) return;
|
|
1962
|
+
if (img._rlySuppressPointClick) { img._rlySuppressPointClick = false; down = null; return; }
|
|
1948
1963
|
if (down && (Math.abs(e.clientX - down.x) > 4 || Math.abs(e.clientY - down.y) > 4)) { down = null; return; }
|
|
1949
1964
|
const r = img.getBoundingClientRect();
|
|
1950
1965
|
if (!r.width || !r.height) return;
|
|
@@ -1984,6 +1999,192 @@
|
|
|
1984
1999
|
syncPins();
|
|
1985
2000
|
}
|
|
1986
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
|
+
|
|
1987
2188
|
// ---------- palette ----------
|
|
1988
2189
|
// Color palettes as swatch cards: hover a swatch to reveal its hex, click to
|
|
1989
2190
|
// copy it. One palette may be {featured:true} → a larger spotlight row. Lets
|
|
@@ -2125,18 +2326,46 @@
|
|
|
2125
2326
|
window.addEventListener('resize', sizeBefore);
|
|
2126
2327
|
|
|
2127
2328
|
let dragging = false;
|
|
2329
|
+
let dragMoved = false;
|
|
2330
|
+
let dragStartX = 0;
|
|
2128
2331
|
const setFromX = (clientX) => {
|
|
2129
2332
|
const r = frame.getBoundingClientRect();
|
|
2130
2333
|
if (r.width) { pos = Math.max(0, Math.min(100, ((clientX - r.left) / r.width) * 100)); apply(); }
|
|
2131
2334
|
};
|
|
2132
|
-
frame.addEventListener('pointerdown', (e) => {
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
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; });
|
|
2136
2352
|
handle.addEventListener('keydown', (e) => {
|
|
2137
2353
|
if (e.key === 'ArrowLeft') { pos = Math.max(0, pos - 2); apply(); e.preventDefault(); }
|
|
2138
2354
|
else if (e.key === 'ArrowRight') { pos = Math.min(100, pos + 2); apply(); e.preventDefault(); }
|
|
2139
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
|
+
}
|
|
2140
2369
|
attachViewer(wrap, { zoomEl: null, label: 'comparison', comment: wholeBlockComment(ctx, blockId, 'comparison') });
|
|
2141
2370
|
return wrap;
|
|
2142
2371
|
}
|
|
@@ -2181,7 +2410,6 @@
|
|
|
2181
2410
|
if (btn) btn.innerHTML = ICON_EXPAND;
|
|
2182
2411
|
fullOpen = null;
|
|
2183
2412
|
window.dispatchEvent(new Event('resize'));
|
|
2184
|
-
if (c._rlyToolsSync) c._rlyToolsSync(); // re-pin toolbar to its scrolled corner
|
|
2185
2413
|
if (c._rlyZoom) c._rlyZoom.reapply(); // restore the compact inline height cap
|
|
2186
2414
|
}
|
|
2187
2415
|
document.addEventListener('keydown', (e) => {
|
|
@@ -2200,7 +2428,6 @@
|
|
|
2200
2428
|
const btn = container.querySelector('.blk-tools .tool-full');
|
|
2201
2429
|
if (btn) btn.textContent = '✕';
|
|
2202
2430
|
window.dispatchEvent(new Event('resize'));
|
|
2203
|
-
if (container._rlyToolsSync) container._rlyToolsSync();
|
|
2204
2431
|
if (container._rlyZoom) container._rlyZoom.reapply();
|
|
2205
2432
|
}
|
|
2206
2433
|
|
|
@@ -2303,6 +2530,7 @@
|
|
|
2303
2530
|
});
|
|
2304
2531
|
scrollEl.addEventListener('pointermove', (e) => {
|
|
2305
2532
|
if (!pending) return;
|
|
2533
|
+
if (scrollEl._rlyRegionSelecting) { pending = false; active = false; return; }
|
|
2306
2534
|
const dx = e.clientX - sx, dy = e.clientY - sy;
|
|
2307
2535
|
if (!active) {
|
|
2308
2536
|
if (Math.abs(dx) < THRESH && Math.abs(dy) < THRESH) return;
|
|
@@ -2363,22 +2591,6 @@
|
|
|
2363
2591
|
if (!container._rlyPan) container._rlyPan = enablePan(container);
|
|
2364
2592
|
const refreshPan = container._rlyPan;
|
|
2365
2593
|
|
|
2366
|
-
// The toolbar is absolute inside the scroll box, so it scrolls away when the
|
|
2367
|
-
// user pans/scrolls. Counter-translate it by the scroll offset to pin it to
|
|
2368
|
-
// the visible corner (off in full-screen, where it is position:fixed). Bound
|
|
2369
|
-
// once; always re-reads the current toolbar (mermaid rebuilds it on render).
|
|
2370
|
-
if (!container._rlyToolsSync) {
|
|
2371
|
-
const sync = () => {
|
|
2372
|
-
const tb = container._rlyTools;
|
|
2373
|
-
if (!tb) return;
|
|
2374
|
-
if (container.classList.contains('blk-full')) { tb.style.transform = ''; return; }
|
|
2375
|
-
const x = container.scrollLeft, y = container.scrollTop;
|
|
2376
|
-
tb.style.transform = x || y ? 'translate(' + x + 'px,' + y + 'px)' : '';
|
|
2377
|
-
};
|
|
2378
|
-
container.addEventListener('scroll', sync);
|
|
2379
|
-
container._rlyToolsSync = sync;
|
|
2380
|
-
}
|
|
2381
|
-
|
|
2382
2594
|
// zoom level persists across re-renders; the wheel handler (bound once)
|
|
2383
2595
|
// delegates through container._rlyZoom so it never holds a stale zoomEl
|
|
2384
2596
|
if (container._rlyZ === undefined) container._rlyZ = null; // null = fit-to-width
|
|
@@ -2427,7 +2639,6 @@
|
|
|
2427
2639
|
}
|
|
2428
2640
|
window.dispatchEvent(new Event('resize')); // annotation badges reposition
|
|
2429
2641
|
refreshPan(); // content size → grab affordance
|
|
2430
|
-
container._rlyToolsSync(); // keep the toolbar pinned
|
|
2431
2642
|
}
|
|
2432
2643
|
function currentZ() {
|
|
2433
2644
|
if (container._rlyZ !== null) return container._rlyZ;
|
|
@@ -2550,12 +2761,11 @@
|
|
|
2550
2761
|
});
|
|
2551
2762
|
tools.append(fullBtn);
|
|
2552
2763
|
|
|
2553
|
-
container.
|
|
2764
|
+
container.prepend(tools);
|
|
2554
2765
|
container._rlyTools = tools;
|
|
2555
2766
|
if (zoomable) apply();
|
|
2556
2767
|
// non-zoomable diagrams (tall mermaid, oversized image) can still overflow
|
|
2557
2768
|
refreshPan();
|
|
2558
|
-
container._rlyToolsSync();
|
|
2559
2769
|
if (container._rlyCmtSync) container._rlyCmtSync(); // reflect existing comment
|
|
2560
2770
|
}
|
|
2561
2771
|
|
|
@@ -2678,6 +2888,8 @@
|
|
|
2678
2888
|
// reports an accepted change (or null to clear back to the original).
|
|
2679
2889
|
edits: ctx && ctx.edits ? ctx.edits : {},
|
|
2680
2890
|
canComment: !ctx || ctx.canComment !== false,
|
|
2891
|
+
saveArtifact:
|
|
2892
|
+
ctx && typeof ctx.saveArtifact === 'function' ? ctx.saveArtifact : null,
|
|
2681
2893
|
canEditBlocks: !ctx || ctx.canEditBlocks !== false,
|
|
2682
2894
|
onBlockEdit:
|
|
2683
2895
|
ctx && typeof ctx.onBlockEdit === 'function' ? ctx.onBlockEdit : () => {},
|