@khanglvm/relay 0.15.0 → 0.16.1
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 +24 -1
- package/docs/AGENT.md +58 -14
- package/package.json +1 -1
- package/skills/relay/SKILL.md +41 -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 +68 -22
- package/src/ui/app.js +25 -6
- package/src/ui/blocks.css +90 -9
- package/src/ui/blocks.js +359 -30
package/src/ui/blocks.js
CHANGED
|
@@ -1888,6 +1888,24 @@
|
|
|
1888
1888
|
}
|
|
1889
1889
|
|
|
1890
1890
|
// ---------- image ----------
|
|
1891
|
+
let activeRegionMode = null;
|
|
1892
|
+
let viewerSpacePan = false;
|
|
1893
|
+
const setViewerSpacePan = (enabled) => {
|
|
1894
|
+
viewerSpacePan = Boolean(enabled);
|
|
1895
|
+
document.body.classList.toggle('blk-space-pan', viewerSpacePan);
|
|
1896
|
+
};
|
|
1897
|
+
document.addEventListener('keydown', (e) => {
|
|
1898
|
+
const tag = e.target && e.target.tagName ? e.target.tagName.toLowerCase() : '';
|
|
1899
|
+
if (e.code === 'Space' && !e.repeat && !['input', 'textarea', 'select'].includes(tag)) {
|
|
1900
|
+
setViewerSpacePan(true);
|
|
1901
|
+
e.preventDefault();
|
|
1902
|
+
}
|
|
1903
|
+
});
|
|
1904
|
+
document.addEventListener('keyup', (e) => {
|
|
1905
|
+
if (e.code === 'Space') setViewerSpacePan(false);
|
|
1906
|
+
});
|
|
1907
|
+
window.addEventListener('blur', () => setViewerSpacePan(false));
|
|
1908
|
+
|
|
1891
1909
|
// src is a remote URL, or absent for embedded local files (served by the
|
|
1892
1910
|
// board server at /img/b/<id>). Same sizing rule as diagrams: never upscale
|
|
1893
1911
|
// past natural width; zoom/full-screen viewer attached once loaded.
|
|
@@ -1901,7 +1919,11 @@
|
|
|
1901
1919
|
src,
|
|
1902
1920
|
alt: block.alt || 'image',
|
|
1903
1921
|
loading: 'lazy',
|
|
1922
|
+
draggable: 'false',
|
|
1923
|
+
title: ctx.canComment === false ? '' : 'Drag to draw a comment area · Space-drag to pan',
|
|
1904
1924
|
});
|
|
1925
|
+
img.addEventListener('dragstart', (e) => e.preventDefault());
|
|
1926
|
+
const stage = el('div', { class: 'blk-imgstage' }, img);
|
|
1905
1927
|
// Inline default: the image fills its full width (container width, never
|
|
1906
1928
|
// upscaled past natural) so it's readable without manual zoom; the CONTAINER
|
|
1907
1929
|
// caps the height (default 800 via CSS, or block.height) and SCROLLS — we
|
|
@@ -1911,18 +1933,31 @@
|
|
|
1911
1933
|
img.addEventListener('error', () => {
|
|
1912
1934
|
container.replaceChildren(el('div', { class: 'blk-error' }, 'Image failed to load'));
|
|
1913
1935
|
});
|
|
1914
|
-
container.append(
|
|
1936
|
+
container.append(stage);
|
|
1937
|
+
const region = ctx.annotate && ctx.canComment !== false
|
|
1938
|
+
? enableImageRegions({
|
|
1939
|
+
host: stage,
|
|
1940
|
+
surface: img,
|
|
1941
|
+
panHost: container,
|
|
1942
|
+
sourceForSide: () => img,
|
|
1943
|
+
sideAtPoint: () => null,
|
|
1944
|
+
ctx,
|
|
1945
|
+
blockId,
|
|
1946
|
+
label: block.alt || 'Image',
|
|
1947
|
+
})
|
|
1948
|
+
: null;
|
|
1915
1949
|
const attachImgViewer = () =>
|
|
1916
1950
|
attachViewer(container, {
|
|
1917
|
-
zoomEl:
|
|
1951
|
+
zoomEl: stage,
|
|
1918
1952
|
natural: () => (img.naturalWidth > 0 ? { w: img.naturalWidth, h: img.naturalHeight } : null),
|
|
1919
1953
|
label: 'image',
|
|
1920
1954
|
comment: wholeBlockComment(ctx, blockId, 'image'),
|
|
1955
|
+
region,
|
|
1921
1956
|
});
|
|
1922
1957
|
if (img.complete && img.naturalWidth > 0) attachImgViewer();
|
|
1923
1958
|
else img.addEventListener('load', attachImgViewer, { once: true });
|
|
1924
1959
|
if (ctx.annotate && block.pins === true) {
|
|
1925
|
-
enableImagePins(
|
|
1960
|
+
enableImagePins(stage, img, ctx, blockId, block.alt || 'Image');
|
|
1926
1961
|
} else if (ctx.annotate) {
|
|
1927
1962
|
ctx.annotate.register(img, {
|
|
1928
1963
|
blockId,
|
|
@@ -1945,6 +1980,7 @@
|
|
|
1945
1980
|
img.addEventListener('pointerdown', (e) => { down = { x: e.clientX, y: e.clientY }; });
|
|
1946
1981
|
img.addEventListener('click', (e) => {
|
|
1947
1982
|
if (ctx.canComment === false) return;
|
|
1983
|
+
if (img._rlySuppressPointClick) { img._rlySuppressPointClick = false; down = null; return; }
|
|
1948
1984
|
if (down && (Math.abs(e.clientX - down.x) > 4 || Math.abs(e.clientY - down.y) > 4)) { down = null; return; }
|
|
1949
1985
|
const r = img.getBoundingClientRect();
|
|
1950
1986
|
if (!r.width || !r.height) return;
|
|
@@ -1984,6 +2020,244 @@
|
|
|
1984
2020
|
syncPins();
|
|
1985
2021
|
}
|
|
1986
2022
|
|
|
2023
|
+
// Hold, then drag, to select a rectangular image area. A short move remains
|
|
2024
|
+
// native viewer pan / comparison-slider input; the hold threshold makes the
|
|
2025
|
+
// two gestures coexist without a mode switch. Region coordinates are stored
|
|
2026
|
+
// as normalized fractions, while a pixel crop is uploaded through the host
|
|
2027
|
+
// context so the result gives the agent an actual viewable image artifact.
|
|
2028
|
+
function enableImageRegions({ host, surface, panHost, sourceForSide, sideAtPoint, ctx, blockId, label }) {
|
|
2029
|
+
const layer = el('div', { class: 'blk-region-layer' });
|
|
2030
|
+
const selection = el('div', { class: 'blk-region-selection' });
|
|
2031
|
+
layer.append(selection);
|
|
2032
|
+
host.append(layer);
|
|
2033
|
+
panHost._rlyHasRegions = true;
|
|
2034
|
+
panHost.classList.add('blk-has-regions');
|
|
2035
|
+
let pending = null;
|
|
2036
|
+
let active = false;
|
|
2037
|
+
let status = null;
|
|
2038
|
+
let modeActive = false;
|
|
2039
|
+
const modeHooks = new Set();
|
|
2040
|
+
let controller = null;
|
|
2041
|
+
const MIN_REGION = 0.012;
|
|
2042
|
+
|
|
2043
|
+
const localPoint = (e) => {
|
|
2044
|
+
const r = host.getBoundingClientRect();
|
|
2045
|
+
return {
|
|
2046
|
+
x: r.width ? Math.max(0, Math.min(1, (e.clientX - r.left) / r.width)) : 0,
|
|
2047
|
+
y: r.height ? Math.max(0, Math.min(1, (e.clientY - r.top) / r.height)) : 0,
|
|
2048
|
+
};
|
|
2049
|
+
};
|
|
2050
|
+
const clearStatus = () => { if (status) status.remove(); status = null; };
|
|
2051
|
+
const showStatus = (text) => {
|
|
2052
|
+
clearStatus();
|
|
2053
|
+
status = el('div', { class: 'blk-region-status', role: 'status' }, text);
|
|
2054
|
+
host.append(status);
|
|
2055
|
+
};
|
|
2056
|
+
const reset = () => {
|
|
2057
|
+
active = false;
|
|
2058
|
+
pending = null;
|
|
2059
|
+
panHost._rlyRegionSelecting = false;
|
|
2060
|
+
host.classList.remove('blk-region-arming', 'blk-region-selecting');
|
|
2061
|
+
selection.style.display = 'none';
|
|
2062
|
+
clearStatus();
|
|
2063
|
+
};
|
|
2064
|
+
const setModeActive = (next) => {
|
|
2065
|
+
const enabled = Boolean(next);
|
|
2066
|
+
if (enabled && activeRegionMode && activeRegionMode !== controller) {
|
|
2067
|
+
activeRegionMode.setModeActive(false);
|
|
2068
|
+
}
|
|
2069
|
+
if (!enabled && pending) reset();
|
|
2070
|
+
if (modeActive === enabled) return;
|
|
2071
|
+
modeActive = enabled;
|
|
2072
|
+
panHost._rlyRegionMode = enabled;
|
|
2073
|
+
host.classList.toggle('blk-region-mode', enabled);
|
|
2074
|
+
activeRegionMode = enabled ? controller : (activeRegionMode === controller ? null : activeRegionMode);
|
|
2075
|
+
for (const fn of modeHooks) { try { fn(enabled); } catch (_) {} }
|
|
2076
|
+
};
|
|
2077
|
+
controller = {
|
|
2078
|
+
setModeActive,
|
|
2079
|
+
isModeActive: () => modeActive,
|
|
2080
|
+
onModeChange: (fn) => {
|
|
2081
|
+
if (typeof fn === 'function') modeHooks.add(fn);
|
|
2082
|
+
return () => modeHooks.delete(fn);
|
|
2083
|
+
},
|
|
2084
|
+
};
|
|
2085
|
+
document.addEventListener('keydown', (e) => {
|
|
2086
|
+
if (e.key === 'Escape' && modeActive) setModeActive(false);
|
|
2087
|
+
});
|
|
2088
|
+
const draw = (a, b) => {
|
|
2089
|
+
const x = Math.min(a.x, b.x), y = Math.min(a.y, b.y);
|
|
2090
|
+
const w = Math.abs(a.x - b.x), h = Math.abs(a.y - b.y);
|
|
2091
|
+
selection.style.left = (x * 100) + '%';
|
|
2092
|
+
selection.style.top = (y * 100) + '%';
|
|
2093
|
+
selection.style.width = (w * 100) + '%';
|
|
2094
|
+
selection.style.height = (h * 100) + '%';
|
|
2095
|
+
return { x, y, w, h };
|
|
2096
|
+
};
|
|
2097
|
+
const createRegionZone = (target, count, provisional = false) => {
|
|
2098
|
+
const badge = el('span', { class: 'blk-imgregion-badge', 'aria-hidden': 'true' });
|
|
2099
|
+
badge.innerHTML = ICON_COMMENT;
|
|
2100
|
+
badge.append(el('span', { class: 'blk-imgregion-count' }, String(count)));
|
|
2101
|
+
const sideLabel = target.side ? target.side + ' ' : '';
|
|
2102
|
+
const region = el('button', {
|
|
2103
|
+
class: 'blk-imgregion' + (provisional ? ' blk-imgregion-provisional' : ''),
|
|
2104
|
+
type: 'button',
|
|
2105
|
+
title: provisional
|
|
2106
|
+
? `Add a comment to this ${sideLabel}area`
|
|
2107
|
+
: `${target.side ? target.side + ' · ' : ''}${count} ${count === 1 ? 'comment' : 'comments'}`,
|
|
2108
|
+
'aria-label': provisional
|
|
2109
|
+
? `New ${sideLabel}image area comment`
|
|
2110
|
+
: `${sideLabel}image area with ${count} ${count === 1 ? 'comment' : 'comments'}`,
|
|
2111
|
+
}, badge);
|
|
2112
|
+
region.style.left = (target.x * 100) + '%';
|
|
2113
|
+
region.style.top = (target.y * 100) + '%';
|
|
2114
|
+
region.style.width = (target.w * 100) + '%';
|
|
2115
|
+
region.style.height = (target.h * 100) + '%';
|
|
2116
|
+
region.addEventListener('pointerdown', (e) => e.stopPropagation());
|
|
2117
|
+
if (!provisional) {
|
|
2118
|
+
region.addEventListener('click', (e) => {
|
|
2119
|
+
e.stopPropagation();
|
|
2120
|
+
ctx.annotate.openExternal({ blockId, questionId: ctx.questionId, target }, region);
|
|
2121
|
+
});
|
|
2122
|
+
}
|
|
2123
|
+
return region;
|
|
2124
|
+
};
|
|
2125
|
+
const beginSelection = (pointerId) => {
|
|
2126
|
+
if (!pending || active) return;
|
|
2127
|
+
active = true;
|
|
2128
|
+
panHost._rlyRegionSelecting = true;
|
|
2129
|
+
host.classList.remove('blk-region-arming');
|
|
2130
|
+
host.classList.add('blk-region-selecting');
|
|
2131
|
+
selection.style.display = 'block';
|
|
2132
|
+
draw(pending.start, pending.start);
|
|
2133
|
+
showStatus((pending.side ? pending.side.charAt(0).toUpperCase() + pending.side.slice(1) + ' · ' : '') + 'drag to select an area');
|
|
2134
|
+
try { surface.setPointerCapture(pointerId); } catch (_) {}
|
|
2135
|
+
};
|
|
2136
|
+
|
|
2137
|
+
async function cropRegion(img, region) {
|
|
2138
|
+
if (!img || !img.naturalWidth || !img.naturalHeight) throw new Error('image is not ready');
|
|
2139
|
+
const sx = Math.max(0, Math.floor(region.x * img.naturalWidth));
|
|
2140
|
+
const sy = Math.max(0, Math.floor(region.y * img.naturalHeight));
|
|
2141
|
+
const sw = Math.max(1, Math.min(img.naturalWidth - sx, Math.round(region.w * img.naturalWidth)));
|
|
2142
|
+
const sh = Math.max(1, Math.min(img.naturalHeight - sy, Math.round(region.h * img.naturalHeight)));
|
|
2143
|
+
const scale = Math.min(1, 1400 / sw, 1400 / sh);
|
|
2144
|
+
const width = Math.max(1, Math.round(sw * scale));
|
|
2145
|
+
const height = Math.max(1, Math.round(sh * scale));
|
|
2146
|
+
const canvas = document.createElement('canvas');
|
|
2147
|
+
canvas.width = width;
|
|
2148
|
+
canvas.height = height;
|
|
2149
|
+
const g = canvas.getContext('2d');
|
|
2150
|
+
if (!g) throw new Error('canvas unavailable');
|
|
2151
|
+
g.drawImage(img, sx, sy, sw, sh, 0, 0, width, height);
|
|
2152
|
+
const dataUrl = canvas.toDataURL('image/png');
|
|
2153
|
+
if (!/^data:image\/png;base64,/.test(dataUrl)) throw new Error('crop unavailable');
|
|
2154
|
+
if (ctx.saveArtifact) return ctx.saveArtifact({ dataUrl, mime: 'image/png', width, height, blockId });
|
|
2155
|
+
return { data: dataUrl.slice(dataUrl.indexOf(',') + 1), mime: 'image/png', width, height };
|
|
2156
|
+
}
|
|
2157
|
+
|
|
2158
|
+
const finishRegion = async (region, side) => {
|
|
2159
|
+
const target = {
|
|
2160
|
+
kind: 'image-region',
|
|
2161
|
+
x: Math.round(region.x * 10000) / 10000,
|
|
2162
|
+
y: Math.round(region.y * 10000) / 10000,
|
|
2163
|
+
w: Math.round(region.w * 10000) / 10000,
|
|
2164
|
+
h: Math.round(region.h * 10000) / 10000,
|
|
2165
|
+
side: side || undefined,
|
|
2166
|
+
label,
|
|
2167
|
+
};
|
|
2168
|
+
const provisional = createRegionZone(target, 0, true);
|
|
2169
|
+
layer.append(provisional);
|
|
2170
|
+
showStatus('Saving selected area…');
|
|
2171
|
+
try {
|
|
2172
|
+
target.crop = await cropRegion(sourceForSide(side), region);
|
|
2173
|
+
} catch {
|
|
2174
|
+
target.cropUnavailable = true;
|
|
2175
|
+
}
|
|
2176
|
+
clearStatus();
|
|
2177
|
+
ctx.annotate.openExternal({
|
|
2178
|
+
blockId,
|
|
2179
|
+
questionId: ctx.questionId,
|
|
2180
|
+
target,
|
|
2181
|
+
onClose: () => provisional.remove(),
|
|
2182
|
+
}, provisional);
|
|
2183
|
+
};
|
|
2184
|
+
|
|
2185
|
+
surface.addEventListener('pointerdown', (e) => {
|
|
2186
|
+
if (e.button !== 0 || viewerSpacePan || ctx.canComment === false) return;
|
|
2187
|
+
if (e.target.closest && e.target.closest('.cmp-handle, .blk-imgregion, .blk-tools')) return;
|
|
2188
|
+
if (modeActive) {
|
|
2189
|
+
e.preventDefault();
|
|
2190
|
+
e.stopPropagation();
|
|
2191
|
+
}
|
|
2192
|
+
const p = localPoint(e);
|
|
2193
|
+
pending = {
|
|
2194
|
+
start: p,
|
|
2195
|
+
last: p,
|
|
2196
|
+
clientX: e.clientX,
|
|
2197
|
+
clientY: e.clientY,
|
|
2198
|
+
pointerId: e.pointerId,
|
|
2199
|
+
explicitMode: modeActive,
|
|
2200
|
+
side: sideAtPoint(p.x),
|
|
2201
|
+
};
|
|
2202
|
+
host.classList.add('blk-region-arming');
|
|
2203
|
+
if (modeActive) beginSelection(e.pointerId);
|
|
2204
|
+
});
|
|
2205
|
+
surface.addEventListener('pointermove', (e) => {
|
|
2206
|
+
if (!pending) return;
|
|
2207
|
+
const moved = Math.hypot(e.clientX - pending.clientX, e.clientY - pending.clientY);
|
|
2208
|
+
if (!active && moved > 5) beginSelection(pending.pointerId);
|
|
2209
|
+
if (!active) {
|
|
2210
|
+
return;
|
|
2211
|
+
}
|
|
2212
|
+
e.preventDefault();
|
|
2213
|
+
e.stopPropagation();
|
|
2214
|
+
pending.last = localPoint(e);
|
|
2215
|
+
draw(pending.start, pending.last);
|
|
2216
|
+
});
|
|
2217
|
+
const end = (e) => {
|
|
2218
|
+
if (!pending) return;
|
|
2219
|
+
if (!active) { reset(); return; }
|
|
2220
|
+
e.preventDefault();
|
|
2221
|
+
const region = draw(pending.start, pending.last || localPoint(e));
|
|
2222
|
+
const side = pending.side;
|
|
2223
|
+
const explicitMode = pending.explicitMode;
|
|
2224
|
+
surface._rlySuppressPointClick = true;
|
|
2225
|
+
reset();
|
|
2226
|
+
clearStatus();
|
|
2227
|
+
if (region.w >= MIN_REGION && region.h >= MIN_REGION) {
|
|
2228
|
+
if (explicitMode) setModeActive(false);
|
|
2229
|
+
finishRegion(region, side);
|
|
2230
|
+
} else if (explicitMode) {
|
|
2231
|
+
showStatus('Drag a larger area');
|
|
2232
|
+
setTimeout(clearStatus, 1200);
|
|
2233
|
+
}
|
|
2234
|
+
};
|
|
2235
|
+
surface.addEventListener('pointerup', end);
|
|
2236
|
+
// Some Chromium/CDP paths emit pointercancel immediately after granting
|
|
2237
|
+
// capture on the first dragged frame. Once area mode is active, keep the
|
|
2238
|
+
// last drawn rectangle instead of throwing the user's deliberate hold away.
|
|
2239
|
+
surface.addEventListener('pointercancel', (e) => active ? end(e) : reset());
|
|
2240
|
+
|
|
2241
|
+
const syncRegions = () => {
|
|
2242
|
+
for (const node of Array.from(layer.querySelectorAll('.blk-imgregion:not(.blk-imgregion-provisional)'))) node.remove();
|
|
2243
|
+
const groups = new Map();
|
|
2244
|
+
for (const a of ctx.annotate.list()) {
|
|
2245
|
+
if (a.blockId !== blockId || !a.target || a.target.kind !== 'image-region') continue;
|
|
2246
|
+
const t = a.target;
|
|
2247
|
+
const key = [t.side || '', t.x, t.y, t.w, t.h].join(':');
|
|
2248
|
+
const group = groups.get(key) || { target: t, count: 0 };
|
|
2249
|
+
group.count++;
|
|
2250
|
+
groups.set(key, group);
|
|
2251
|
+
}
|
|
2252
|
+
for (const { target, count } of groups.values()) {
|
|
2253
|
+
layer.append(createRegionZone(target, count));
|
|
2254
|
+
}
|
|
2255
|
+
};
|
|
2256
|
+
if (ctx.annotate.onBadgeRefresh) ctx.annotate.onBadgeRefresh(syncRegions);
|
|
2257
|
+
syncRegions();
|
|
2258
|
+
return controller;
|
|
2259
|
+
}
|
|
2260
|
+
|
|
1987
2261
|
// ---------- palette ----------
|
|
1988
2262
|
// Color palettes as swatch cards: hover a swatch to reveal its hex, click to
|
|
1989
2263
|
// copy it. One palette may be {featured:true} → a larger spotlight row. Lets
|
|
@@ -2125,19 +2399,54 @@
|
|
|
2125
2399
|
window.addEventListener('resize', sizeBefore);
|
|
2126
2400
|
|
|
2127
2401
|
let dragging = false;
|
|
2402
|
+
let dragMoved = false;
|
|
2403
|
+
let dragStartX = 0;
|
|
2128
2404
|
const setFromX = (clientX) => {
|
|
2129
2405
|
const r = frame.getBoundingClientRect();
|
|
2130
2406
|
if (r.width) { pos = Math.max(0, Math.min(100, ((clientX - r.left) / r.width) * 100)); apply(); }
|
|
2131
2407
|
};
|
|
2132
|
-
frame.addEventListener('pointerdown', (e) => {
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2408
|
+
frame.addEventListener('pointerdown', (e) => {
|
|
2409
|
+
const handleTarget = e.target.closest('.cmp-handle');
|
|
2410
|
+
if (!handleTarget || frame._rlyRegionMode) return;
|
|
2411
|
+
dragging = true;
|
|
2412
|
+
dragMoved = false;
|
|
2413
|
+
dragStartX = e.clientX;
|
|
2414
|
+
try { frame.setPointerCapture(e.pointerId); } catch (_) {}
|
|
2415
|
+
});
|
|
2416
|
+
frame.addEventListener('pointermove', (e) => {
|
|
2417
|
+
if (!dragging || frame._rlyRegionMode || frame._rlyRegionSelecting) return;
|
|
2418
|
+
if (Math.abs(e.clientX - dragStartX) > 3) dragMoved = true;
|
|
2419
|
+
if (dragMoved) { setFromX(e.clientX); e.preventDefault(); }
|
|
2420
|
+
});
|
|
2421
|
+
frame.addEventListener('pointerup', (e) => {
|
|
2422
|
+
if (dragging && !dragMoved && !frame._rlyRegionMode && !frame._rlyRegionSelecting) setFromX(e.clientX);
|
|
2423
|
+
dragging = false;
|
|
2424
|
+
});
|
|
2425
|
+
frame.addEventListener('pointercancel', () => { dragging = false; dragMoved = false; });
|
|
2136
2426
|
handle.addEventListener('keydown', (e) => {
|
|
2137
2427
|
if (e.key === 'ArrowLeft') { pos = Math.max(0, pos - 2); apply(); e.preventDefault(); }
|
|
2138
2428
|
else if (e.key === 'ArrowRight') { pos = Math.min(100, pos + 2); apply(); e.preventDefault(); }
|
|
2139
2429
|
});
|
|
2140
|
-
|
|
2430
|
+
let region = null;
|
|
2431
|
+
if (ctx.annotate && ctx.canComment !== false) {
|
|
2432
|
+
frame.title = 'Drag to draw a comment area · drag the divider handle to compare';
|
|
2433
|
+
region = enableImageRegions({
|
|
2434
|
+
host: frame,
|
|
2435
|
+
surface: frame,
|
|
2436
|
+
panHost: frame,
|
|
2437
|
+
sourceForSide: (side) => side === 'before' ? beforeImg : afterImg,
|
|
2438
|
+
sideAtPoint: (x) => (x * 100 <= pos ? 'before' : 'after'),
|
|
2439
|
+
ctx,
|
|
2440
|
+
blockId,
|
|
2441
|
+
label: 'Comparison',
|
|
2442
|
+
});
|
|
2443
|
+
}
|
|
2444
|
+
attachViewer(wrap, {
|
|
2445
|
+
zoomEl: null,
|
|
2446
|
+
label: 'comparison',
|
|
2447
|
+
comment: wholeBlockComment(ctx, blockId, 'comparison'),
|
|
2448
|
+
region,
|
|
2449
|
+
});
|
|
2141
2450
|
return wrap;
|
|
2142
2451
|
}
|
|
2143
2452
|
|
|
@@ -2170,6 +2479,10 @@
|
|
|
2170
2479
|
const ICON_COMMENT =
|
|
2171
2480
|
'<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
|
|
2172
2481
|
'<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8z"/></svg>';
|
|
2482
|
+
const ICON_REGION =
|
|
2483
|
+
'<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
|
|
2484
|
+
'<rect x="3" y="4" width="14" height="12" rx="2" stroke-dasharray="3 2"/>' +
|
|
2485
|
+
'<path d="M14 14.5h6.5v4H18l-2.5 2v-2H14z" fill="currentColor" stroke="none"/></svg>';
|
|
2173
2486
|
|
|
2174
2487
|
function exitFull() {
|
|
2175
2488
|
if (!fullOpen) return;
|
|
@@ -2181,7 +2494,6 @@
|
|
|
2181
2494
|
if (btn) btn.innerHTML = ICON_EXPAND;
|
|
2182
2495
|
fullOpen = null;
|
|
2183
2496
|
window.dispatchEvent(new Event('resize'));
|
|
2184
|
-
if (c._rlyToolsSync) c._rlyToolsSync(); // re-pin toolbar to its scrolled corner
|
|
2185
2497
|
if (c._rlyZoom) c._rlyZoom.reapply(); // restore the compact inline height cap
|
|
2186
2498
|
}
|
|
2187
2499
|
document.addEventListener('keydown', (e) => {
|
|
@@ -2200,7 +2512,6 @@
|
|
|
2200
2512
|
const btn = container.querySelector('.blk-tools .tool-full');
|
|
2201
2513
|
if (btn) btn.textContent = '✕';
|
|
2202
2514
|
window.dispatchEvent(new Event('resize'));
|
|
2203
|
-
if (container._rlyToolsSync) container._rlyToolsSync();
|
|
2204
2515
|
if (container._rlyZoom) container._rlyZoom.reapply();
|
|
2205
2516
|
}
|
|
2206
2517
|
|
|
@@ -2293,7 +2604,8 @@
|
|
|
2293
2604
|
const refresh = () => scrollEl.classList.toggle('blk-pannable', pannable());
|
|
2294
2605
|
|
|
2295
2606
|
scrollEl.addEventListener('pointerdown', (e) => {
|
|
2296
|
-
|
|
2607
|
+
const panGesture = e.button === 1 || (e.button === 0 && (!scrollEl._rlyHasRegions || viewerSpacePan));
|
|
2608
|
+
if (!panGesture || scrollEl._rlyRegionMode || !pannable()) return;
|
|
2297
2609
|
// leave the toolbar, the diagram editor, and real controls interactive
|
|
2298
2610
|
if (e.target.closest && e.target.closest('.blk-tools, .blk-editor, button, a, input, textarea, select')) return;
|
|
2299
2611
|
pending = true; active = false;
|
|
@@ -2303,6 +2615,7 @@
|
|
|
2303
2615
|
});
|
|
2304
2616
|
scrollEl.addEventListener('pointermove', (e) => {
|
|
2305
2617
|
if (!pending) return;
|
|
2618
|
+
if (scrollEl._rlyRegionMode || scrollEl._rlyRegionSelecting) { pending = false; active = false; return; }
|
|
2306
2619
|
const dx = e.clientX - sx, dy = e.clientY - sy;
|
|
2307
2620
|
if (!active) {
|
|
2308
2621
|
if (Math.abs(dx) < THRESH && Math.abs(dy) < THRESH) return;
|
|
@@ -2353,6 +2666,10 @@
|
|
|
2353
2666
|
// comment (optional) = { open(anchor), active(), subscribe(fn) } wiring the
|
|
2354
2667
|
// "comment on the whole block" button + its already-commented style.
|
|
2355
2668
|
function attachViewer(container, opts) {
|
|
2669
|
+
if (container._rlyRegionHook) {
|
|
2670
|
+
try { container._rlyRegionHook(); } catch (_) {}
|
|
2671
|
+
container._rlyRegionHook = null;
|
|
2672
|
+
}
|
|
2356
2673
|
if (container._rlyTools) container._rlyTools.remove();
|
|
2357
2674
|
container.classList.add('blk-viewer');
|
|
2358
2675
|
const zoomable = Boolean(opts && opts.zoomEl);
|
|
@@ -2363,22 +2680,6 @@
|
|
|
2363
2680
|
if (!container._rlyPan) container._rlyPan = enablePan(container);
|
|
2364
2681
|
const refreshPan = container._rlyPan;
|
|
2365
2682
|
|
|
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
2683
|
// zoom level persists across re-renders; the wheel handler (bound once)
|
|
2383
2684
|
// delegates through container._rlyZoom so it never holds a stale zoomEl
|
|
2384
2685
|
if (container._rlyZ === undefined) container._rlyZ = null; // null = fit-to-width
|
|
@@ -2427,7 +2728,6 @@
|
|
|
2427
2728
|
}
|
|
2428
2729
|
window.dispatchEvent(new Event('resize')); // annotation badges reposition
|
|
2429
2730
|
refreshPan(); // content size → grab affordance
|
|
2430
|
-
container._rlyToolsSync(); // keep the toolbar pinned
|
|
2431
2731
|
}
|
|
2432
2732
|
function currentZ() {
|
|
2433
2733
|
if (container._rlyZ !== null) return container._rlyZ;
|
|
@@ -2484,6 +2784,34 @@
|
|
|
2484
2784
|
}
|
|
2485
2785
|
}
|
|
2486
2786
|
|
|
2787
|
+
if (opts && opts.region) {
|
|
2788
|
+
const regionCtl = opts.region;
|
|
2789
|
+
const regionLabel = el('span', { class: 'tool-region-label' }, 'Area');
|
|
2790
|
+
const regionBtn = el('button', {
|
|
2791
|
+
class: 'tool-region',
|
|
2792
|
+
type: 'button',
|
|
2793
|
+
title: 'Draw an area comment',
|
|
2794
|
+
'aria-label': 'Draw an area comment',
|
|
2795
|
+
'aria-pressed': 'false',
|
|
2796
|
+
});
|
|
2797
|
+
regionBtn.innerHTML = ICON_REGION;
|
|
2798
|
+
regionBtn.append(regionLabel);
|
|
2799
|
+
const syncRegionMode = (enabled) => {
|
|
2800
|
+
regionBtn.classList.toggle('is-active', enabled);
|
|
2801
|
+
regionBtn.setAttribute('aria-pressed', enabled ? 'true' : 'false');
|
|
2802
|
+
regionBtn.title = enabled ? 'Cancel area comment' : 'Draw an area comment';
|
|
2803
|
+
regionBtn.setAttribute('aria-label', regionBtn.title);
|
|
2804
|
+
regionLabel.textContent = enabled ? 'Drawing' : 'Area';
|
|
2805
|
+
};
|
|
2806
|
+
regionBtn.addEventListener('click', (e) => {
|
|
2807
|
+
e.stopPropagation();
|
|
2808
|
+
regionCtl.setModeActive(!regionCtl.isModeActive());
|
|
2809
|
+
});
|
|
2810
|
+
container._rlyRegionHook = regionCtl.onModeChange(syncRegionMode);
|
|
2811
|
+
syncRegionMode(regionCtl.isModeActive());
|
|
2812
|
+
tools.append(regionBtn);
|
|
2813
|
+
}
|
|
2814
|
+
|
|
2487
2815
|
if (zoomable) {
|
|
2488
2816
|
// cmd/ctrl+wheel zoom, bound ONCE (re-binding per render would stack);
|
|
2489
2817
|
// delegates to the current controller so it never uses a stale zoomEl
|
|
@@ -2550,12 +2878,11 @@
|
|
|
2550
2878
|
});
|
|
2551
2879
|
tools.append(fullBtn);
|
|
2552
2880
|
|
|
2553
|
-
container.
|
|
2881
|
+
container.prepend(tools);
|
|
2554
2882
|
container._rlyTools = tools;
|
|
2555
2883
|
if (zoomable) apply();
|
|
2556
2884
|
// non-zoomable diagrams (tall mermaid, oversized image) can still overflow
|
|
2557
2885
|
refreshPan();
|
|
2558
|
-
container._rlyToolsSync();
|
|
2559
2886
|
if (container._rlyCmtSync) container._rlyCmtSync(); // reflect existing comment
|
|
2560
2887
|
}
|
|
2561
2888
|
|
|
@@ -2678,6 +3005,8 @@
|
|
|
2678
3005
|
// reports an accepted change (or null to clear back to the original).
|
|
2679
3006
|
edits: ctx && ctx.edits ? ctx.edits : {},
|
|
2680
3007
|
canComment: !ctx || ctx.canComment !== false,
|
|
3008
|
+
saveArtifact:
|
|
3009
|
+
ctx && typeof ctx.saveArtifact === 'function' ? ctx.saveArtifact : null,
|
|
2681
3010
|
canEditBlocks: !ctx || ctx.canEditBlocks !== false,
|
|
2682
3011
|
onBlockEdit:
|
|
2683
3012
|
ctx && typeof ctx.onBlockEdit === 'function' ? ctx.onBlockEdit : () => {},
|