@khanglvm/relay 0.5.4 → 0.7.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 +16 -0
- package/docs/AGENT.md +53 -17
- package/package.json +1 -1
- package/skills/relay/SKILL.md +10 -3
- package/src/cli.js +119 -2
- package/src/server.js +111 -14
- package/src/spec.js +7 -4
- package/src/ui/annotate.css +93 -1
- package/src/ui/annotate.js +259 -11
- package/src/ui/app.js +125 -39
- package/src/ui/blocks.css +25 -7
- package/src/ui/blocks.js +54 -1
- package/src/ui/kit.js +335 -27
- package/src/ui/style.css +8 -2
package/src/ui/annotate.css
CHANGED
|
@@ -167,7 +167,81 @@
|
|
|
167
167
|
}
|
|
168
168
|
.ann-selbtn:hover { background: var(--accent-hover); }
|
|
169
169
|
|
|
170
|
-
/* ----------
|
|
170
|
+
/* ---------- inline text highlight ---------- */
|
|
171
|
+
/* Commented text in the intro / markdown gets a soft accent highlight that is
|
|
172
|
+
itself clickable (opens the comment popover). */
|
|
173
|
+
.ann-hl {
|
|
174
|
+
background: var(--accent-soft);
|
|
175
|
+
color: inherit;
|
|
176
|
+
border-radius: 3px;
|
|
177
|
+
box-shadow: inset 0 -1px 0 var(--accent);
|
|
178
|
+
padding: 0 1px;
|
|
179
|
+
cursor: pointer;
|
|
180
|
+
transition: background 150ms var(--ease), color 150ms var(--ease);
|
|
181
|
+
}
|
|
182
|
+
.ann-hl:hover { background: var(--accent); color: var(--accent-fg); }
|
|
183
|
+
|
|
184
|
+
/* ---------- comments rail (Outline-style right sidebar) ---------- */
|
|
185
|
+
.ann-rail-toggle {
|
|
186
|
+
position: fixed; z-index: 57; right: 20px; bottom: 20px;
|
|
187
|
+
display: none; align-items: center; gap: 7px;
|
|
188
|
+
background: var(--card); color: var(--fg-2);
|
|
189
|
+
border: 1px solid var(--border-strong); border-radius: 999px;
|
|
190
|
+
padding: 9px 16px; cursor: pointer;
|
|
191
|
+
font: 600 0.82rem var(--sans);
|
|
192
|
+
box-shadow: var(--shadow-lift);
|
|
193
|
+
transition: border-color 150ms var(--ease), color 150ms var(--ease);
|
|
194
|
+
}
|
|
195
|
+
.ann-rail-toggle:hover { border-color: var(--accent); color: var(--accent); }
|
|
196
|
+
.ann-rail-toggle-count {
|
|
197
|
+
display: inline-block; min-width: 18px;
|
|
198
|
+
background: var(--accent); color: var(--accent-fg);
|
|
199
|
+
border-radius: 999px; padding: 0 6px;
|
|
200
|
+
font: 600 0.72rem/18px var(--sans); text-align: center;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
.ann-rail-scrim {
|
|
204
|
+
position: fixed; inset: 0; z-index: 61;
|
|
205
|
+
background: rgba(28, 27, 25, 0.34);
|
|
206
|
+
display: none;
|
|
207
|
+
}
|
|
208
|
+
.ann-rail-scrim.show { display: block; }
|
|
209
|
+
|
|
210
|
+
.ann-rail {
|
|
211
|
+
position: fixed; z-index: 62; top: 0; right: 0; bottom: 0;
|
|
212
|
+
width: min(340px, 88vw);
|
|
213
|
+
display: flex; flex-direction: column;
|
|
214
|
+
background: var(--card);
|
|
215
|
+
border-left: 1px solid var(--border);
|
|
216
|
+
box-shadow: var(--shadow-lift);
|
|
217
|
+
transform: translateX(100%);
|
|
218
|
+
transition: transform 240ms var(--ease);
|
|
219
|
+
}
|
|
220
|
+
.ann-rail.open { transform: translateX(0); }
|
|
221
|
+
.ann-rail-head {
|
|
222
|
+
flex: none;
|
|
223
|
+
display: flex; align-items: center; gap: 8px;
|
|
224
|
+
padding: 16px 18px;
|
|
225
|
+
border-bottom: 1px solid var(--border);
|
|
226
|
+
}
|
|
227
|
+
.ann-rail-title { font: 500 1.05rem var(--serif); letter-spacing: -0.01em; }
|
|
228
|
+
.ann-rail-headcount {
|
|
229
|
+
color: var(--muted); font-size: 0.8rem;
|
|
230
|
+
background: var(--bg-sunken); border-radius: 999px; padding: 1px 8px;
|
|
231
|
+
}
|
|
232
|
+
.ann-rail-headcount:empty { display: none; }
|
|
233
|
+
.ann-rail-close {
|
|
234
|
+
margin-left: auto;
|
|
235
|
+
background: transparent; color: var(--muted);
|
|
236
|
+
border: none; border-radius: 8px;
|
|
237
|
+
width: 28px; height: 28px;
|
|
238
|
+
font-size: 1.3rem; line-height: 1; cursor: pointer;
|
|
239
|
+
transition: color 150ms var(--ease), background 150ms var(--ease);
|
|
240
|
+
}
|
|
241
|
+
.ann-rail-close:hover { color: var(--fg); background: var(--bg-sunken); }
|
|
242
|
+
.ann-rail-list { flex: 1; overflow-y: auto; padding: 12px 16px 28px; }
|
|
243
|
+
|
|
244
|
+
/* ---------- summary list (rows) ---------- */
|
|
171
245
|
.ann-sum-head {
|
|
172
246
|
font: 600 0.95rem var(--sans);
|
|
173
247
|
margin: 0 0 2px;
|
|
@@ -184,6 +258,24 @@
|
|
|
184
258
|
.ann-sum-meta { display: flex; align-items: center; gap: 6px; margin-top: 2px; }
|
|
185
259
|
.ann-sum-replies { color: var(--muted); font-size: 0.75rem; }
|
|
186
260
|
|
|
261
|
+
/* Rail rows render as discrete cards rather than a hairline-divided list. */
|
|
262
|
+
.ann-rail-list .ann-sum-row {
|
|
263
|
+
padding: 10px 12px;
|
|
264
|
+
margin-bottom: 8px;
|
|
265
|
+
border: 1px solid var(--border);
|
|
266
|
+
border-radius: 10px;
|
|
267
|
+
background: var(--bg);
|
|
268
|
+
transition: border-color 150ms var(--ease), box-shadow 150ms var(--ease);
|
|
269
|
+
}
|
|
270
|
+
.ann-rail-list .ann-sum-row + .ann-sum-row { border-top: 1px solid var(--border); }
|
|
271
|
+
.ann-rail-list .ann-sum-row:hover { border-color: var(--accent); box-shadow: var(--shadow-card); }
|
|
272
|
+
|
|
273
|
+
/* Wide screens: dock the rail beside the content instead of overlaying it. */
|
|
274
|
+
@media (min-width: 1180px) {
|
|
275
|
+
.ann-rail-scrim { display: none !important; }
|
|
276
|
+
body.ann-rail-open { padding-right: 340px; }
|
|
277
|
+
}
|
|
278
|
+
|
|
187
279
|
/* ---------- jump-to flash ---------- */
|
|
188
280
|
.ann-flash { animation: ann-flash 1s var(--ease); }
|
|
189
281
|
@keyframes ann-flash {
|
package/src/ui/annotate.js
CHANGED
|
@@ -90,13 +90,163 @@
|
|
|
90
90
|
let badges = []; // live badge nodes (rebuilt on every refresh)
|
|
91
91
|
let pendingSelection = null; // {info} captured at mouseup time
|
|
92
92
|
|
|
93
|
-
|
|
93
|
+
// Text-selection comments are anchored back into the document as inline
|
|
94
|
+
// <mark> highlights. textRoots maps a blockId (null = intro/board) to the
|
|
95
|
+
// element whose text can be highlighted; highlightMap maps a target
|
|
96
|
+
// signature to its live <mark> nodes so we can flash or unwrap them.
|
|
97
|
+
const textRoots = new Map();
|
|
98
|
+
const highlightMap = new Map();
|
|
99
|
+
|
|
100
|
+
let dom = null; // {pin, selBtn, pop, rail, railList, railToggle, railScrim, ...}
|
|
94
101
|
let pinTimer = null;
|
|
95
102
|
let pinEntry = null;
|
|
96
103
|
let popOpen = false;
|
|
97
104
|
let popScrollY = 0;
|
|
98
105
|
let popSave = null;
|
|
99
106
|
let badgeTimer = 0;
|
|
107
|
+
let railOpen = false;
|
|
108
|
+
|
|
109
|
+
// Stable signature for a (blockId, target) pair — same key matching() groups by.
|
|
110
|
+
const sigOf = (blockId, target) => JSON.stringify({ b: blockId ?? null, t: target });
|
|
111
|
+
|
|
112
|
+
// Brief highlight pulse used by the rail's jump-to.
|
|
113
|
+
function flashEl(target) {
|
|
114
|
+
if (!target) return;
|
|
115
|
+
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
116
|
+
target.classList.remove('ann-flash');
|
|
117
|
+
void target.getBoundingClientRect(); // restart the animation
|
|
118
|
+
target.classList.add('ann-flash');
|
|
119
|
+
setTimeout(() => target.classList.remove('ann-flash'), 1000);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ---------- text-selection highlights ----------
|
|
123
|
+
// Re-locate a saved text quote inside a root using its prefix/suffix anchors,
|
|
124
|
+
// returning a live Range (or null when the text can't be found anymore).
|
|
125
|
+
function findRangeByQuote(root, target) {
|
|
126
|
+
const quote = target && target.quote ? String(target.quote) : '';
|
|
127
|
+
if (!quote) return null;
|
|
128
|
+
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null);
|
|
129
|
+
const map = []; // [{ node, start }] start = offset in `full`
|
|
130
|
+
let full = '';
|
|
131
|
+
let node;
|
|
132
|
+
while ((node = walker.nextNode())) {
|
|
133
|
+
map.push({ node, start: full.length });
|
|
134
|
+
full += node.nodeValue;
|
|
135
|
+
}
|
|
136
|
+
if (!map.length) return null;
|
|
137
|
+
const prefix = target.prefix || '';
|
|
138
|
+
const suffix = target.suffix || '';
|
|
139
|
+
const hits = [];
|
|
140
|
+
let from = 0;
|
|
141
|
+
let idx;
|
|
142
|
+
while ((idx = full.indexOf(quote, from)) !== -1) {
|
|
143
|
+
hits.push(idx);
|
|
144
|
+
from = idx + 1;
|
|
145
|
+
}
|
|
146
|
+
if (!hits.length) return null;
|
|
147
|
+
let chosen = hits[0];
|
|
148
|
+
for (const h of hits) {
|
|
149
|
+
const before = full.slice(Math.max(0, h - prefix.length), h);
|
|
150
|
+
const after = full.slice(h + quote.length, h + quote.length + suffix.length);
|
|
151
|
+
if ((!prefix || before.endsWith(prefix)) && (!suffix || after.startsWith(suffix))) {
|
|
152
|
+
chosen = h;
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
const locate = (off) => {
|
|
157
|
+
for (const m of map) {
|
|
158
|
+
if (off <= m.start + m.node.nodeValue.length) return { node: m.node, offset: off - m.start };
|
|
159
|
+
}
|
|
160
|
+
const last = map[map.length - 1];
|
|
161
|
+
return { node: last.node, offset: last.node.nodeValue.length };
|
|
162
|
+
};
|
|
163
|
+
const s = locate(chosen);
|
|
164
|
+
const e = locate(chosen + quote.length);
|
|
165
|
+
const range = document.createRange();
|
|
166
|
+
try {
|
|
167
|
+
range.setStart(s.node, s.offset);
|
|
168
|
+
range.setEnd(e.node, e.offset);
|
|
169
|
+
} catch {
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
return range;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Wrap every text-node segment inside `range` in its own <mark>; clicking a
|
|
176
|
+
// mark opens the comment popover for that target. Returns the mark nodes.
|
|
177
|
+
function wrapRange(range, sig, info) {
|
|
178
|
+
const sC = range.startContainer;
|
|
179
|
+
const sO = range.startOffset;
|
|
180
|
+
const eC = range.endContainer;
|
|
181
|
+
const eO = range.endOffset;
|
|
182
|
+
const anc = range.commonAncestorContainer;
|
|
183
|
+
const root = anc.nodeType === 1 ? anc : anc.parentNode;
|
|
184
|
+
if (!root) return [];
|
|
185
|
+
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null);
|
|
186
|
+
const all = [];
|
|
187
|
+
let n;
|
|
188
|
+
while ((n = walker.nextNode())) all.push(n);
|
|
189
|
+
// Decide membership BEFORE mutating the DOM (splitText adds new siblings).
|
|
190
|
+
const inRange = all.filter((node) => {
|
|
191
|
+
try {
|
|
192
|
+
return range.intersectsNode(node);
|
|
193
|
+
} catch {
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
const marks = [];
|
|
198
|
+
for (const orig of inRange) {
|
|
199
|
+
let fromOff = 0;
|
|
200
|
+
let toOff = orig.nodeValue.length;
|
|
201
|
+
if (orig === sC) fromOff = sO;
|
|
202
|
+
if (orig === eC) toOff = eO;
|
|
203
|
+
if (fromOff >= toOff) continue;
|
|
204
|
+
let seg = orig;
|
|
205
|
+
if (fromOff > 0) seg = seg.splitText(fromOff);
|
|
206
|
+
if (toOff - fromOff < seg.nodeValue.length) seg.splitText(toOff - fromOff);
|
|
207
|
+
const mark = el('mark', { class: 'ann-hl', 'data-sig': sig });
|
|
208
|
+
seg.parentNode.insertBefore(mark, seg);
|
|
209
|
+
mark.appendChild(seg);
|
|
210
|
+
mark.addEventListener('click', (ev) => {
|
|
211
|
+
ev.stopPropagation();
|
|
212
|
+
openPopover(info, mark);
|
|
213
|
+
});
|
|
214
|
+
marks.push(mark);
|
|
215
|
+
}
|
|
216
|
+
return marks;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function unwrapHighlight(sig) {
|
|
220
|
+
const marks = highlightMap.get(sig);
|
|
221
|
+
if (!marks) return;
|
|
222
|
+
for (const m of marks) {
|
|
223
|
+
if (!m.isConnected) continue;
|
|
224
|
+
const parent = m.parentNode;
|
|
225
|
+
while (m.firstChild) parent.insertBefore(m.firstChild, m);
|
|
226
|
+
parent.removeChild(m);
|
|
227
|
+
parent.normalize();
|
|
228
|
+
}
|
|
229
|
+
highlightMap.delete(sig);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Highlight every saved text annotation whose blockId matches this root and
|
|
233
|
+
// that isn't already wrapped (idempotent — safe to call on every register).
|
|
234
|
+
function applyHighlightsForRoot(root, blockId) {
|
|
235
|
+
const key = blockId ?? null;
|
|
236
|
+
const seen = new Set();
|
|
237
|
+
for (const a of annotations) {
|
|
238
|
+
if (!a.target || a.target.kind !== 'text') continue;
|
|
239
|
+
if ((a.blockId ?? null) !== key) continue;
|
|
240
|
+
const sig = sigOf(a.blockId, a.target);
|
|
241
|
+
if (seen.has(sig) || highlightMap.has(sig)) continue;
|
|
242
|
+
seen.add(sig);
|
|
243
|
+
const range = findRangeByQuote(root, a.target);
|
|
244
|
+
if (!range) continue;
|
|
245
|
+
const info = { blockId: a.blockId ?? null, questionId: a.questionId ?? null, target: a.target };
|
|
246
|
+
const marks = wrapRange(range, sig, info);
|
|
247
|
+
if (marks.length) highlightMap.set(sig, marks);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
100
250
|
|
|
101
251
|
// ---------- singleton DOM ----------
|
|
102
252
|
function ensureDom() {
|
|
@@ -135,8 +285,36 @@
|
|
|
135
285
|
}
|
|
136
286
|
});
|
|
137
287
|
|
|
138
|
-
|
|
139
|
-
|
|
288
|
+
// Comments rail (Outline-style right sidebar) + its floating toggle and
|
|
289
|
+
// narrow-screen scrim. The list is a summary target, so renderSummaries()
|
|
290
|
+
// keeps it in sync; chrome (count, visibility) is refreshed separately.
|
|
291
|
+
const railToggleCount = el('span', { class: 'ann-rail-toggle-count' }, '0');
|
|
292
|
+
const railToggle = el('button', { class: 'ann-rail-toggle', type: 'button', 'aria-label': 'Comments' });
|
|
293
|
+
railToggle.innerHTML =
|
|
294
|
+
'<svg viewBox="0 0 16 16" width="13" height="13" aria-hidden="true">' +
|
|
295
|
+
'<path d="M3 2.5h10A1.5 1.5 0 0 1 14.5 4v5a1.5 1.5 0 0 1-1.5 1.5H8.4L5 13.4v-2.9H3A1.5 1.5 0 0 1 1.5 9V4A1.5 1.5 0 0 1 3 2.5Z" fill="currentColor"/></svg>';
|
|
296
|
+
railToggle.append(el('span', { class: 'ann-rail-toggle-label' }, 'Comments'), railToggleCount);
|
|
297
|
+
railToggle.addEventListener('click', toggleRail);
|
|
298
|
+
|
|
299
|
+
const railScrim = el('div', { class: 'ann-rail-scrim' });
|
|
300
|
+
railScrim.addEventListener('click', closeRail);
|
|
301
|
+
|
|
302
|
+
const railClose = el('button', { class: 'ann-rail-close', type: 'button', 'aria-label': 'Close comments', title: 'Close' }, '×');
|
|
303
|
+
railClose.addEventListener('click', closeRail);
|
|
304
|
+
const railHeadCount = el('span', { class: 'ann-rail-headcount' }, '');
|
|
305
|
+
const railList = el('div', { class: 'ann-rail-list' });
|
|
306
|
+
const rail = el('aside', { class: 'ann-rail', 'aria-label': 'Comments' },
|
|
307
|
+
el('div', { class: 'ann-rail-head' },
|
|
308
|
+
el('span', { class: 'ann-rail-title' }, 'Comments'),
|
|
309
|
+
railHeadCount,
|
|
310
|
+
railClose
|
|
311
|
+
),
|
|
312
|
+
railList
|
|
313
|
+
);
|
|
314
|
+
|
|
315
|
+
document.body.append(pin, selBtn, pop, railToggle, railScrim, rail);
|
|
316
|
+
dom = { pin, selBtn, pop, railToggle, railToggleCount, railScrim, rail, railList, railHeadCount };
|
|
317
|
+
summaryEls.add(railList);
|
|
140
318
|
|
|
141
319
|
document.addEventListener('selectionchange', () => {
|
|
142
320
|
if (dom.selBtn.style.display === '' || dom.selBtn.style.display === 'none') return;
|
|
@@ -163,6 +341,36 @@
|
|
|
163
341
|
window.addEventListener('resize', scheduleBadgeRefresh);
|
|
164
342
|
}
|
|
165
343
|
|
|
344
|
+
// ---------- comments rail ----------
|
|
345
|
+
function openRail() {
|
|
346
|
+
if (!dom || torndown || !annotations.length) return;
|
|
347
|
+
railOpen = true;
|
|
348
|
+
dom.rail.classList.add('open');
|
|
349
|
+
dom.railScrim.classList.add('show');
|
|
350
|
+
document.body.classList.add('ann-rail-open');
|
|
351
|
+
}
|
|
352
|
+
function closeRail() {
|
|
353
|
+
railOpen = false;
|
|
354
|
+
if (!dom) return;
|
|
355
|
+
dom.rail.classList.remove('open');
|
|
356
|
+
dom.railScrim.classList.remove('show');
|
|
357
|
+
document.body.classList.remove('ann-rail-open');
|
|
358
|
+
}
|
|
359
|
+
function toggleRail() {
|
|
360
|
+
if (railOpen) closeRail();
|
|
361
|
+
else openRail();
|
|
362
|
+
}
|
|
363
|
+
// Sync the toggle badge + rail count with the live list; hide the whole rail
|
|
364
|
+
// affordance when there are no comments.
|
|
365
|
+
function refreshRailChrome() {
|
|
366
|
+
if (!dom) return;
|
|
367
|
+
const n = annotations.length;
|
|
368
|
+
dom.railToggleCount.textContent = String(n);
|
|
369
|
+
dom.railHeadCount.textContent = n ? `${n}` : '';
|
|
370
|
+
dom.railToggle.style.display = n ? 'inline-flex' : 'none';
|
|
371
|
+
if (!n) closeRail();
|
|
372
|
+
}
|
|
373
|
+
|
|
166
374
|
// ---------- hover pin ----------
|
|
167
375
|
function showPin(entry) {
|
|
168
376
|
clearTimeout(pinTimer);
|
|
@@ -345,7 +553,21 @@
|
|
|
345
553
|
author: 'user',
|
|
346
554
|
replies: [],
|
|
347
555
|
});
|
|
556
|
+
// Highlight the selected text in place (re-located via its quote anchors,
|
|
557
|
+
// since the live selection is already gone by save time).
|
|
558
|
+
if (info.target && info.target.kind === 'text') {
|
|
559
|
+
const sig = sigOf(info.blockId, info.target);
|
|
560
|
+
const root = textRoots.get(info.blockId ?? null);
|
|
561
|
+
if (root && !highlightMap.has(sig)) {
|
|
562
|
+
const range = findRangeByQuote(root, info.target);
|
|
563
|
+
if (range) {
|
|
564
|
+
const marks = wrapRange(range, sig, { blockId: info.blockId ?? null, questionId: info.questionId ?? null, target: info.target });
|
|
565
|
+
if (marks.length) highlightMap.set(sig, marks);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
}
|
|
348
569
|
changed();
|
|
570
|
+
openRail(); // surface the new comment in the sidebar, Outline-style
|
|
349
571
|
}
|
|
350
572
|
|
|
351
573
|
// Append a user reply to a top-level annotation's thread (cap 50, like the
|
|
@@ -366,13 +588,20 @@
|
|
|
366
588
|
function removeAnnotation(id) {
|
|
367
589
|
const i = annotations.findIndex((a) => a.id === id);
|
|
368
590
|
if (i < 0) return;
|
|
591
|
+
const removed = annotations[i];
|
|
369
592
|
annotations.splice(i, 1);
|
|
593
|
+
// Drop the inline highlight once the last comment on that text is gone.
|
|
594
|
+
if (removed.target && removed.target.kind === 'text') {
|
|
595
|
+
const sig = sigOf(removed.blockId, removed.target);
|
|
596
|
+
if (!annotations.some((a) => sigOf(a.blockId, a.target) === sig)) unwrapHighlight(sig);
|
|
597
|
+
}
|
|
370
598
|
changed();
|
|
371
599
|
}
|
|
372
600
|
|
|
373
601
|
function changed() {
|
|
374
602
|
scheduleBadgeRefresh();
|
|
375
603
|
renderSummaries();
|
|
604
|
+
refreshRailChrome();
|
|
376
605
|
if (onChange) onChange(annotations.slice());
|
|
377
606
|
}
|
|
378
607
|
|
|
@@ -434,6 +663,7 @@
|
|
|
434
663
|
}
|
|
435
664
|
|
|
436
665
|
function renderSummaryInto(target) {
|
|
666
|
+
const isRail = target.classList.contains('ann-rail-list');
|
|
437
667
|
target.classList.add('ann-summary');
|
|
438
668
|
target.replaceChildren();
|
|
439
669
|
if (!annotations.length) {
|
|
@@ -441,7 +671,8 @@
|
|
|
441
671
|
return;
|
|
442
672
|
}
|
|
443
673
|
target.style.display = '';
|
|
444
|
-
|
|
674
|
+
// The rail has its own header chrome; standalone summaries get a heading.
|
|
675
|
+
if (!isRail) target.append(el('h3', { class: 'ann-sum-head' }, `Comments (${annotations.length})`));
|
|
445
676
|
for (const a of annotations) {
|
|
446
677
|
const del = el('button', { class: 'ann-del', type: 'button', title: 'Delete comment', 'aria-label': 'Delete comment' }, '×');
|
|
447
678
|
del.addEventListener('click', (e) => {
|
|
@@ -463,17 +694,22 @@
|
|
|
463
694
|
),
|
|
464
695
|
del
|
|
465
696
|
);
|
|
466
|
-
// Clicking a row jumps to (and flashes) the matching
|
|
697
|
+
// Clicking a row jumps to (and flashes) the matching element: a
|
|
698
|
+
// registered block element, or the inline highlight for a text comment.
|
|
467
699
|
row.addEventListener('click', () => {
|
|
468
700
|
const entry = registered.find(
|
|
469
701
|
(r) => r.el.isConnected && sameBlock(r.info.blockId, a.blockId) && sameTarget(r.info.target, a.target)
|
|
470
702
|
);
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
703
|
+
let jump = entry ? entry.el : null;
|
|
704
|
+
if (!jump && a.target && a.target.kind === 'text') {
|
|
705
|
+
const marks = highlightMap.get(sigOf(a.blockId, a.target));
|
|
706
|
+
jump = marks && marks.find((m) => m.isConnected);
|
|
707
|
+
}
|
|
708
|
+
if (!jump) return;
|
|
709
|
+
// On narrow screens the rail overlays the page — close it so the
|
|
710
|
+
// flashed target is actually visible.
|
|
711
|
+
if (isRail && window.matchMedia('(max-width: 1179px)').matches) closeRail();
|
|
712
|
+
flashEl(jump);
|
|
477
713
|
});
|
|
478
714
|
target.append(row);
|
|
479
715
|
}
|
|
@@ -506,6 +742,7 @@
|
|
|
506
742
|
}
|
|
507
743
|
scheduleBadgeRefresh();
|
|
508
744
|
renderSummaries();
|
|
745
|
+
refreshRailChrome();
|
|
509
746
|
}
|
|
510
747
|
|
|
511
748
|
// Remove every floating element (pin, badges, popover, selection button)
|
|
@@ -521,13 +758,20 @@
|
|
|
521
758
|
}
|
|
522
759
|
hidePin();
|
|
523
760
|
hideSelBtn();
|
|
761
|
+
closeRail();
|
|
524
762
|
for (const b of badges) b.remove();
|
|
525
763
|
badges = [];
|
|
526
764
|
registered = [];
|
|
765
|
+
textRoots.clear();
|
|
766
|
+
highlightMap.clear();
|
|
527
767
|
if (dom) {
|
|
528
768
|
dom.pin.remove();
|
|
529
769
|
dom.selBtn.remove();
|
|
530
770
|
dom.pop.remove();
|
|
771
|
+
dom.railToggle.remove();
|
|
772
|
+
dom.railScrim.remove();
|
|
773
|
+
dom.rail.remove();
|
|
774
|
+
summaryEls.delete(dom.railList);
|
|
531
775
|
dom = null;
|
|
532
776
|
}
|
|
533
777
|
}
|
|
@@ -546,11 +790,15 @@
|
|
|
546
790
|
function enableTextSelection(rootEl, baseInfo) {
|
|
547
791
|
if (torndown) return;
|
|
548
792
|
ensureDom();
|
|
793
|
+
const blockId = (baseInfo && baseInfo.blockId != null) ? baseInfo.blockId : null;
|
|
794
|
+
textRoots.set(blockId, rootEl);
|
|
549
795
|
rootEl.addEventListener('mouseup', () => {
|
|
550
796
|
if (torndown) return;
|
|
551
797
|
// defer: the selection settles after mouseup
|
|
552
798
|
setTimeout(() => maybeShowSelBtn(rootEl, baseInfo), 0);
|
|
553
799
|
});
|
|
800
|
+
// Re-anchor any saved text comments for this root (reload / prefill path).
|
|
801
|
+
applyHighlightsForRoot(rootEl, blockId);
|
|
554
802
|
}
|
|
555
803
|
|
|
556
804
|
function openExternal(info, anchorEl) {
|