@erdemtuna/doc-review 0.7.0 → 0.8.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 +58 -5
- package/package.json +3 -2
- package/src/SKILL.md +54 -7
- package/src/anchor-text.js +45 -0
- package/src/atomic-write.js +53 -0
- package/src/chrome-client.js +1272 -252
- package/src/chrome-session.js +70 -0
- package/src/chrome.css +278 -329
- package/src/chrome.html +81 -35
- package/src/cli.js +58 -132
- package/src/comment-anchor.js +22 -0
- package/src/comment-target.js +77 -0
- package/src/edit-limits.js +23 -0
- package/src/icons.js +251 -0
- package/src/paths.js +5 -1
- package/src/poll-transport.js +222 -0
- package/src/positioning.js +107 -0
- package/src/review-mode.js +59 -0
- package/src/sdk.js +792 -69
- package/src/serialize.js +0 -14
- package/src/server.js +39 -9
- package/src/setup-guidance.js +128 -0
- package/src/setup.js +41 -15
- package/src/state.js +37 -21
package/src/sdk.js
CHANGED
|
@@ -7,9 +7,12 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { buildContext, findQuote } from "./anchor-text.js";
|
|
9
9
|
import { hashClickAction, navigationHref } from "./click-target.js";
|
|
10
|
+
import { acceptedOpenGeneration, targetMessage } from "./comment-target.js";
|
|
10
11
|
import { classifyHref, externalHref, linkStyleFixup, listCommandFor, listStyleFixup, normalizeHref } from "./editing.js";
|
|
11
12
|
import { frameMessage, initializeChannelFromDocument, matchesFrameMessage } from "./frame-channel.js";
|
|
12
|
-
import {
|
|
13
|
+
import { iconMarkup } from "./icons.js";
|
|
14
|
+
import { keepBodyInReviewMode } from "./review-mode.js";
|
|
15
|
+
import { serializeDocument, UI_ATTR, MARK_ATTR } from "./serialize.js";
|
|
13
16
|
|
|
14
17
|
initializeChannelFromDocument();
|
|
15
18
|
|
|
@@ -24,7 +27,14 @@ const CHROME_ORIGIN = `${location.protocol}//${location.hostname === "127.0.0.1"
|
|
|
24
27
|
|
|
25
28
|
const post = (type, payload) => parent.postMessage(frameMessage(type, payload), CHROME_ORIGIN);
|
|
26
29
|
|
|
27
|
-
let pending = null; //
|
|
30
|
+
let pending = null; // semantic target plus cloned range/element until commit or cancel
|
|
31
|
+
let retarget = null;
|
|
32
|
+
let targetGeneration = 0;
|
|
33
|
+
let targetObserver = null;
|
|
34
|
+
let targetIntersection = null;
|
|
35
|
+
let reviewMode = "view";
|
|
36
|
+
let savePolicy = "writable";
|
|
37
|
+
let modeController = null;
|
|
28
38
|
let hoverTarget = null;
|
|
29
39
|
let hoverMove = null; // the innermost block under the cursor, movable via the handle
|
|
30
40
|
let hoverMedia = null; // the img/video under the cursor, resizable via the grip
|
|
@@ -32,9 +42,21 @@ let resizing = null; // live drag state while the grip is held
|
|
|
32
42
|
let suppressUntil = 0; // ignore the mouseup/click that ends a resize drag
|
|
33
43
|
let saveTimer = null;
|
|
34
44
|
let composeOpen = false;
|
|
45
|
+
let commentOpenRequestGeneration = null;
|
|
46
|
+
let activeCommentId = null;
|
|
47
|
+
let modeMenuOpen = false;
|
|
48
|
+
let lastTargetGeometrySignature = "";
|
|
49
|
+
let lastTargetRelation = null;
|
|
50
|
+
let geometryWatchTimer = null;
|
|
51
|
+
let watchedGeometrySignature = "";
|
|
52
|
+
let selectionTimer = null;
|
|
35
53
|
/** True when the page's own scripts rewrote the DOM before any user edit. */
|
|
36
54
|
let dynamic = false;
|
|
37
55
|
|
|
56
|
+
const diagnostic = (event, detail = {}) => {
|
|
57
|
+
console.info("[doc-review-frame]", { event, ...detail });
|
|
58
|
+
};
|
|
59
|
+
|
|
38
60
|
// ------------------------------------------------------------------ overlay
|
|
39
61
|
|
|
40
62
|
const host = document.createElement("div");
|
|
@@ -98,21 +120,33 @@ shadow.innerHTML = `
|
|
|
98
120
|
position: fixed; z-index: 2147483646; height: 0; display: none;
|
|
99
121
|
border-top: 2px solid #1b1a16; border-radius: 1px; pointer-events: none;
|
|
100
122
|
}
|
|
123
|
+
.comment-action {
|
|
124
|
+
position: fixed; z-index: 2147483647; width: 30px; height: 30px;
|
|
125
|
+
display: none; align-items: center; justify-content: center; padding: 0;
|
|
126
|
+
border: 1px solid #d9d5c9; border-radius: 999px; background: #1b1a16; color: #fff;
|
|
127
|
+
cursor: pointer; pointer-events: auto; box-shadow: 0 5px 16px rgba(27,26,22,.24);
|
|
128
|
+
}
|
|
129
|
+
.comment-action:hover, .comment-action:focus-visible { background: #34312b; outline: 3px solid rgba(90,99,216,.32); }
|
|
130
|
+
.selection-cues { position: fixed; inset: 0; pointer-events: none; z-index: 2147483645; }
|
|
131
|
+
.selection-cue { position: fixed; border-radius: 2px; background: rgba(245,196,0,.2); }
|
|
132
|
+
@media (prefers-reduced-motion: reduce) { * { transition: none !important; } }
|
|
101
133
|
</style>
|
|
102
134
|
<div class="box outline" id="outline"></div>
|
|
103
135
|
<div class="box active" id="activeBox"></div>
|
|
104
136
|
<div class="chips" id="chips">
|
|
105
|
-
<button class="chip danger" id="chipDelete" title="Delete this block" aria-label="Delete this block"
|
|
137
|
+
<button class="chip danger" id="chipDelete" title="Delete this block" aria-label="Delete this block">${iconMarkup("trash", { size: 14 })}</button>
|
|
106
138
|
</div>
|
|
107
139
|
<div class="grip" id="grip" title="Drag to resize"></div>
|
|
108
140
|
<div class="hint" id="hint"></div>
|
|
109
141
|
<div class="linkbox" id="linkbox">
|
|
110
142
|
<input id="linkInput" type="text" placeholder="Link to…" spellcheck="false">
|
|
111
|
-
<button class="chip" id="linkApply" title="Apply link
|
|
112
|
-
<button class="chip danger" id="linkRemove" title="Remove link" aria-label="Remove link"
|
|
143
|
+
<button class="chip" id="linkApply" title="Apply link" aria-label="Apply link">${iconMarkup("check", { size: 14 })}</button>
|
|
144
|
+
<button class="chip danger" id="linkRemove" title="Remove link" aria-label="Remove link">${iconMarkup("x", { size: 14 })}</button>
|
|
113
145
|
</div>
|
|
114
146
|
<div class="mover" id="mover" title="Drag to move this block">⠿</div>
|
|
115
147
|
<div class="dropline" id="dropline"></div>
|
|
148
|
+
<div class="selection-cues" id="selectionCues"></div>
|
|
149
|
+
<button class="comment-action" id="commentAction" title="Comment on this target" aria-label="Comment on this target">${iconMarkup("messageSquarePlus", { size: 17 })}</button>
|
|
116
150
|
`;
|
|
117
151
|
|
|
118
152
|
const els = {};
|
|
@@ -130,6 +164,8 @@ const mountOverlay = () => {
|
|
|
130
164
|
els.linkRemove = shadow.getElementById("linkRemove");
|
|
131
165
|
els.mover = shadow.getElementById("mover");
|
|
132
166
|
els.dropline = shadow.getElementById("dropline");
|
|
167
|
+
els.selectionCues = shadow.getElementById("selectionCues");
|
|
168
|
+
els.commentAction = shadow.getElementById("commentAction");
|
|
133
169
|
};
|
|
134
170
|
|
|
135
171
|
function showMover(el) {
|
|
@@ -220,6 +256,335 @@ function showHint(text, x, y) {
|
|
|
220
256
|
els.hint.style.top = `${y + 16}px`;
|
|
221
257
|
}
|
|
222
258
|
|
|
259
|
+
const rectData = (rect) => ({
|
|
260
|
+
left: rect.left,
|
|
261
|
+
top: rect.top,
|
|
262
|
+
right: rect.right,
|
|
263
|
+
bottom: rect.bottom,
|
|
264
|
+
width: rect.width,
|
|
265
|
+
height: rect.height,
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
const viewportData = () => ({ width: window.innerWidth, height: window.innerHeight });
|
|
269
|
+
|
|
270
|
+
function intersectRects(one, two) {
|
|
271
|
+
const left = Math.max(one.left, two.left);
|
|
272
|
+
const top = Math.max(one.top, two.top);
|
|
273
|
+
const right = Math.min(one.right, two.right);
|
|
274
|
+
const bottom = Math.min(one.bottom, two.bottom);
|
|
275
|
+
if (right <= left || bottom <= top) return null;
|
|
276
|
+
return { left, top, right, bottom, width: right - left, height: bottom - top };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function targetRects(target = pending) {
|
|
280
|
+
if (!target) return [];
|
|
281
|
+
if (target.kind === "selection" && target.range) {
|
|
282
|
+
return [...target.range.getClientRects()].map(rectData);
|
|
283
|
+
}
|
|
284
|
+
if (target.element?.isConnected) return [rectData(target.element.getBoundingClientRect())];
|
|
285
|
+
return [];
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function targetElement(target) {
|
|
289
|
+
if (!target) return null;
|
|
290
|
+
if (target.kind === "element") return target.element || null;
|
|
291
|
+
const node = target.range?.commonAncestorContainer;
|
|
292
|
+
return node?.nodeType === 1 ? node : node?.parentElement || null;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function targetConnected(target) {
|
|
296
|
+
if (!target) return false;
|
|
297
|
+
if (target.kind === "element") return !!target.element?.isConnected;
|
|
298
|
+
const range = target.range;
|
|
299
|
+
return !!(
|
|
300
|
+
range &&
|
|
301
|
+
document.body.contains(range.startContainer) &&
|
|
302
|
+
document.body.contains(range.endContainer)
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function clippingStartNode(target) {
|
|
307
|
+
const element = targetElement(target);
|
|
308
|
+
return target?.kind === "selection" ? element : element?.parentElement;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function effectiveClipRect(target) {
|
|
312
|
+
let clip = {
|
|
313
|
+
left: 0,
|
|
314
|
+
top: 0,
|
|
315
|
+
right: window.innerWidth,
|
|
316
|
+
bottom: window.innerHeight,
|
|
317
|
+
width: window.innerWidth,
|
|
318
|
+
height: window.innerHeight,
|
|
319
|
+
};
|
|
320
|
+
let node = clippingStartNode(target);
|
|
321
|
+
while (node && node !== document.body && node !== document.documentElement) {
|
|
322
|
+
const style = getComputedStyle(node);
|
|
323
|
+
const clipsX = /(auto|scroll|hidden|clip)/.test(`${style.overflow} ${style.overflowX}`);
|
|
324
|
+
const clipsY = /(auto|scroll|hidden|clip)/.test(`${style.overflow} ${style.overflowY}`);
|
|
325
|
+
if (clipsX || clipsY) {
|
|
326
|
+
const rect = node.getBoundingClientRect();
|
|
327
|
+
const ancestorClip = {
|
|
328
|
+
left: clipsX ? rect.left + node.clientLeft : clip.left,
|
|
329
|
+
right: clipsX ? rect.left + node.clientLeft + node.clientWidth : clip.right,
|
|
330
|
+
top: clipsY ? rect.top + node.clientTop : clip.top,
|
|
331
|
+
bottom: clipsY ? rect.top + node.clientTop + node.clientHeight : clip.bottom,
|
|
332
|
+
};
|
|
333
|
+
ancestorClip.width = ancestorClip.right - ancestorClip.left;
|
|
334
|
+
ancestorClip.height = ancestorClip.bottom - ancestorClip.top;
|
|
335
|
+
clip = intersectRects(clip, ancestorClip);
|
|
336
|
+
if (!clip) return null;
|
|
337
|
+
}
|
|
338
|
+
node = node.parentElement;
|
|
339
|
+
}
|
|
340
|
+
return clip;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function targetGeometry(target = pending) {
|
|
344
|
+
const clip = effectiveClipRect(target);
|
|
345
|
+
const rects = targetRects(target).filter((rect) =>
|
|
346
|
+
["left", "top", "right", "bottom", "width", "height"].every((key) => Number.isFinite(rect[key])) &&
|
|
347
|
+
rect.width >= 0 &&
|
|
348
|
+
rect.height >= 0
|
|
349
|
+
);
|
|
350
|
+
if (!targetConnected(target) || !clip || !rects.length) {
|
|
351
|
+
return { relation: "unavailable", clip: clip || {
|
|
352
|
+
left: 0, top: 0, right: window.innerWidth, bottom: window.innerHeight,
|
|
353
|
+
width: window.innerWidth, height: window.innerHeight,
|
|
354
|
+
}, rects: [], horizontal: target?.horizontal ?? null, rejected: targetConnected(target) ? "invalid" : "disconnected" };
|
|
355
|
+
}
|
|
356
|
+
if (rects.some((rect) => rect.left < clip.left - 1 || rect.right > clip.right + 1)) {
|
|
357
|
+
return { relation: "unavailable", clip, rects: [], horizontal: target?.horizontal ?? null, rejected: "horizontally-clipped" };
|
|
358
|
+
}
|
|
359
|
+
if (rects.every((rect) => rect.bottom <= clip.top)) {
|
|
360
|
+
return { relation: "above", clip, rects: [], horizontal: target?.horizontal ?? null };
|
|
361
|
+
}
|
|
362
|
+
if (rects.every((rect) => rect.top >= clip.bottom)) {
|
|
363
|
+
return { relation: "below", clip, rects: [], horizontal: target?.horizontal ?? null };
|
|
364
|
+
}
|
|
365
|
+
const visible = rects.map((rect) => intersectRects(rect, clip)).filter(Boolean);
|
|
366
|
+
if (!visible.length) {
|
|
367
|
+
return { relation: "unavailable", clip, rects: [], horizontal: target?.horizontal ?? null, rejected: "unavailable" };
|
|
368
|
+
}
|
|
369
|
+
const attached = visible[visible.length - 1];
|
|
370
|
+
target.horizontal = attached.right;
|
|
371
|
+
return { relation: "visible", clip, rects: visible, horizontal: target.horizontal };
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function rectVisibleThroughAncestors(rect, target) {
|
|
375
|
+
const viewport = viewportData();
|
|
376
|
+
if (
|
|
377
|
+
!Number.isFinite(rect.left) ||
|
|
378
|
+
!Number.isFinite(rect.top) ||
|
|
379
|
+
rect.right <= 0 ||
|
|
380
|
+
rect.bottom <= 0 ||
|
|
381
|
+
rect.left >= viewport.width ||
|
|
382
|
+
rect.top >= viewport.height
|
|
383
|
+
) return false;
|
|
384
|
+
let node = clippingStartNode(target);
|
|
385
|
+
while (node && node !== document.body) {
|
|
386
|
+
const style = getComputedStyle(node);
|
|
387
|
+
if (/(auto|scroll|hidden|clip)/.test(`${style.overflow} ${style.overflowX} ${style.overflowY}`)) {
|
|
388
|
+
const clip = node.getBoundingClientRect();
|
|
389
|
+
if (rect.right <= clip.left || rect.left >= clip.right || rect.bottom <= clip.top || rect.top >= clip.bottom) {
|
|
390
|
+
return false;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
node = node.parentElement;
|
|
394
|
+
}
|
|
395
|
+
return true;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function visibleRects(rects, target = pending) {
|
|
399
|
+
return rects.filter((rect) => rectVisibleThroughAncestors(rect, target));
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function renderSelectionCues(rects) {
|
|
403
|
+
els.selectionCues.textContent = "";
|
|
404
|
+
if (!composeOpen || !pending || pending.kind !== "selection") return;
|
|
405
|
+
for (const rect of visibleRects(rects, pending)) {
|
|
406
|
+
const cue = document.createElement("span");
|
|
407
|
+
cue.className = "selection-cue";
|
|
408
|
+
cue.style.left = `${rect.left}px`;
|
|
409
|
+
cue.style.top = `${rect.top}px`;
|
|
410
|
+
cue.style.width = `${rect.width}px`;
|
|
411
|
+
cue.style.height = `${rect.height}px`;
|
|
412
|
+
els.selectionCues.append(cue);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function positionCommentAction(rects, target = retarget || pending) {
|
|
417
|
+
const visible = visibleRects(rects, target);
|
|
418
|
+
const rect = visible[visible.length - 1];
|
|
419
|
+
if (!rect || (composeOpen && !retarget)) {
|
|
420
|
+
els.commentAction.style.display = "none";
|
|
421
|
+
return false;
|
|
422
|
+
}
|
|
423
|
+
els.commentAction.style.display = "flex";
|
|
424
|
+
els.commentAction.style.left = `${Math.max(4, Math.min(window.innerWidth - 34, rect.right + 6))}px`;
|
|
425
|
+
els.commentAction.style.top = `${Math.max(4, Math.min(window.innerHeight - 34, rect.top - 4))}px`;
|
|
426
|
+
return true;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function postTarget(type, target = pending) {
|
|
430
|
+
if (!target) return;
|
|
431
|
+
const geometry = targetGeometry(target);
|
|
432
|
+
const message = targetMessage(
|
|
433
|
+
{
|
|
434
|
+
kind: target.kind,
|
|
435
|
+
quote: target.quote,
|
|
436
|
+
anchor: target.anchor,
|
|
437
|
+
rects: geometry.rects,
|
|
438
|
+
generation: target.generation,
|
|
439
|
+
relation: geometry.relation,
|
|
440
|
+
clip: geometry.clip,
|
|
441
|
+
horizontal: geometry.horizontal,
|
|
442
|
+
},
|
|
443
|
+
viewportData()
|
|
444
|
+
);
|
|
445
|
+
if (!message) return;
|
|
446
|
+
const signature = JSON.stringify({
|
|
447
|
+
generation: message.generation,
|
|
448
|
+
relation: message.relation,
|
|
449
|
+
clip: message.clip,
|
|
450
|
+
rects: message.rects,
|
|
451
|
+
horizontal: message.horizontal,
|
|
452
|
+
});
|
|
453
|
+
if (type !== "eh:openComment" && signature === lastTargetGeometrySignature) return;
|
|
454
|
+
if (type !== "eh:openComment") lastTargetGeometrySignature = signature;
|
|
455
|
+
if (lastTargetRelation !== message.relation) {
|
|
456
|
+
diagnostic("anchor-relation-transition", {
|
|
457
|
+
from: lastTargetRelation,
|
|
458
|
+
to: message.relation,
|
|
459
|
+
targetGeneration: message.generation,
|
|
460
|
+
});
|
|
461
|
+
lastTargetRelation = message.relation;
|
|
462
|
+
}
|
|
463
|
+
if (geometry.rejected) {
|
|
464
|
+
diagnostic("geometry-rejected", {
|
|
465
|
+
reason: geometry.rejected,
|
|
466
|
+
targetGeneration: message.generation,
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
post(type, {
|
|
470
|
+
kind: message.kind,
|
|
471
|
+
quote: message.quote,
|
|
472
|
+
anchor: message.anchor,
|
|
473
|
+
rects: message.rects,
|
|
474
|
+
targetGeneration: message.generation,
|
|
475
|
+
relation: message.relation,
|
|
476
|
+
clip: message.clip,
|
|
477
|
+
horizontal: message.horizontal,
|
|
478
|
+
viewport: viewportData(),
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function disconnectTargetObservers() {
|
|
483
|
+
targetObserver?.disconnect();
|
|
484
|
+
targetIntersection?.disconnect();
|
|
485
|
+
targetObserver = null;
|
|
486
|
+
targetIntersection = null;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
const roundedGeometry = (value) => Number.isFinite(value) ? Math.round(value * 2) / 2 : null;
|
|
490
|
+
|
|
491
|
+
function geometryWatchSignature() {
|
|
492
|
+
const target = retarget || pending;
|
|
493
|
+
const targetState = target ? targetGeometry(target) : null;
|
|
494
|
+
let activeState = null;
|
|
495
|
+
if (activeCommentId) {
|
|
496
|
+
const marks = marksFor(activeCommentId);
|
|
497
|
+
const element = marks[0] || document.querySelector(`[data-eh-el="${CSS.escape(activeCommentId)}"]`);
|
|
498
|
+
if (element) {
|
|
499
|
+
const rects = marks.length
|
|
500
|
+
? marks.map((mark) => rectData(mark.getBoundingClientRect()))
|
|
501
|
+
: [rectData(element.getBoundingClientRect())];
|
|
502
|
+
activeState = {
|
|
503
|
+
rects,
|
|
504
|
+
clip: effectiveClipRect({ kind: "element", element }),
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
const normalize = (state) => state && {
|
|
509
|
+
relation: state.relation,
|
|
510
|
+
rects: (state.rects || []).map((rect) => [
|
|
511
|
+
roundedGeometry(rect.left),
|
|
512
|
+
roundedGeometry(rect.top),
|
|
513
|
+
roundedGeometry(rect.right),
|
|
514
|
+
roundedGeometry(rect.bottom),
|
|
515
|
+
]),
|
|
516
|
+
clip: state.clip && [
|
|
517
|
+
roundedGeometry(state.clip.left),
|
|
518
|
+
roundedGeometry(state.clip.top),
|
|
519
|
+
roundedGeometry(state.clip.right),
|
|
520
|
+
roundedGeometry(state.clip.bottom),
|
|
521
|
+
],
|
|
522
|
+
};
|
|
523
|
+
return JSON.stringify({
|
|
524
|
+
target: normalize(targetState),
|
|
525
|
+
active: normalize(activeState),
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function refreshGeometryWatch() {
|
|
530
|
+
const shouldWatch = !!(pending || retarget || activeCommentId);
|
|
531
|
+
if (!shouldWatch) {
|
|
532
|
+
clearInterval(geometryWatchTimer);
|
|
533
|
+
geometryWatchTimer = null;
|
|
534
|
+
watchedGeometrySignature = "";
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
if (geometryWatchTimer) return;
|
|
538
|
+
watchedGeometrySignature = geometryWatchSignature();
|
|
539
|
+
geometryWatchTimer = setInterval(() => {
|
|
540
|
+
if (!(pending || retarget || activeCommentId)) {
|
|
541
|
+
refreshGeometryWatch();
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
const next = geometryWatchSignature();
|
|
545
|
+
if (next === watchedGeometrySignature) return;
|
|
546
|
+
watchedGeometrySignature = next;
|
|
547
|
+
scheduleTargetGeometry();
|
|
548
|
+
if (activeCommentId) activate(activeCommentId, false);
|
|
549
|
+
}, 100);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function observePendingTarget(target = pending) {
|
|
553
|
+
disconnectTargetObservers();
|
|
554
|
+
if (!target) return;
|
|
555
|
+
const observed = target.kind === "element"
|
|
556
|
+
? target.element
|
|
557
|
+
: target.range?.commonAncestorContainer?.nodeType === 1
|
|
558
|
+
? target.range.commonAncestorContainer
|
|
559
|
+
: target.range?.commonAncestorContainer?.parentElement;
|
|
560
|
+
if (!observed) return;
|
|
561
|
+
if ("ResizeObserver" in window) {
|
|
562
|
+
targetObserver = new ResizeObserver(scheduleTargetGeometry);
|
|
563
|
+
targetObserver.observe(observed);
|
|
564
|
+
}
|
|
565
|
+
if ("IntersectionObserver" in window) {
|
|
566
|
+
targetIntersection = new IntersectionObserver(scheduleTargetGeometry, { threshold: [0, .01, 1] });
|
|
567
|
+
targetIntersection.observe(observed);
|
|
568
|
+
}
|
|
569
|
+
refreshGeometryWatch();
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
let targetGeometryQueued = false;
|
|
573
|
+
function scheduleTargetGeometry() {
|
|
574
|
+
if (targetGeometryQueued) return;
|
|
575
|
+
targetGeometryQueued = true;
|
|
576
|
+
requestAnimationFrame(() => {
|
|
577
|
+
targetGeometryQueued = false;
|
|
578
|
+
if (!pending && !retarget) return;
|
|
579
|
+
const actionTarget = retarget || pending;
|
|
580
|
+
const actionRects = targetRects(actionTarget);
|
|
581
|
+
positionCommentAction(actionRects, actionTarget);
|
|
582
|
+
renderSelectionCues(targetRects(pending));
|
|
583
|
+
if (composeOpen) postTarget("eh:targetGeometry", pending);
|
|
584
|
+
else postTarget("eh:target", pending);
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
|
|
223
588
|
// ------------------------------------------------------------ text plumbing
|
|
224
589
|
|
|
225
590
|
const isOurs = (node) => {
|
|
@@ -389,6 +754,20 @@ function targetFor(node) {
|
|
|
389
754
|
return { el: block, label: pinnedLabels.get(block), authored: false };
|
|
390
755
|
}
|
|
391
756
|
|
|
757
|
+
function commentTargetFor(node) {
|
|
758
|
+
const el = node && node.nodeType === 1 ? node : node && node.parentElement;
|
|
759
|
+
const control = el?.closest?.("a[href], button, summary, input, select, textarea, [role='button'], [role='tab']");
|
|
760
|
+
if (control && !isOurs(control)) {
|
|
761
|
+
const label =
|
|
762
|
+
control.getAttribute("aria-label") ||
|
|
763
|
+
control.getAttribute("title") ||
|
|
764
|
+
clip(control.textContent, 80) ||
|
|
765
|
+
control.tagName.toLowerCase();
|
|
766
|
+
return { el: control, label, authored: false };
|
|
767
|
+
}
|
|
768
|
+
return targetFor(node);
|
|
769
|
+
}
|
|
770
|
+
|
|
392
771
|
/**
|
|
393
772
|
* The nearest rendered block around a node, ignoring authored data-block
|
|
394
773
|
* containers. List conversion and drop targeting need the line the caret is
|
|
@@ -511,7 +890,8 @@ function watchSelfRendering() {
|
|
|
511
890
|
}
|
|
512
891
|
|
|
513
892
|
function emitSave() {
|
|
514
|
-
if (
|
|
893
|
+
if (reviewMode !== "edit") return;
|
|
894
|
+
if (savePolicy === "feedback-only" || dynamic) {
|
|
515
895
|
post("eh:dynamic", {});
|
|
516
896
|
return;
|
|
517
897
|
}
|
|
@@ -525,8 +905,9 @@ function emitSave() {
|
|
|
525
905
|
}
|
|
526
906
|
|
|
527
907
|
function scheduleSave() {
|
|
908
|
+
if (reviewMode !== "edit") return;
|
|
528
909
|
clearTimeout(saveTimer);
|
|
529
|
-
if (!dynamic) post("eh:saving", {});
|
|
910
|
+
if (savePolicy === "writable" && !dynamic) post("eh:saving", {});
|
|
530
911
|
saveTimer = setTimeout(emitSave, SAVE_DEBOUNCE_MS);
|
|
531
912
|
}
|
|
532
913
|
|
|
@@ -554,6 +935,7 @@ function flushEdits() {
|
|
|
554
935
|
}
|
|
555
936
|
|
|
556
937
|
function queueEdit(payload) {
|
|
938
|
+
if (reviewMode !== "edit") return;
|
|
557
939
|
const key = `${payload.label}\u0000${payload.kind}`;
|
|
558
940
|
const queued = editQueue.get(key);
|
|
559
941
|
if (queued && (queued.staged_assets || payload.staged_assets)) {
|
|
@@ -567,16 +949,49 @@ function queueEdit(payload) {
|
|
|
567
949
|
|
|
568
950
|
// ------------------------------------------------------------- interactions
|
|
569
951
|
|
|
570
|
-
function clearPending() {
|
|
952
|
+
function clearPending({ keepRetarget = false } = {}) {
|
|
953
|
+
commentOpenRequestGeneration = null;
|
|
571
954
|
pending = null;
|
|
955
|
+
if (!keepRetarget) retarget = null;
|
|
956
|
+
lastTargetGeometrySignature = "";
|
|
957
|
+
lastTargetRelation = null;
|
|
958
|
+
disconnectTargetObservers();
|
|
572
959
|
place(els.activeBox, null);
|
|
960
|
+
els.commentAction.style.display = "none";
|
|
961
|
+
els.selectionCues.textContent = "";
|
|
962
|
+
refreshGeometryWatch();
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
function restoreTargetFocus(target) {
|
|
966
|
+
if (!target) return;
|
|
967
|
+
if (target.kind === "element" && target.element?.isConnected) {
|
|
968
|
+
const element = target.element;
|
|
969
|
+
element.focus({ preventScroll: true });
|
|
970
|
+
requestAnimationFrame(() => {
|
|
971
|
+
if (element.isConnected && document.activeElement !== element) {
|
|
972
|
+
element.focus({ preventScroll: true });
|
|
973
|
+
}
|
|
974
|
+
});
|
|
975
|
+
return;
|
|
976
|
+
}
|
|
977
|
+
const range = target.range;
|
|
978
|
+
if (!range || !document.body.contains(range.commonAncestorContainer)) return;
|
|
979
|
+
const focusTarget = targetElement(target);
|
|
980
|
+
if (focusTarget?.matches?.("a[href], button, input, select, textarea, summary, [tabindex]")) {
|
|
981
|
+
focusTarget.focus({ preventScroll: true });
|
|
982
|
+
} else if (reviewMode === "edit") {
|
|
983
|
+
document.body.focus({ preventScroll: true });
|
|
984
|
+
} else {
|
|
985
|
+
window.focus();
|
|
986
|
+
}
|
|
987
|
+
const selection = document.getSelection();
|
|
988
|
+
selection.removeAllRanges();
|
|
989
|
+
selection.addRange(range);
|
|
573
990
|
}
|
|
574
991
|
|
|
575
992
|
/**
|
|
576
|
-
*
|
|
577
|
-
*
|
|
578
|
-
* until the comment is actually committed, so typing or Backspace behaves
|
|
579
|
-
* exactly as it would in any editable page.
|
|
993
|
+
* Remember a settled selection without opening composition. The explicit
|
|
994
|
+
* contextual action or keyboard shortcut owns that transition.
|
|
580
995
|
*/
|
|
581
996
|
function settleSelection() {
|
|
582
997
|
const sel = document.getSelection();
|
|
@@ -592,30 +1007,151 @@ function settleSelection() {
|
|
|
592
1007
|
if (!offsets) return false;
|
|
593
1008
|
|
|
594
1009
|
const context = buildContext(text, offsets.start, offsets.end);
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
post("eh:compose", {
|
|
1010
|
+
targetGeneration += 1;
|
|
1011
|
+
const target = {
|
|
598
1012
|
kind: "selection",
|
|
599
1013
|
quote,
|
|
600
1014
|
anchor: { ...context, selector: cssPath(range.commonAncestorContainer.parentElement || document.body) },
|
|
601
|
-
|
|
1015
|
+
context,
|
|
1016
|
+
range: range.cloneRange(),
|
|
1017
|
+
generation: targetGeneration,
|
|
1018
|
+
};
|
|
1019
|
+
if (composeOpen && pending) retarget = target;
|
|
1020
|
+
else {
|
|
1021
|
+
pending = target;
|
|
1022
|
+
retarget = null;
|
|
1023
|
+
}
|
|
1024
|
+
observePendingTarget(target);
|
|
1025
|
+
scheduleTargetGeometry();
|
|
602
1026
|
return true;
|
|
603
1027
|
}
|
|
604
1028
|
|
|
605
|
-
function
|
|
606
|
-
if (
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
1029
|
+
function setElementTarget(container) {
|
|
1030
|
+
if (
|
|
1031
|
+
!container?.el ||
|
|
1032
|
+
(pending && pending.kind === "element" && pending.element === container.el) ||
|
|
1033
|
+
(retarget && retarget.kind === "element" && retarget.element === container.el)
|
|
1034
|
+
) {
|
|
1035
|
+
if (pending || retarget) scheduleTargetGeometry();
|
|
1036
|
+
return;
|
|
1037
|
+
}
|
|
1038
|
+
targetGeneration += 1;
|
|
1039
|
+
const next = {
|
|
610
1040
|
kind: "element",
|
|
1041
|
+
element: container.el,
|
|
611
1042
|
quote: container.label,
|
|
612
1043
|
anchor: { selector: cssPath(container.el), label: container.label },
|
|
1044
|
+
generation: targetGeneration,
|
|
1045
|
+
};
|
|
1046
|
+
if (composeOpen && pending) retarget = next;
|
|
1047
|
+
else {
|
|
1048
|
+
pending = next;
|
|
1049
|
+
retarget = null;
|
|
1050
|
+
}
|
|
1051
|
+
observePendingTarget(next);
|
|
1052
|
+
scheduleTargetGeometry();
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
function openPendingCompose() {
|
|
1056
|
+
if (!pending && !retarget) {
|
|
1057
|
+
if (!settleSelection()) return false;
|
|
1058
|
+
}
|
|
1059
|
+
const target = retarget || pending;
|
|
1060
|
+
commentOpenRequestGeneration = target.generation;
|
|
1061
|
+
postTarget("eh:openComment", target);
|
|
1062
|
+
return true;
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
function acceptCommentOpen(msg) {
|
|
1066
|
+
const requested = Number(msg.requestedGeneration);
|
|
1067
|
+
if (commentOpenRequestGeneration === requested) commentOpenRequestGeneration = null;
|
|
1068
|
+
if (!msg.accepted) {
|
|
1069
|
+
const authoritative = Number(msg.targetGeneration);
|
|
1070
|
+
if (pending?.generation === authoritative) {
|
|
1071
|
+
composeOpen = true;
|
|
1072
|
+
} else if (retarget?.generation === authoritative) {
|
|
1073
|
+
pending = retarget;
|
|
1074
|
+
retarget = null;
|
|
1075
|
+
composeOpen = true;
|
|
1076
|
+
observePendingTarget(pending);
|
|
1077
|
+
scheduleTargetGeometry();
|
|
1078
|
+
} else {
|
|
1079
|
+
composeOpen = false;
|
|
1080
|
+
}
|
|
1081
|
+
diagnostic("composer-open-rejected", { targetGeneration: requested });
|
|
1082
|
+
return;
|
|
1083
|
+
}
|
|
1084
|
+
if (Number(msg.targetGeneration) !== requested) {
|
|
1085
|
+
diagnostic("geometry-rejected", { reason: "stale", targetGeneration: requested });
|
|
1086
|
+
return;
|
|
1087
|
+
}
|
|
1088
|
+
const acceptedGeneration = acceptedOpenGeneration(
|
|
1089
|
+
{
|
|
1090
|
+
pendingGeneration: pending?.generation || null,
|
|
1091
|
+
retargetGeneration: retarget?.generation || null,
|
|
1092
|
+
},
|
|
1093
|
+
{ accepted: true, requestedGeneration: requested }
|
|
1094
|
+
);
|
|
1095
|
+
const candidate = retarget?.generation === acceptedGeneration
|
|
1096
|
+
? retarget
|
|
1097
|
+
: pending?.generation === acceptedGeneration
|
|
1098
|
+
? pending
|
|
1099
|
+
: null;
|
|
1100
|
+
if (!candidate) {
|
|
1101
|
+
diagnostic("geometry-rejected", { reason: "stale", targetGeneration: requested });
|
|
1102
|
+
return;
|
|
1103
|
+
}
|
|
1104
|
+
pending = candidate;
|
|
1105
|
+
retarget = null;
|
|
1106
|
+
composeOpen = true;
|
|
1107
|
+
lastTargetGeometrySignature = "";
|
|
1108
|
+
observePendingTarget(pending);
|
|
1109
|
+
positionCommentAction([]);
|
|
1110
|
+
renderSelectionCues(targetRects(pending));
|
|
1111
|
+
scheduleTargetGeometry();
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
function deactivateComment() {
|
|
1115
|
+
activeCommentId = null;
|
|
1116
|
+
for (const mark of document.querySelectorAll(`mark[${MARK_ATTR}]`)) mark.classList.remove("eh-active");
|
|
1117
|
+
place(els.activeBox, null);
|
|
1118
|
+
refreshGeometryWatch();
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
function revealTarget(generation) {
|
|
1122
|
+
diagnostic("reveal-target-requested", { targetGeneration: generation });
|
|
1123
|
+
if (!composeOpen || !pending || pending.generation !== generation || !targetConnected(pending)) {
|
|
1124
|
+
diagnostic("reveal-target-failed", { targetGeneration: generation });
|
|
1125
|
+
post("eh:revealTargetResult", { targetGeneration: generation, success: false });
|
|
1126
|
+
return;
|
|
1127
|
+
}
|
|
1128
|
+
const target = pending.kind === "element"
|
|
1129
|
+
? pending.element
|
|
1130
|
+
: pending.range.startContainer.nodeType === Node.ELEMENT_NODE
|
|
1131
|
+
? pending.range.startContainer
|
|
1132
|
+
: pending.range.startContainer.parentElement;
|
|
1133
|
+
if (!target?.isConnected) {
|
|
1134
|
+
diagnostic("reveal-target-failed", { targetGeneration: generation });
|
|
1135
|
+
post("eh:revealTargetResult", { targetGeneration: generation, success: false });
|
|
1136
|
+
return;
|
|
1137
|
+
}
|
|
1138
|
+
target.scrollIntoView({ block: "center", inline: "nearest", behavior: "auto" });
|
|
1139
|
+
requestAnimationFrame(() => {
|
|
1140
|
+
requestAnimationFrame(() => {
|
|
1141
|
+
const authoritative = composeOpen && pending && pending.generation === generation;
|
|
1142
|
+
const revealed = authoritative && targetGeometry(pending).relation === "visible";
|
|
1143
|
+
scheduleTargetGeometry();
|
|
1144
|
+
diagnostic(revealed ? "reveal-target-succeeded" : "reveal-target-failed", {
|
|
1145
|
+
targetGeneration: generation,
|
|
1146
|
+
});
|
|
1147
|
+
post("eh:revealTargetResult", { targetGeneration: generation, success: revealed });
|
|
1148
|
+
});
|
|
613
1149
|
});
|
|
614
1150
|
}
|
|
615
1151
|
|
|
616
1152
|
/** Commit turns the remembered range into real <mark> wrappers. */
|
|
617
|
-
function commitPending(id) {
|
|
618
|
-
if (!pending) return;
|
|
1153
|
+
function commitPending(id, generation, { restoreFocus = false } = {}) {
|
|
1154
|
+
if (!pending || (generation && generation !== pending.generation)) return;
|
|
619
1155
|
if (pending.kind === "element") {
|
|
620
1156
|
if (pending.element && pending.element.isConnected) pending.element.setAttribute("data-eh-el", id);
|
|
621
1157
|
} else {
|
|
@@ -623,12 +1159,32 @@ function commitPending(id) {
|
|
|
623
1159
|
const hit = findQuote(text, pending.context);
|
|
624
1160
|
if (hit) wrapOffsets(map, hit.start, hit.end, id);
|
|
625
1161
|
}
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
1162
|
+
if (restoreFocus) restoreTargetFocus(pending);
|
|
1163
|
+
else {
|
|
1164
|
+
const sel = document.getSelection();
|
|
1165
|
+
if (sel) sel.removeAllRanges();
|
|
1166
|
+
}
|
|
1167
|
+
clearPending({ keepRetarget: true });
|
|
629
1168
|
}
|
|
630
1169
|
|
|
631
1170
|
function reanchor(comments) {
|
|
1171
|
+
const authoritative = new Set((comments || []).map((comment) => String(comment.id)));
|
|
1172
|
+
const staleMarks = new Set();
|
|
1173
|
+
for (const mark of document.querySelectorAll(`mark[${MARK_ATTR}]`)) {
|
|
1174
|
+
const id = mark.getAttribute(MARK_ATTR);
|
|
1175
|
+
if (id && !authoritative.has(id)) staleMarks.add(id);
|
|
1176
|
+
}
|
|
1177
|
+
for (const id of staleMarks) unwrap(id);
|
|
1178
|
+
for (const element of document.querySelectorAll("[data-eh-el]")) {
|
|
1179
|
+
if (!authoritative.has(element.getAttribute("data-eh-el"))) element.removeAttribute("data-eh-el");
|
|
1180
|
+
}
|
|
1181
|
+
if (activeCommentId && !authoritative.has(activeCommentId)) {
|
|
1182
|
+
const staleId = activeCommentId;
|
|
1183
|
+
activeCommentId = null;
|
|
1184
|
+
place(els.activeBox, null);
|
|
1185
|
+
post("eh:commentGeometry", { id: staleId, rects: [], visible: false, viewport: viewportData() });
|
|
1186
|
+
}
|
|
1187
|
+
|
|
632
1188
|
const resolved = [];
|
|
633
1189
|
const orphaned = [];
|
|
634
1190
|
for (const comment of comments) {
|
|
@@ -656,6 +1212,7 @@ function reanchor(comments) {
|
|
|
656
1212
|
}
|
|
657
1213
|
|
|
658
1214
|
function activate(id, scroll) {
|
|
1215
|
+
activeCommentId = id || null;
|
|
659
1216
|
const marks = marksFor(id);
|
|
660
1217
|
let target = marks[0] || null;
|
|
661
1218
|
if (!target) {
|
|
@@ -665,6 +1222,19 @@ function activate(id, scroll) {
|
|
|
665
1222
|
for (const mark of document.querySelectorAll(`mark[${MARK_ATTR}]`)) mark.classList.remove("eh-active");
|
|
666
1223
|
for (const mark of marks) mark.classList.add("eh-active");
|
|
667
1224
|
place(els.activeBox, target);
|
|
1225
|
+
const rects = marks.length
|
|
1226
|
+
? marks.map((mark) => rectData(mark.getBoundingClientRect()))
|
|
1227
|
+
: target
|
|
1228
|
+
? [rectData(target.getBoundingClientRect())]
|
|
1229
|
+
: [];
|
|
1230
|
+
const visible = visibleRects(rects, { kind: "element", element: target });
|
|
1231
|
+
post("eh:commentGeometry", {
|
|
1232
|
+
id,
|
|
1233
|
+
rects: visible,
|
|
1234
|
+
visible: visible.length > 0,
|
|
1235
|
+
viewport: viewportData(),
|
|
1236
|
+
});
|
|
1237
|
+
refreshGeometryWatch();
|
|
668
1238
|
if (target && scroll) {
|
|
669
1239
|
// Reveal a target hidden inside a collapsed tab or accordion.
|
|
670
1240
|
let hidden = target.closest("[hidden]") || null;
|
|
@@ -690,6 +1260,7 @@ function activate(id, scroll) {
|
|
|
690
1260
|
// scrollIntoView walks every scrollable ancestor, so targets inside an
|
|
691
1261
|
// app's inner scroll container are reached too, not just window-scrolled ones.
|
|
692
1262
|
target.scrollIntoView({ block: "center", behavior: "smooth" });
|
|
1263
|
+
setTimeout(() => activate(id, false), 180);
|
|
693
1264
|
}
|
|
694
1265
|
}
|
|
695
1266
|
|
|
@@ -709,25 +1280,24 @@ function boot() {
|
|
|
709
1280
|
`;
|
|
710
1281
|
document.head.appendChild(style);
|
|
711
1282
|
|
|
712
|
-
|
|
713
|
-
// server rendered the page. Keep edit mode on after hydration settles so a
|
|
714
|
-
// real app stays directly editable, not just its initial HTML response.
|
|
715
|
-
keepBodyEditable(document.body);
|
|
716
|
-
document.body.spellcheck = false;
|
|
1283
|
+
modeController = keepBodyInReviewMode(document.body, "view");
|
|
717
1284
|
baseline = serialize();
|
|
718
1285
|
bootSnapshot = baseline;
|
|
719
1286
|
watchSelfRendering();
|
|
720
1287
|
|
|
1288
|
+
const notifyChromeInteraction = (event) => {
|
|
1289
|
+
if (isOurs(event.target)) return;
|
|
1290
|
+
post("eh:interaction", { interaction: event.type });
|
|
1291
|
+
};
|
|
1292
|
+
document.addEventListener("pointerdown", notifyChromeInteraction, true);
|
|
1293
|
+
document.addEventListener("focusin", notifyChromeInteraction, true);
|
|
1294
|
+
window.addEventListener("focus", notifyChromeInteraction, true);
|
|
1295
|
+
|
|
721
1296
|
document.addEventListener("mouseup", (event) => {
|
|
722
1297
|
if (isOurs(event.target) || resizing || Date.now() < suppressUntil) return;
|
|
723
1298
|
setTimeout(() => {
|
|
724
1299
|
if (settleSelection()) return;
|
|
725
|
-
|
|
726
|
-
if (target) {
|
|
727
|
-
openElementCompose(target);
|
|
728
|
-
return;
|
|
729
|
-
}
|
|
730
|
-
post("eh:dismiss", {});
|
|
1300
|
+
if (!composeOpen && pending?.kind === "selection") clearPending();
|
|
731
1301
|
}, 0);
|
|
732
1302
|
});
|
|
733
1303
|
|
|
@@ -737,8 +1307,14 @@ function boot() {
|
|
|
737
1307
|
if (isOurs(event.target)) return;
|
|
738
1308
|
const modified = event.metaKey || event.ctrlKey;
|
|
739
1309
|
const href = navigationHref(event.target);
|
|
1310
|
+
const mark = event.target.closest && event.target.closest(`mark[${MARK_ATTR}]`);
|
|
1311
|
+
if (mark) {
|
|
1312
|
+
event.preventDefault();
|
|
1313
|
+
post("eh:activate", { id: mark.getAttribute(MARK_ATTR) });
|
|
1314
|
+
return;
|
|
1315
|
+
}
|
|
740
1316
|
|
|
741
|
-
if (
|
|
1317
|
+
if (reviewMode === "view") {
|
|
742
1318
|
if (href) {
|
|
743
1319
|
event.preventDefault();
|
|
744
1320
|
event.stopPropagation();
|
|
@@ -757,49 +1333,108 @@ function boot() {
|
|
|
757
1333
|
}
|
|
758
1334
|
}
|
|
759
1335
|
else if (classification === "navigate") {
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
1336
|
+
post("eh:navigate", { href });
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
return;
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
if (modified) {
|
|
1343
|
+
if (href) {
|
|
1344
|
+
event.preventDefault();
|
|
1345
|
+
event.stopPropagation();
|
|
1346
|
+
const classification = classifyHref(href);
|
|
1347
|
+
if (classification === "external") {
|
|
1348
|
+
const external = externalHref(href, document.baseURI);
|
|
1349
|
+
if (external) post("eh:external", { href: external });
|
|
1350
|
+
} else if (classification === "hash") {
|
|
1351
|
+
const action = hashClickAction(href, location.hash);
|
|
1352
|
+
if (action.kind === "scroll") {
|
|
1353
|
+
const el = document.getElementById(action.id) || document.getElementsByName(action.id)[0] || document.body;
|
|
1354
|
+
el.scrollIntoView({ behavior: "smooth" });
|
|
1355
|
+
} else {
|
|
1356
|
+
location.hash = action.hash;
|
|
1357
|
+
}
|
|
1358
|
+
} else if (classification === "navigate") {
|
|
763
1359
|
flushSave();
|
|
764
1360
|
post("eh:navigate", { href });
|
|
765
1361
|
}
|
|
766
1362
|
}
|
|
767
|
-
return;
|
|
1363
|
+
return;
|
|
768
1364
|
}
|
|
769
1365
|
|
|
770
|
-
//
|
|
1366
|
+
// Edit keeps activation suppression so direct manipulation cannot navigate.
|
|
771
1367
|
if (href || (event.target.closest && event.target.closest("button, [role='button'], summary"))) {
|
|
772
1368
|
event.preventDefault();
|
|
773
1369
|
event.stopPropagation();
|
|
774
1370
|
}
|
|
775
|
-
const mark = event.target.closest && event.target.closest(`mark[${MARK_ATTR}]`);
|
|
776
|
-
if (mark) post("eh:activate", { id: mark.getAttribute(MARK_ATTR) });
|
|
777
1371
|
},
|
|
778
1372
|
true
|
|
779
1373
|
);
|
|
780
1374
|
|
|
781
1375
|
document.addEventListener("submit", (event) => {
|
|
782
|
-
if (
|
|
783
|
-
|
|
1376
|
+
if (event.defaultPrevented) return;
|
|
1377
|
+
if (reviewMode === "edit") {
|
|
1378
|
+
if (!event.metaKey && !event.ctrlKey) event.preventDefault();
|
|
1379
|
+
return;
|
|
1380
|
+
}
|
|
1381
|
+
const form = event.target;
|
|
1382
|
+
if (!(form instanceof HTMLFormElement)) return;
|
|
1383
|
+
const submitter = event.submitter;
|
|
1384
|
+
const method = String(submitter?.formMethod || form.method || "get").toLowerCase();
|
|
1385
|
+
if (method === "dialog") return;
|
|
1386
|
+
event.preventDefault();
|
|
1387
|
+
if (method !== "get") {
|
|
1388
|
+
post("eh:formBlocked", { reason: "Only GET form navigation is available in View mode." });
|
|
1389
|
+
return;
|
|
1390
|
+
}
|
|
1391
|
+
const action =
|
|
1392
|
+
submitter?.getAttribute("formaction") ??
|
|
1393
|
+
form.getAttribute("action") ??
|
|
1394
|
+
"";
|
|
1395
|
+
if (!action) {
|
|
1396
|
+
post("eh:formBlocked", { reason: "This form does not name a reviewable destination." });
|
|
1397
|
+
return;
|
|
1398
|
+
}
|
|
1399
|
+
const query = new URLSearchParams();
|
|
1400
|
+
for (const [name, value] of new FormData(form)) {
|
|
1401
|
+
if (typeof value === "string") query.append(name, value);
|
|
1402
|
+
}
|
|
1403
|
+
if (submitter?.name) query.append(submitter.name, submitter.value);
|
|
1404
|
+
const hashIndex = action.indexOf("#");
|
|
1405
|
+
const hash = hashIndex >= 0 ? action.slice(hashIndex) : "";
|
|
1406
|
+
const base = hashIndex >= 0 ? action.slice(0, hashIndex) : action;
|
|
1407
|
+
const separator = base.includes("?") ? "&" : "?";
|
|
1408
|
+
const href = query.size ? `${base}${separator}${query}${hash}` : action;
|
|
1409
|
+
post("eh:navigate", { href });
|
|
1410
|
+
});
|
|
784
1411
|
|
|
785
1412
|
document.addEventListener("mouseover", (event) => {
|
|
786
1413
|
if (isOurs(event.target) || resizing || moving) return;
|
|
787
|
-
const target =
|
|
1414
|
+
const target = commentTargetFor(event.target);
|
|
788
1415
|
hoverTarget = target ? target.el : null;
|
|
789
1416
|
// The move handle works per element, not per labeled container, so each
|
|
790
1417
|
// paragraph inside a card can travel on its own.
|
|
791
1418
|
hoverMove = hoverTarget ? innermostBlock(event.target) || hoverTarget : null;
|
|
792
1419
|
hoverMedia = event.target.closest ? event.target.closest("img, video") : null;
|
|
793
1420
|
place(els.outline, hoverTarget);
|
|
794
|
-
|
|
1421
|
+
const selectionActive = selectionIsActive();
|
|
1422
|
+
if (!selectionActive && target && !composeOpen) setElementTarget(target);
|
|
1423
|
+
if (reviewMode !== "edit" || controlsAreSuppressed()) hideActionControls();
|
|
795
1424
|
else {
|
|
796
1425
|
showChip(hoverTarget);
|
|
797
1426
|
showMover(hoverMove);
|
|
798
1427
|
}
|
|
799
|
-
showGrip(hoverMedia);
|
|
1428
|
+
showGrip(reviewMode === "edit" ? hoverMedia : null);
|
|
800
1429
|
const interactive = event.target.closest && event.target.closest("a[href], [data-href], button, [role='button']");
|
|
801
1430
|
const draggable = hoverMedia && hoverMedia.tagName === "IMG";
|
|
802
|
-
showHint(interactive ? "⌘-click to open" : draggable ? "Drag to move" : "", event.clientX, event.clientY);
|
|
1431
|
+
showHint(reviewMode === "edit" && interactive ? "⌘-click to open" : reviewMode === "edit" && draggable ? "Drag to move" : "", event.clientX, event.clientY);
|
|
1432
|
+
});
|
|
1433
|
+
|
|
1434
|
+
document.addEventListener("focusin", (event) => {
|
|
1435
|
+
if (isOurs(event.target) || selectionIsActive() || composeOpen) return;
|
|
1436
|
+
const target = commentTargetFor(event.target);
|
|
1437
|
+
if (target) setElementTarget(target);
|
|
803
1438
|
});
|
|
804
1439
|
|
|
805
1440
|
document.addEventListener("mouseleave", () => {
|
|
@@ -812,11 +1447,29 @@ function boot() {
|
|
|
812
1447
|
showMover(null);
|
|
813
1448
|
showGrip(null);
|
|
814
1449
|
showHint("");
|
|
1450
|
+
if (
|
|
1451
|
+
!composeOpen &&
|
|
1452
|
+
!commentOpenRequestGeneration &&
|
|
1453
|
+
pending?.kind === "element" &&
|
|
1454
|
+
!pending.element?.contains(document.activeElement)
|
|
1455
|
+
) clearPending();
|
|
1456
|
+
});
|
|
1457
|
+
|
|
1458
|
+
els.commentAction.addEventListener("pointerdown", (event) => {
|
|
1459
|
+
event.preventDefault();
|
|
1460
|
+
event.stopPropagation();
|
|
1461
|
+
});
|
|
1462
|
+
|
|
1463
|
+
els.commentAction.addEventListener("click", (event) => {
|
|
1464
|
+
event.preventDefault();
|
|
1465
|
+
event.stopPropagation();
|
|
1466
|
+
openPendingCompose();
|
|
815
1467
|
});
|
|
816
1468
|
|
|
817
1469
|
// ------------------------------------------------------------ image resize
|
|
818
1470
|
|
|
819
1471
|
els.grip.addEventListener("pointerdown", (event) => {
|
|
1472
|
+
if (reviewMode !== "edit") return;
|
|
820
1473
|
if (!hoverMedia || !hoverMedia.isConnected) return;
|
|
821
1474
|
event.preventDefault();
|
|
822
1475
|
event.stopPropagation();
|
|
@@ -865,7 +1518,7 @@ function boot() {
|
|
|
865
1518
|
els.chipDelete.addEventListener("click", (event) => {
|
|
866
1519
|
event.preventDefault();
|
|
867
1520
|
event.stopPropagation();
|
|
868
|
-
if (!hoverTarget) return;
|
|
1521
|
+
if (reviewMode !== "edit" || !hoverTarget) return;
|
|
869
1522
|
userEdited = true;
|
|
870
1523
|
const target = targetFor(hoverTarget);
|
|
871
1524
|
const label = target ? target.label : "Element";
|
|
@@ -934,7 +1587,7 @@ function boot() {
|
|
|
934
1587
|
document.addEventListener(
|
|
935
1588
|
"beforeinput",
|
|
936
1589
|
(event) => {
|
|
937
|
-
if (isOurs(event.target)) return;
|
|
1590
|
+
if (isOurs(event.target) || reviewMode !== "edit") return;
|
|
938
1591
|
suppressActionControlsWhileTyping();
|
|
939
1592
|
// From here on, DOM drift is the human typing, not the page rendering.
|
|
940
1593
|
userEdited = true;
|
|
@@ -946,8 +1599,18 @@ function boot() {
|
|
|
946
1599
|
);
|
|
947
1600
|
|
|
948
1601
|
document.addEventListener("selectionchange", () => {
|
|
949
|
-
|
|
950
|
-
|
|
1602
|
+
clearTimeout(selectionTimer);
|
|
1603
|
+
if (!composeOpen && selectionIsActive()) clearPending();
|
|
1604
|
+
else els.commentAction.style.display = "none";
|
|
1605
|
+
selectionTimer = setTimeout(() => {
|
|
1606
|
+
if (selectionIsActive()) {
|
|
1607
|
+
hideActionControls();
|
|
1608
|
+
settleSelection();
|
|
1609
|
+
} else {
|
|
1610
|
+
restoreActionControls();
|
|
1611
|
+
if (!composeOpen && pending?.kind === "selection") clearPending();
|
|
1612
|
+
}
|
|
1613
|
+
}, 40);
|
|
951
1614
|
});
|
|
952
1615
|
|
|
953
1616
|
// ------------------------------------------------------------------ links
|
|
@@ -1092,6 +1755,21 @@ function boot() {
|
|
|
1092
1755
|
const sel = document.getSelection();
|
|
1093
1756
|
const anchor = sel ? sel.anchorNode : null;
|
|
1094
1757
|
|
|
1758
|
+
if (
|
|
1759
|
+
event.altKey &&
|
|
1760
|
+
(event.ctrlKey || event.metaKey) &&
|
|
1761
|
+
event.key.toLowerCase() === "m"
|
|
1762
|
+
) {
|
|
1763
|
+
if (selectionIsActive()) settleSelection();
|
|
1764
|
+
if (openPendingCompose()) {
|
|
1765
|
+
event.preventDefault();
|
|
1766
|
+
event.stopPropagation();
|
|
1767
|
+
}
|
|
1768
|
+
return;
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1771
|
+
if (reviewMode !== "edit") return;
|
|
1772
|
+
|
|
1095
1773
|
// ⌘K links the selection, like Docs, Word, and Notion.
|
|
1096
1774
|
if (meta && !event.shiftKey && !event.altKey && (event.key === "k" || event.key === "K")) {
|
|
1097
1775
|
if (openLinkbox()) {
|
|
@@ -1184,6 +1862,7 @@ function boot() {
|
|
|
1184
1862
|
let dragging = null; // { el, fromBlock, fromLabel, overBlock, dropped } during an image drag
|
|
1185
1863
|
|
|
1186
1864
|
document.addEventListener("dragstart", (event) => {
|
|
1865
|
+
if (reviewMode !== "edit") return;
|
|
1187
1866
|
const img = event.target && event.target.nodeType === 1 && event.target.tagName === "IMG" ? event.target : null;
|
|
1188
1867
|
if (!img || isOurs(img)) return;
|
|
1189
1868
|
const target = targetFor(img);
|
|
@@ -1202,6 +1881,7 @@ function boot() {
|
|
|
1202
1881
|
});
|
|
1203
1882
|
|
|
1204
1883
|
document.addEventListener("dragover", (event) => {
|
|
1884
|
+
if (reviewMode !== "edit") return;
|
|
1205
1885
|
if (!dragging) {
|
|
1206
1886
|
// External payloads would navigate the frame or paste file:// markup.
|
|
1207
1887
|
if (event.dataTransfer && [...(event.dataTransfer.types || [])].includes("Files")) {
|
|
@@ -1245,6 +1925,7 @@ function boot() {
|
|
|
1245
1925
|
};
|
|
1246
1926
|
|
|
1247
1927
|
document.addEventListener("drop", (event) => {
|
|
1928
|
+
if (reviewMode !== "edit") return;
|
|
1248
1929
|
if (dragging) {
|
|
1249
1930
|
// The move itself is the drop's default action, so the edit rows can
|
|
1250
1931
|
// only be read after it has run.
|
|
@@ -1281,6 +1962,7 @@ function boot() {
|
|
|
1281
1962
|
};
|
|
1282
1963
|
|
|
1283
1964
|
els.mover.addEventListener("pointerdown", (event) => {
|
|
1965
|
+
if (reviewMode !== "edit") return;
|
|
1284
1966
|
const el = hoverMove && hoverMove.isConnected ? hoverMove : hoverTarget;
|
|
1285
1967
|
if (!el || !el.isConnected) return;
|
|
1286
1968
|
event.preventDefault();
|
|
@@ -1345,7 +2027,7 @@ function boot() {
|
|
|
1345
2027
|
document.addEventListener(
|
|
1346
2028
|
"paste",
|
|
1347
2029
|
(event) => {
|
|
1348
|
-
if (isOurs(event.target)) return;
|
|
2030
|
+
if (isOurs(event.target) || reviewMode !== "edit") return;
|
|
1349
2031
|
const items = event.clipboardData ? [...event.clipboardData.items] : [];
|
|
1350
2032
|
const images = items.filter((item) => item.kind === "file" && /^image\//.test(item.type));
|
|
1351
2033
|
if (!images.length) return;
|
|
@@ -1389,7 +2071,7 @@ function boot() {
|
|
|
1389
2071
|
};
|
|
1390
2072
|
|
|
1391
2073
|
document.addEventListener("input", (event) => {
|
|
1392
|
-
if (isOurs(event.target)) return;
|
|
2074
|
+
if (isOurs(event.target) || reviewMode !== "edit") return;
|
|
1393
2075
|
// A drop fires deleteByDrag/insertFromDrop input events whose selection
|
|
1394
2076
|
// points anywhere; finalizeImageDrag writes the real rows instead.
|
|
1395
2077
|
if (dragging) return;
|
|
@@ -1416,9 +2098,16 @@ function boot() {
|
|
|
1416
2098
|
|
|
1417
2099
|
const reposition = () => {
|
|
1418
2100
|
place(els.outline, hoverTarget);
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
2101
|
+
if (reviewMode === "edit" && !controlsAreSuppressed()) {
|
|
2102
|
+
showChip(hoverTarget);
|
|
2103
|
+
showMover(moving ? null : hoverMove);
|
|
2104
|
+
showGrip(resizing ? resizing.el : hoverMedia);
|
|
2105
|
+
} else {
|
|
2106
|
+
hideActionControls();
|
|
2107
|
+
showGrip(null);
|
|
2108
|
+
}
|
|
2109
|
+
scheduleTargetGeometry();
|
|
2110
|
+
if (activeCommentId) activate(activeCommentId, false);
|
|
1422
2111
|
// The popup is viewport-fixed; scrolled away from its text it just lies.
|
|
1423
2112
|
if (linkState) closeLinkbox(false);
|
|
1424
2113
|
};
|
|
@@ -1433,6 +2122,7 @@ function boot() {
|
|
|
1433
2122
|
reposition();
|
|
1434
2123
|
});
|
|
1435
2124
|
};
|
|
2125
|
+
document.addEventListener("scroll", scheduleReposition, true);
|
|
1436
2126
|
window.addEventListener("scroll", scheduleReposition, true);
|
|
1437
2127
|
window.addEventListener("resize", scheduleReposition);
|
|
1438
2128
|
|
|
@@ -1461,15 +2151,39 @@ function boot() {
|
|
|
1461
2151
|
reanchor(msg.comments || []);
|
|
1462
2152
|
break;
|
|
1463
2153
|
case "eh:commit":
|
|
1464
|
-
|
|
2154
|
+
if (msg.targetGeneration && pending && msg.targetGeneration !== pending.generation) break;
|
|
2155
|
+
commitPending(msg.id, msg.targetGeneration, { restoreFocus: !!msg.restoreFocus });
|
|
1465
2156
|
composeOpen = false;
|
|
1466
2157
|
break;
|
|
1467
2158
|
case "eh:cancel":
|
|
1468
|
-
|
|
2159
|
+
if (msg.targetGeneration && pending && msg.targetGeneration !== pending.generation) break;
|
|
2160
|
+
{
|
|
2161
|
+
const discardThrough = Number(msg.discardThroughGeneration) || Number(msg.targetGeneration) || 0;
|
|
2162
|
+
const newerRetarget = retarget && retarget.generation > discardThrough ? retarget : null;
|
|
2163
|
+
if (msg.restoreFocus && !newerRetarget) restoreTargetFocus(pending);
|
|
2164
|
+
clearPending({ keepRetarget: !!newerRetarget || !!msg.preserveRetarget });
|
|
2165
|
+
if (newerRetarget) {
|
|
2166
|
+
pending = newerRetarget;
|
|
2167
|
+
retarget = null;
|
|
2168
|
+
}
|
|
2169
|
+
}
|
|
1469
2170
|
composeOpen = false;
|
|
2171
|
+
if (pending) {
|
|
2172
|
+
observePendingTarget(pending);
|
|
2173
|
+
scheduleTargetGeometry();
|
|
2174
|
+
}
|
|
2175
|
+
break;
|
|
2176
|
+
case "eh:commentOpenResult":
|
|
2177
|
+
acceptCommentOpen(msg);
|
|
2178
|
+
break;
|
|
2179
|
+
case "eh:deactivateComment":
|
|
2180
|
+
deactivateComment();
|
|
1470
2181
|
break;
|
|
1471
|
-
case "eh:
|
|
1472
|
-
|
|
2182
|
+
case "eh:revealTarget":
|
|
2183
|
+
revealTarget(msg.targetGeneration);
|
|
2184
|
+
break;
|
|
2185
|
+
case "eh:modeMenuState":
|
|
2186
|
+
modeMenuOpen = !!msg.open;
|
|
1473
2187
|
break;
|
|
1474
2188
|
case "eh:remove":
|
|
1475
2189
|
unwrap(msg.id);
|
|
@@ -1492,13 +2206,22 @@ function boot() {
|
|
|
1492
2206
|
editQueue.clear();
|
|
1493
2207
|
break;
|
|
1494
2208
|
case "eh:raw":
|
|
1495
|
-
checkDynamic(String(msg.html || ""));
|
|
2209
|
+
if (savePolicy === "writable") checkDynamic(String(msg.html || ""));
|
|
1496
2210
|
break;
|
|
1497
|
-
case "eh:
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
2211
|
+
case "eh:configureReview": {
|
|
2212
|
+
reviewMode = msg.mode === "edit" ? "edit" : "view";
|
|
2213
|
+
savePolicy = msg.savePolicy === "feedback-only" ? "feedback-only" : "writable";
|
|
2214
|
+
modeController.setMode(reviewMode);
|
|
2215
|
+
if (reviewMode === "view") {
|
|
2216
|
+
showChip(null);
|
|
2217
|
+
showMover(null);
|
|
2218
|
+
showGrip(null);
|
|
2219
|
+
els.linkbox.style.display = "none";
|
|
2220
|
+
}
|
|
2221
|
+
scheduleTargetGeometry();
|
|
2222
|
+
post("eh:configurationApplied", { mode: reviewMode, savePolicy });
|
|
1501
2223
|
break;
|
|
2224
|
+
}
|
|
1502
2225
|
case "eh:restoreScroll":
|
|
1503
2226
|
window.scrollTo(msg.x || 0, msg.y || 0);
|
|
1504
2227
|
break;
|