@erdemtuna/doc-review 0.8.1 → 0.9.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 +112 -22
- package/package.json +4 -2
- package/src/SKILL.md +28 -4
- package/src/chrome-client.js +844 -103
- package/src/chrome.css +253 -8
- package/src/chrome.html +78 -11
- package/src/cli.js +4 -0
- package/src/comment-target.js +87 -0
- package/src/comparison-view.js +337 -0
- package/src/document-execution.js +103 -0
- package/src/document-trust.js +2 -0
- package/src/execution-client.js +63 -0
- package/src/frame-policy.js +38 -0
- package/src/history-client.js +104 -0
- package/src/history-coordinator.js +186 -0
- package/src/history-policy.js +43 -0
- package/src/history-server.js +463 -0
- package/src/paths.js +1 -1
- package/src/review-mode.js +1 -1
- package/src/revision-diff.js +440 -0
- package/src/revision-schema.js +219 -0
- package/src/revision-store.js +177 -0
- package/src/sdk.js +419 -55
- package/src/semantic-snapshot.js +230 -0
- package/src/server.js +217 -49
- package/src/state.js +240 -15
- package/src/view-identity.js +99 -0
package/src/sdk.js
CHANGED
|
@@ -7,17 +7,21 @@
|
|
|
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
|
+
import { acceptedOpenGeneration, createHoverIntent, groupCommentTargets, nextCommentId, normalizeSelectionRange, pointInCommentApproach, sameRange, targetMessage } from "./comment-target.js";
|
|
11
11
|
import { classifyHref, externalHref, linkStyleFixup, listCommandFor, listStyleFixup, normalizeHref } from "./editing.js";
|
|
12
12
|
import { frameMessage, initializeChannelFromDocument, matchesFrameMessage } from "./frame-channel.js";
|
|
13
13
|
import { iconMarkup } from "./icons.js";
|
|
14
14
|
import { keepBodyInReviewMode } from "./review-mode.js";
|
|
15
|
+
import { captureSemanticSnapshot } from "./semantic-snapshot.js";
|
|
15
16
|
import { serializeDocument, UI_ATTR, MARK_ATTR } from "./serialize.js";
|
|
17
|
+
import { observeView, sameObservedView } from "./view-identity.js";
|
|
16
18
|
|
|
17
19
|
initializeChannelFromDocument();
|
|
18
20
|
|
|
19
21
|
const SAVE_DEBOUNCE_MS = 700;
|
|
20
22
|
const EDIT_FLUSH_MS = 500;
|
|
23
|
+
const CAPTURE_READY_TIMEOUT_MS = 2500;
|
|
24
|
+
const CAPTURE_STABLE_MS = 300;
|
|
21
25
|
const MEDIA = /^(img|svg|canvas|video|picture|iframe|hr|figure)$/i;
|
|
22
26
|
|
|
23
27
|
// The chrome page lives on the other loopback hostname (a separate origin, so
|
|
@@ -50,6 +54,15 @@ let lastTargetRelation = null;
|
|
|
50
54
|
let geometryWatchTimer = null;
|
|
51
55
|
let watchedGeometrySignature = "";
|
|
52
56
|
let selectionTimer = null;
|
|
57
|
+
let hoverIntent = null;
|
|
58
|
+
let pointerSelecting = false;
|
|
59
|
+
let composing = false;
|
|
60
|
+
let disposed = false;
|
|
61
|
+
let viewObserver = null;
|
|
62
|
+
let viewChangeTimer = null;
|
|
63
|
+
let lastObservedView = null;
|
|
64
|
+
const blockTargets = new Map();
|
|
65
|
+
const blockMarkers = new Map();
|
|
53
66
|
/** True when the page's own scripts rewrote the DOM before any user edit. */
|
|
54
67
|
let dynamic = false;
|
|
55
68
|
|
|
@@ -66,8 +79,21 @@ shadow.innerHTML = `
|
|
|
66
79
|
<style>
|
|
67
80
|
:host { all: initial; }
|
|
68
81
|
.box { position: fixed; pointer-events: none; z-index: 2147483646; border-radius: 3px; display: none; }
|
|
69
|
-
.outline { border: 1px
|
|
70
|
-
|
|
82
|
+
.outline { border: 1px dashed #96938c; animation: outline-in 90ms ease-out; }
|
|
83
|
+
@keyframes outline-in { from { opacity: .2; } to { opacity: 1; } }
|
|
84
|
+
.active { border: 2px solid #b46a00; box-shadow: 0 0 0 1px #fff9; }
|
|
85
|
+
.block-marker { position: fixed; pointer-events: none; z-index: 2147483644;
|
|
86
|
+
box-sizing: border-box; border-left: 3px solid #a96100; background: #d58b0012; }
|
|
87
|
+
.block-marker[data-active="true"] { border-left-width: 6px; background: #d58b0026; }
|
|
88
|
+
.block-marker[data-dark="true"] { border-left-color: #ffcb66; background: #ffcb6617; }
|
|
89
|
+
.block-marker[data-dark="true"][data-active="true"] { background: #ffcb6630; }
|
|
90
|
+
.block-badge { position: fixed; z-index: 2147483647; pointer-events: auto;
|
|
91
|
+
height: 24px; min-width: 32px; box-sizing: border-box; padding: 2px 6px;
|
|
92
|
+
border: 1px solid #965900; border-radius: 5px; background: #fff3d3; color: #663b00;
|
|
93
|
+
font: 600 12px/18px system-ui, sans-serif; cursor: pointer; }
|
|
94
|
+
.block-badge[data-dark="true"] { background: #493517; color: #ffe0a0; border-color: #ffcb66; }
|
|
95
|
+
.block-badge[aria-pressed="true"] { border-width: 2px; text-decoration: underline; }
|
|
96
|
+
.block-badge:focus-visible { outline: 3px solid #718cff; outline-offset: 2px; }
|
|
71
97
|
.chips {
|
|
72
98
|
position: fixed; z-index: 2147483647; display: none; gap: 4px;
|
|
73
99
|
pointer-events: auto;
|
|
@@ -129,7 +155,7 @@ shadow.innerHTML = `
|
|
|
129
155
|
.comment-action:hover, .comment-action:focus-visible { background: #34312b; outline: 3px solid rgba(90,99,216,.32); }
|
|
130
156
|
.selection-cues { position: fixed; inset: 0; pointer-events: none; z-index: 2147483645; }
|
|
131
157
|
.selection-cue { position: fixed; border-radius: 2px; background: rgba(245,196,0,.2); }
|
|
132
|
-
@media (prefers-reduced-motion: reduce) { * { transition: none !important; } }
|
|
158
|
+
@media (prefers-reduced-motion: reduce) { * { transition: none !important; animation: none !important; } }
|
|
133
159
|
</style>
|
|
134
160
|
<div class="box outline" id="outline"></div>
|
|
135
161
|
<div class="box active" id="activeBox"></div>
|
|
@@ -146,6 +172,7 @@ shadow.innerHTML = `
|
|
|
146
172
|
<div class="mover" id="mover" title="Drag to move this block">⠿</div>
|
|
147
173
|
<div class="dropline" id="dropline"></div>
|
|
148
174
|
<div class="selection-cues" id="selectionCues"></div>
|
|
175
|
+
<div id="blockAnnotations" role="group" aria-label="Saved block comments"></div>
|
|
149
176
|
<button class="comment-action" id="commentAction" title="Comment on this target" aria-label="Comment on this target">${iconMarkup("messageSquarePlus", { size: 17 })}</button>
|
|
150
177
|
`;
|
|
151
178
|
|
|
@@ -166,8 +193,96 @@ const mountOverlay = () => {
|
|
|
166
193
|
els.dropline = shadow.getElementById("dropline");
|
|
167
194
|
els.selectionCues = shadow.getElementById("selectionCues");
|
|
168
195
|
els.commentAction = shadow.getElementById("commentAction");
|
|
196
|
+
els.blockAnnotations = shadow.getElementById("blockAnnotations");
|
|
169
197
|
};
|
|
170
198
|
|
|
199
|
+
function darkSurface(element) {
|
|
200
|
+
for (let probe = element; probe; probe = probe.parentElement) {
|
|
201
|
+
const color = getComputedStyle(probe).backgroundColor.match(/[\d.]+/g)?.map(Number);
|
|
202
|
+
if (color?.length >= 3 && (color.length < 4 || color[3] >= .5)) {
|
|
203
|
+
return color[0] * .2126 + color[1] * .7152 + color[2] * .0722 < 128;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return matchMedia("(prefers-color-scheme: dark)").matches;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function positionBlockBadge(badge, rect) {
|
|
210
|
+
const width = badge.offsetWidth || 36;
|
|
211
|
+
const height = 24;
|
|
212
|
+
const candidates = [
|
|
213
|
+
[rect.left - width - 5, rect.top], [rect.right + 5, rect.top],
|
|
214
|
+
[rect.right - width, rect.top - height - 5], [rect.right - width, rect.bottom + 5],
|
|
215
|
+
[innerWidth - width - 4, rect.top], [4, rect.top],
|
|
216
|
+
];
|
|
217
|
+
const controls = "a[href], button, input, select, textarea, summary, [role=button], [role=tab], [contenteditable=true]";
|
|
218
|
+
const occupied = [...blockMarkers.values()].filter((entry) => entry.badge !== badge && entry.badge.style.display !== "none")
|
|
219
|
+
.map((entry) => entry.badge.getBoundingClientRect());
|
|
220
|
+
for (const [x, y] of candidates) {
|
|
221
|
+
if (x < 2 || y < 2 || x + width > innerWidth - 2 || y + height > innerHeight - 2) continue;
|
|
222
|
+
if (occupied.some((other) => x < other.right && x + width > other.left && y < other.bottom && y + height > other.top)) continue;
|
|
223
|
+
const blocked = [[x, y], [x + width / 2, y + height / 2], [x + width, y + height], [x, y + height], [x + width, y]]
|
|
224
|
+
.some(([left, top]) => document.elementsFromPoint(left, top).some((element) =>
|
|
225
|
+
!isOurs(element) && element !== document.body && element.closest(controls) !== document.body && element.closest(controls)));
|
|
226
|
+
if (blocked) continue;
|
|
227
|
+
badge.style.left = `${x}px`;
|
|
228
|
+
badge.style.top = `${y}px`;
|
|
229
|
+
badge.style.display = "block";
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
// Never cover an authored control; the drawer remains an accessible fallback.
|
|
233
|
+
badge.style.display = "none";
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function renderBlockAnnotations() {
|
|
237
|
+
if (!els.blockAnnotations || disposed) return;
|
|
238
|
+
const groups = groupCommentTargets(blockTargets);
|
|
239
|
+
for (const [element, entry] of blockMarkers) {
|
|
240
|
+
if (groups.has(element)) continue;
|
|
241
|
+
entry.marker.remove();
|
|
242
|
+
entry.badge.remove();
|
|
243
|
+
blockMarkers.delete(element);
|
|
244
|
+
}
|
|
245
|
+
for (const [element, ids] of groups) {
|
|
246
|
+
let entry = blockMarkers.get(element);
|
|
247
|
+
if (!entry) {
|
|
248
|
+
const marker = document.createElement("div");
|
|
249
|
+
marker.className = "block-marker";
|
|
250
|
+
marker.setAttribute("aria-hidden", "true");
|
|
251
|
+
const badge = document.createElement("button");
|
|
252
|
+
badge.className = "block-badge";
|
|
253
|
+
badge.type = "button";
|
|
254
|
+
badge.addEventListener("pointerdown", (event) => event.stopPropagation());
|
|
255
|
+
badge.addEventListener("click", (event) => {
|
|
256
|
+
event.stopPropagation();
|
|
257
|
+
hoverIntent?.cancel();
|
|
258
|
+
const currentIds = groupCommentTargets(blockTargets).get(element) || [];
|
|
259
|
+
const id = nextCommentId(currentIds, activeCommentId);
|
|
260
|
+
if (id) post("eh:activate", { id });
|
|
261
|
+
});
|
|
262
|
+
els.blockAnnotations.append(marker, badge);
|
|
263
|
+
entry = { marker, badge };
|
|
264
|
+
blockMarkers.set(element, entry);
|
|
265
|
+
}
|
|
266
|
+
const { marker, badge } = entry;
|
|
267
|
+
const active = ids.includes(activeCommentId);
|
|
268
|
+
const dark = darkSurface(element);
|
|
269
|
+
marker.dataset.active = String(active);
|
|
270
|
+
marker.dataset.dark = badge.dataset.dark = String(dark);
|
|
271
|
+
badge.setAttribute("aria-pressed", String(active));
|
|
272
|
+
const label = commentTargetFor(element)?.label || element.tagName.toLowerCase();
|
|
273
|
+
const name = `${ids.length} block comment${ids.length === 1 ? "" : "s"} on ${label}${ids.length > 1 ? "; activate to open next comment" : ""}`;
|
|
274
|
+
badge.setAttribute("aria-label", name);
|
|
275
|
+
badge.title = name;
|
|
276
|
+
badge.textContent = `◧ ${ids.length}`;
|
|
277
|
+
const rect = visibleRects([rectData(element.getBoundingClientRect())], { kind: "element", element })[0];
|
|
278
|
+
marker.style.display = badge.style.display = "none";
|
|
279
|
+
if (!rect) continue;
|
|
280
|
+
Object.assign(marker.style, { display: "block", left: `${rect.left}px`, top: `${rect.top}px`, width: `${rect.width}px`, height: `${rect.height}px` });
|
|
281
|
+
positionBlockBadge(badge, rect);
|
|
282
|
+
}
|
|
283
|
+
refreshGeometryWatch();
|
|
284
|
+
}
|
|
285
|
+
|
|
171
286
|
function showMover(el) {
|
|
172
287
|
if (!el || !el.isConnected) {
|
|
173
288
|
els.mover.style.display = "none";
|
|
@@ -494,7 +609,7 @@ function geometryWatchSignature() {
|
|
|
494
609
|
let activeState = null;
|
|
495
610
|
if (activeCommentId) {
|
|
496
611
|
const marks = marksFor(activeCommentId);
|
|
497
|
-
const element = marks[0] ||
|
|
612
|
+
const element = marks[0] || blockTargets.get(activeCommentId);
|
|
498
613
|
if (element) {
|
|
499
614
|
const rects = marks.length
|
|
500
615
|
? marks.map((mark) => rectData(mark.getBoundingClientRect()))
|
|
@@ -523,11 +638,16 @@ function geometryWatchSignature() {
|
|
|
523
638
|
return JSON.stringify({
|
|
524
639
|
target: normalize(targetState),
|
|
525
640
|
active: normalize(activeState),
|
|
641
|
+
blocks: [...groupCommentTargets(blockTargets).keys()].map((element) => {
|
|
642
|
+
const rect = element.getBoundingClientRect();
|
|
643
|
+
const clip = effectiveClipRect({ kind: "element", element });
|
|
644
|
+
return [rect.left, rect.top, rect.width, rect.height, clip, darkSurface(element)];
|
|
645
|
+
}),
|
|
526
646
|
});
|
|
527
647
|
}
|
|
528
648
|
|
|
529
649
|
function refreshGeometryWatch() {
|
|
530
|
-
const shouldWatch = !!(pending || retarget || activeCommentId);
|
|
650
|
+
const shouldWatch = !disposed && !!(pending || retarget || activeCommentId || blockTargets.size);
|
|
531
651
|
if (!shouldWatch) {
|
|
532
652
|
clearInterval(geometryWatchTimer);
|
|
533
653
|
geometryWatchTimer = null;
|
|
@@ -537,7 +657,7 @@ function refreshGeometryWatch() {
|
|
|
537
657
|
if (geometryWatchTimer) return;
|
|
538
658
|
watchedGeometrySignature = geometryWatchSignature();
|
|
539
659
|
geometryWatchTimer = setInterval(() => {
|
|
540
|
-
if (!(pending || retarget || activeCommentId)) {
|
|
660
|
+
if (disposed || !(pending || retarget || activeCommentId || blockTargets.size)) {
|
|
541
661
|
refreshGeometryWatch();
|
|
542
662
|
return;
|
|
543
663
|
}
|
|
@@ -545,6 +665,7 @@ function refreshGeometryWatch() {
|
|
|
545
665
|
if (next === watchedGeometrySignature) return;
|
|
546
666
|
watchedGeometrySignature = next;
|
|
547
667
|
scheduleTargetGeometry();
|
|
668
|
+
renderBlockAnnotations();
|
|
548
669
|
if (activeCommentId) activate(activeCommentId, false);
|
|
549
670
|
}, 100);
|
|
550
671
|
}
|
|
@@ -616,17 +737,6 @@ function flatten() {
|
|
|
616
737
|
return { text, map };
|
|
617
738
|
}
|
|
618
739
|
|
|
619
|
-
function offsetsFromRange(map, range) {
|
|
620
|
-
let start = null;
|
|
621
|
-
let end = null;
|
|
622
|
-
for (const entry of map) {
|
|
623
|
-
if (entry.node === range.startContainer) start = entry.start + range.startOffset;
|
|
624
|
-
if (entry.node === range.endContainer) end = entry.start + range.endOffset;
|
|
625
|
-
}
|
|
626
|
-
if (start === null || end === null || end <= start) return null;
|
|
627
|
-
return { start, end };
|
|
628
|
-
}
|
|
629
|
-
|
|
630
740
|
/** Wrap a global offset span in <mark> elements, one per text node touched. */
|
|
631
741
|
function wrapOffsets(map, start, end, id) {
|
|
632
742
|
const marks = [];
|
|
@@ -917,6 +1027,129 @@ function flushSave() {
|
|
|
917
1027
|
emitSave();
|
|
918
1028
|
}
|
|
919
1029
|
|
|
1030
|
+
let activeCaptureRequest = null;
|
|
1031
|
+
|
|
1032
|
+
function watchObservedView() {
|
|
1033
|
+
if (!lastObservedView) lastObservedView = observeView(document);
|
|
1034
|
+
const scheduleViewCheck = () => {
|
|
1035
|
+
clearTimeout(viewChangeTimer);
|
|
1036
|
+
viewChangeTimer = setTimeout(() => {
|
|
1037
|
+
viewChangeTimer = null;
|
|
1038
|
+
if (disposed) return;
|
|
1039
|
+
const view = observeView(document);
|
|
1040
|
+
if (sameObservedView(lastObservedView, view)) return;
|
|
1041
|
+
lastObservedView = view;
|
|
1042
|
+
hoverIntent?.reset();
|
|
1043
|
+
post("eh:viewChanged", { view });
|
|
1044
|
+
}, 200);
|
|
1045
|
+
};
|
|
1046
|
+
viewObserver?.disconnect();
|
|
1047
|
+
viewObserver = new MutationObserver(scheduleViewCheck);
|
|
1048
|
+
viewObserver.observe(document.documentElement, {
|
|
1049
|
+
subtree: true,
|
|
1050
|
+
childList: true,
|
|
1051
|
+
attributes: true,
|
|
1052
|
+
attributeFilter: ["aria-selected", "hidden", "id", "aria-controls", "role", "aria-hidden"],
|
|
1053
|
+
});
|
|
1054
|
+
scheduleViewCheck();
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
async function captureSnapshot(msg) {
|
|
1058
|
+
const requestId = typeof msg.requestId === "string" && msg.requestId.length <= 128 ? msg.requestId : null;
|
|
1059
|
+
const fail = (code, message) => post("eh:snapshot", { requestId, error: { code, message } });
|
|
1060
|
+
if (!requestId?.trim() || (msg.requireStable !== undefined && typeof msg.requireStable !== "boolean")) {
|
|
1061
|
+
fail("CAPTURE_INVALID_REQUEST", "A valid capture request ID and stability option are required.");
|
|
1062
|
+
return;
|
|
1063
|
+
}
|
|
1064
|
+
if (activeCaptureRequest) {
|
|
1065
|
+
if (activeCaptureRequest !== requestId) fail("CAPTURE_BUSY", "Another capture is in progress. Retry when it finishes.");
|
|
1066
|
+
return;
|
|
1067
|
+
}
|
|
1068
|
+
activeCaptureRequest = requestId;
|
|
1069
|
+
const started = Date.now();
|
|
1070
|
+
const href = location.href;
|
|
1071
|
+
let signature = null;
|
|
1072
|
+
let unchangedSince = started;
|
|
1073
|
+
let documentReady = false;
|
|
1074
|
+
try {
|
|
1075
|
+
flushSave();
|
|
1076
|
+
for (;;) {
|
|
1077
|
+
if (disposed || location.href !== href) {
|
|
1078
|
+
fail("CAPTURE_NAVIGATED", "The page navigated during capture. Recapture the current page.");
|
|
1079
|
+
return;
|
|
1080
|
+
}
|
|
1081
|
+
documentReady = document.readyState !== "loading" && !!document.body;
|
|
1082
|
+
if (documentReady) {
|
|
1083
|
+
const beforeView = observeView(document);
|
|
1084
|
+
const snapshot = captureSemanticSnapshot(document);
|
|
1085
|
+
const view = observeView(document);
|
|
1086
|
+
if (!sameObservedView(beforeView, view)) {
|
|
1087
|
+
fail("VIEW_CHANGED", "The visible tab changed during capture. Retry after the view settles.");
|
|
1088
|
+
return;
|
|
1089
|
+
}
|
|
1090
|
+
const capturedAt = Date.now();
|
|
1091
|
+
const next = JSON.stringify({ snapshot, view });
|
|
1092
|
+
const now = Date.now();
|
|
1093
|
+
if (next !== signature) {
|
|
1094
|
+
signature = next;
|
|
1095
|
+
unchangedSince = now;
|
|
1096
|
+
}
|
|
1097
|
+
if (msg.requireStable === false || now - unchangedSince >= CAPTURE_STABLE_MS) {
|
|
1098
|
+
// This orders queued edit messages before the snapshot; chrome still
|
|
1099
|
+
// owns the durable edit/autosave acknowledgement.
|
|
1100
|
+
flushSave();
|
|
1101
|
+
post("eh:snapshot", { requestId, snapshot, capturedAt, view });
|
|
1102
|
+
return;
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
if (Date.now() - started >= CAPTURE_READY_TIMEOUT_MS) {
|
|
1106
|
+
fail(
|
|
1107
|
+
documentReady ? "CAPTURE_UNSTABLE" : "CAPTURE_NOT_READY",
|
|
1108
|
+
documentReady
|
|
1109
|
+
? "The page is still changing. Retry, or explicitly capture its current state."
|
|
1110
|
+
: "The document is not ready. Retry after it has loaded."
|
|
1111
|
+
);
|
|
1112
|
+
return;
|
|
1113
|
+
}
|
|
1114
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
1115
|
+
}
|
|
1116
|
+
} catch (error) {
|
|
1117
|
+
const semanticError = typeof error?.code === "string" && error.code.startsWith("SEMANTIC_CAPTURE_");
|
|
1118
|
+
fail(
|
|
1119
|
+
semanticError ? error.code : "CAPTURE_FAILED",
|
|
1120
|
+
semanticError ? String(error.message).slice(0, 500) : "The page could not be captured. Retry from the current page."
|
|
1121
|
+
);
|
|
1122
|
+
} finally {
|
|
1123
|
+
activeCaptureRequest = null;
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
function jumpToHistoryTarget(msg) {
|
|
1128
|
+
let success = false;
|
|
1129
|
+
try {
|
|
1130
|
+
if (typeof msg.selector === "string" && msg.selector.length > 0 && msg.selector.length <= 4096 &&
|
|
1131
|
+
typeof msg.text === "string" && msg.text.trim() && msg.text.length <= 200000) {
|
|
1132
|
+
const matches = document.querySelectorAll(msg.selector);
|
|
1133
|
+
if (matches.length === 1 && document.body.contains(matches[0]) && !isOurs(matches[0])) {
|
|
1134
|
+
const snapshot = captureSemanticSnapshot(document);
|
|
1135
|
+
const exact = snapshot.blocks.some((block) => {
|
|
1136
|
+
if (block.text !== msg.text) return false;
|
|
1137
|
+
if (typeof block.selector !== "string") return false;
|
|
1138
|
+
const current = document.querySelectorAll(block.selector);
|
|
1139
|
+
return current.length === 1 && current[0] === matches[0];
|
|
1140
|
+
});
|
|
1141
|
+
if (exact) {
|
|
1142
|
+
matches[0].scrollIntoView({ block: "center", inline: "nearest", behavior: "instant" });
|
|
1143
|
+
success = true;
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
} catch {
|
|
1148
|
+
// Stale, ambiguous, hidden, and invalid selectors remain unresolved.
|
|
1149
|
+
}
|
|
1150
|
+
post("eh:historyJumpResult", { success, ...(!success ? { reason: "unresolved" } : {}) });
|
|
1151
|
+
}
|
|
1152
|
+
|
|
920
1153
|
// ------------------------------------------------------------- edit tracking
|
|
921
1154
|
|
|
922
1155
|
/**
|
|
@@ -950,6 +1183,7 @@ function queueEdit(payload) {
|
|
|
950
1183
|
// ------------------------------------------------------------- interactions
|
|
951
1184
|
|
|
952
1185
|
function clearPending({ keepRetarget = false } = {}) {
|
|
1186
|
+
hoverIntent?.reset();
|
|
953
1187
|
commentOpenRequestGeneration = null;
|
|
954
1188
|
pending = null;
|
|
955
1189
|
if (!keepRetarget) retarget = null;
|
|
@@ -1003,17 +1237,32 @@ function settleSelection() {
|
|
|
1003
1237
|
if (!quote.trim()) return false;
|
|
1004
1238
|
|
|
1005
1239
|
const { text, map } = flatten();
|
|
1006
|
-
const offsets =
|
|
1240
|
+
const offsets = normalizeSelectionRange(map, range);
|
|
1007
1241
|
if (!offsets) return false;
|
|
1008
1242
|
|
|
1009
1243
|
const context = buildContext(text, offsets.start, offsets.end);
|
|
1244
|
+
if (!context.quote.trim()) return false;
|
|
1245
|
+
const existing = [retarget, pending].find((target) =>
|
|
1246
|
+
target?.kind === "selection" && sameRange(target.range, offsets.range) &&
|
|
1247
|
+
target.context.quote === context.quote &&
|
|
1248
|
+
target.context.prefix === context.prefix &&
|
|
1249
|
+
target.context.suffix === context.suffix
|
|
1250
|
+
);
|
|
1251
|
+
if (existing) {
|
|
1252
|
+
if (existing === pending && retarget) {
|
|
1253
|
+
retarget = null;
|
|
1254
|
+
observePendingTarget(pending);
|
|
1255
|
+
}
|
|
1256
|
+
scheduleTargetGeometry();
|
|
1257
|
+
return true;
|
|
1258
|
+
}
|
|
1010
1259
|
targetGeneration += 1;
|
|
1011
1260
|
const target = {
|
|
1012
1261
|
kind: "selection",
|
|
1013
1262
|
quote,
|
|
1014
1263
|
anchor: { ...context, selector: cssPath(range.commonAncestorContainer.parentElement || document.body) },
|
|
1015
1264
|
context,
|
|
1016
|
-
range: range
|
|
1265
|
+
range: offsets.range,
|
|
1017
1266
|
generation: targetGeneration,
|
|
1018
1267
|
};
|
|
1019
1268
|
if (composeOpen && pending) retarget = target;
|
|
@@ -1053,16 +1302,21 @@ function setElementTarget(container) {
|
|
|
1053
1302
|
}
|
|
1054
1303
|
|
|
1055
1304
|
function openPendingCompose() {
|
|
1056
|
-
|
|
1305
|
+
hoverIntent?.cancel();
|
|
1306
|
+
if (composing || disposed) return false;
|
|
1307
|
+
const selection = document.getSelection();
|
|
1308
|
+
if (selection && !selection.isCollapsed && selection.rangeCount) {
|
|
1057
1309
|
if (!settleSelection()) return false;
|
|
1058
|
-
}
|
|
1310
|
+
} else if (!pending && !retarget) return false;
|
|
1059
1311
|
const target = retarget || pending;
|
|
1312
|
+
clearTimeout(selectionTimer);
|
|
1060
1313
|
commentOpenRequestGeneration = target.generation;
|
|
1061
1314
|
postTarget("eh:openComment", target);
|
|
1062
1315
|
return true;
|
|
1063
1316
|
}
|
|
1064
1317
|
|
|
1065
1318
|
function acceptCommentOpen(msg) {
|
|
1319
|
+
hoverIntent?.cancel();
|
|
1066
1320
|
const requested = Number(msg.requestedGeneration);
|
|
1067
1321
|
if (commentOpenRequestGeneration === requested) commentOpenRequestGeneration = null;
|
|
1068
1322
|
if (!msg.accepted) {
|
|
@@ -1115,6 +1369,7 @@ function deactivateComment() {
|
|
|
1115
1369
|
activeCommentId = null;
|
|
1116
1370
|
for (const mark of document.querySelectorAll(`mark[${MARK_ATTR}]`)) mark.classList.remove("eh-active");
|
|
1117
1371
|
place(els.activeBox, null);
|
|
1372
|
+
renderBlockAnnotations();
|
|
1118
1373
|
refreshGeometryWatch();
|
|
1119
1374
|
}
|
|
1120
1375
|
|
|
@@ -1153,7 +1408,7 @@ function revealTarget(generation) {
|
|
|
1153
1408
|
function commitPending(id, generation, { restoreFocus = false } = {}) {
|
|
1154
1409
|
if (!pending || (generation && generation !== pending.generation)) return;
|
|
1155
1410
|
if (pending.kind === "element") {
|
|
1156
|
-
if (pending.element
|
|
1411
|
+
if (pending.element?.isConnected) blockTargets.set(id, pending.element);
|
|
1157
1412
|
} else {
|
|
1158
1413
|
const { text, map } = flatten();
|
|
1159
1414
|
const hit = findQuote(text, pending.context);
|
|
@@ -1165,6 +1420,7 @@ function commitPending(id, generation, { restoreFocus = false } = {}) {
|
|
|
1165
1420
|
if (sel) sel.removeAllRanges();
|
|
1166
1421
|
}
|
|
1167
1422
|
clearPending({ keepRetarget: true });
|
|
1423
|
+
renderBlockAnnotations();
|
|
1168
1424
|
}
|
|
1169
1425
|
|
|
1170
1426
|
function reanchor(comments) {
|
|
@@ -1176,8 +1432,11 @@ function reanchor(comments) {
|
|
|
1176
1432
|
}
|
|
1177
1433
|
for (const id of staleMarks) unwrap(id);
|
|
1178
1434
|
for (const element of document.querySelectorAll("[data-eh-el]")) {
|
|
1179
|
-
|
|
1435
|
+
const id = element.getAttribute("data-eh-el");
|
|
1436
|
+
if (authoritative.has(id) && !blockTargets.has(id)) blockTargets.set(id, element);
|
|
1437
|
+
element.removeAttribute("data-eh-el");
|
|
1180
1438
|
}
|
|
1439
|
+
for (const id of blockTargets.keys()) if (!authoritative.has(id)) blockTargets.delete(id);
|
|
1181
1440
|
if (activeCommentId && !authoritative.has(activeCommentId)) {
|
|
1182
1441
|
const staleId = activeCommentId;
|
|
1183
1442
|
activeCommentId = null;
|
|
@@ -1193,9 +1452,13 @@ function reanchor(comments) {
|
|
|
1193
1452
|
continue;
|
|
1194
1453
|
}
|
|
1195
1454
|
if (comment.kind === "element") {
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1455
|
+
let el = blockTargets.get(comment.id);
|
|
1456
|
+
if (!el?.isConnected) {
|
|
1457
|
+
try { el = comment.anchor?.selector ? document.querySelector(comment.anchor.selector) : null; }
|
|
1458
|
+
catch { el = null; }
|
|
1459
|
+
}
|
|
1460
|
+
if (el && !isOurs(el)) blockTargets.set(comment.id, el);
|
|
1461
|
+
else { el = null; blockTargets.delete(comment.id); }
|
|
1199
1462
|
(el ? resolved : orphaned).push(comment.id);
|
|
1200
1463
|
continue;
|
|
1201
1464
|
}
|
|
@@ -1209,6 +1472,7 @@ function reanchor(comments) {
|
|
|
1209
1472
|
(marks.length ? resolved : orphaned).push(comment.id);
|
|
1210
1473
|
}
|
|
1211
1474
|
post("eh:anchorStatus", { resolved, orphaned });
|
|
1475
|
+
renderBlockAnnotations();
|
|
1212
1476
|
}
|
|
1213
1477
|
|
|
1214
1478
|
function activate(id, scroll) {
|
|
@@ -1216,12 +1480,14 @@ function activate(id, scroll) {
|
|
|
1216
1480
|
const marks = marksFor(id);
|
|
1217
1481
|
let target = marks[0] || null;
|
|
1218
1482
|
if (!target) {
|
|
1219
|
-
const el =
|
|
1483
|
+
const el = blockTargets.get(id);
|
|
1220
1484
|
if (el) target = el;
|
|
1221
1485
|
}
|
|
1222
1486
|
for (const mark of document.querySelectorAll(`mark[${MARK_ATTR}]`)) mark.classList.remove("eh-active");
|
|
1223
1487
|
for (const mark of marks) mark.classList.add("eh-active");
|
|
1224
1488
|
place(els.activeBox, target);
|
|
1489
|
+
els.activeBox.style.borderColor = target && darkSurface(target) ? "#ffcb66" : "#a96100";
|
|
1490
|
+
renderBlockAnnotations();
|
|
1225
1491
|
const rects = marks.length
|
|
1226
1492
|
? marks.map((mark) => rectData(mark.getBoundingClientRect()))
|
|
1227
1493
|
: target
|
|
@@ -1259,8 +1525,8 @@ function activate(id, scroll) {
|
|
|
1259
1525
|
}
|
|
1260
1526
|
// scrollIntoView walks every scrollable ancestor, so targets inside an
|
|
1261
1527
|
// app's inner scroll container are reached too, not just window-scrolled ones.
|
|
1262
|
-
target.scrollIntoView({ block: "center", behavior: "smooth" });
|
|
1263
|
-
setTimeout(() => activate(id, false), 180);
|
|
1528
|
+
target.scrollIntoView({ block: "center", behavior: matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth" });
|
|
1529
|
+
setTimeout(() => { if (!disposed && activeCommentId === id) activate(id, false); }, 180);
|
|
1264
1530
|
}
|
|
1265
1531
|
}
|
|
1266
1532
|
|
|
@@ -1284,6 +1550,7 @@ function boot() {
|
|
|
1284
1550
|
baseline = serialize();
|
|
1285
1551
|
bootSnapshot = baseline;
|
|
1286
1552
|
watchSelfRendering();
|
|
1553
|
+
watchObservedView();
|
|
1287
1554
|
|
|
1288
1555
|
const notifyChromeInteraction = (event) => {
|
|
1289
1556
|
if (isOurs(event.target)) return;
|
|
@@ -1297,7 +1564,14 @@ function boot() {
|
|
|
1297
1564
|
if (isOurs(event.target) || resizing || Date.now() < suppressUntil) return;
|
|
1298
1565
|
setTimeout(() => {
|
|
1299
1566
|
if (settleSelection()) return;
|
|
1300
|
-
if (!composeOpen &&
|
|
1567
|
+
if (!composeOpen && !commentOpenRequestGeneration) {
|
|
1568
|
+
if (pending?.kind === "selection") clearPending();
|
|
1569
|
+
if (!selectionIsActive()) {
|
|
1570
|
+
const target = event.target?.isConnected && commentTargetFor(event.target);
|
|
1571
|
+
if (target) setElementTarget(target);
|
|
1572
|
+
else restoreElementCommentTarget();
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1301
1575
|
}, 0);
|
|
1302
1576
|
});
|
|
1303
1577
|
|
|
@@ -1409,8 +1683,27 @@ function boot() {
|
|
|
1409
1683
|
post("eh:navigate", { href });
|
|
1410
1684
|
});
|
|
1411
1685
|
|
|
1412
|
-
|
|
1413
|
-
|
|
1686
|
+
const inCommentApproach = (event) => pending?.kind === "element" &&
|
|
1687
|
+
pending.element.isConnected &&
|
|
1688
|
+
pointInCommentApproach(
|
|
1689
|
+
{ x: event.clientX, y: event.clientY },
|
|
1690
|
+
pending.element.getBoundingClientRect(),
|
|
1691
|
+
els.commentAction.getBoundingClientRect()
|
|
1692
|
+
);
|
|
1693
|
+
const clearHover = () => {
|
|
1694
|
+
hoverTarget = hoverMove = hoverMedia = null;
|
|
1695
|
+
place(els.outline, null);
|
|
1696
|
+
showChip(null);
|
|
1697
|
+
showMover(null);
|
|
1698
|
+
showGrip(null);
|
|
1699
|
+
showHint("");
|
|
1700
|
+
if (!composeOpen && !commentOpenRequestGeneration && pending?.kind === "element" &&
|
|
1701
|
+
!pending.element?.contains(document.activeElement)) clearPending();
|
|
1702
|
+
};
|
|
1703
|
+
hoverIntent = createHoverIntent((event) => {
|
|
1704
|
+
if (disposed || pointerSelecting || composing || composeOpen || selectionIsActive()) return;
|
|
1705
|
+
if (!event) { clearHover(); return; }
|
|
1706
|
+
if (!event.target?.isConnected) { hoverIntent.reset(); clearHover(); return; }
|
|
1414
1707
|
const target = commentTargetFor(event.target);
|
|
1415
1708
|
hoverTarget = target ? target.el : null;
|
|
1416
1709
|
// The move handle works per element, not per labeled container, so each
|
|
@@ -1419,7 +1712,7 @@ function boot() {
|
|
|
1419
1712
|
hoverMedia = event.target.closest ? event.target.closest("img, video") : null;
|
|
1420
1713
|
place(els.outline, hoverTarget);
|
|
1421
1714
|
const selectionActive = selectionIsActive();
|
|
1422
|
-
if (!selectionActive && target && !composeOpen) setElementTarget(target);
|
|
1715
|
+
if (!selectionActive && target && !composeOpen && !commentOpenRequestGeneration) setElementTarget(target);
|
|
1423
1716
|
if (reviewMode !== "edit" || controlsAreSuppressed()) hideActionControls();
|
|
1424
1717
|
else {
|
|
1425
1718
|
showChip(hoverTarget);
|
|
@@ -1430,29 +1723,71 @@ function boot() {
|
|
|
1430
1723
|
const draggable = hoverMedia && hoverMedia.tagName === "IMG";
|
|
1431
1724
|
showHint(reviewMode === "edit" && interactive ? "⌘-click to open" : reviewMode === "edit" && draggable ? "Drag to move" : "", event.clientX, event.clientY);
|
|
1432
1725
|
});
|
|
1726
|
+
const updateHover = (event) => {
|
|
1727
|
+
if (disposed || resizing || moving || pointerSelecting || composing || composeOpen ||
|
|
1728
|
+
commentOpenRequestGeneration || selectionIsActive()) { hoverIntent.cancel(); return; }
|
|
1729
|
+
if (isOurs(event.target)) { hoverIntent.cancel(); return; }
|
|
1730
|
+
// The committed paragraph owns the entire trip to its action, including
|
|
1731
|
+
// intervening whitespace/containers. Leaving the corridor starts new dwell.
|
|
1732
|
+
if (pending?.kind === "element" && !pending.element.contains(event.target) && inCommentApproach(event)) {
|
|
1733
|
+
hoverIntent.cancel();
|
|
1734
|
+
return;
|
|
1735
|
+
}
|
|
1736
|
+
const target = commentTargetFor(event.target);
|
|
1737
|
+
hoverIntent.request(target?.el || null, target ? {
|
|
1738
|
+
target: event.target, clientX: event.clientX, clientY: event.clientY,
|
|
1739
|
+
} : null);
|
|
1740
|
+
};
|
|
1741
|
+
document.addEventListener("mouseover", updateHover);
|
|
1742
|
+
document.addEventListener("mousemove", updateHover);
|
|
1743
|
+
|
|
1744
|
+
document.addEventListener("pointerdown", (event) => {
|
|
1745
|
+
hoverIntent.cancel();
|
|
1746
|
+
if (!isOurs(event.target) && event.button === 0) pointerSelecting = true;
|
|
1747
|
+
}, true);
|
|
1748
|
+
document.addEventListener("pointerup", () => { pointerSelecting = false; }, true);
|
|
1749
|
+
document.addEventListener("pointercancel", () => { pointerSelecting = false; hoverIntent.reset(); }, true);
|
|
1750
|
+
document.addEventListener("compositionstart", () => {
|
|
1751
|
+
composing = true;
|
|
1752
|
+
hoverIntent.reset();
|
|
1753
|
+
place(els.outline, null);
|
|
1754
|
+
}, true);
|
|
1755
|
+
document.addEventListener("compositionend", () => { composing = false; }, true);
|
|
1433
1756
|
|
|
1434
1757
|
document.addEventListener("focusin", (event) => {
|
|
1435
|
-
|
|
1758
|
+
hoverIntent.reset();
|
|
1759
|
+
if (isOurs(event.target) || selectionIsActive() || composeOpen || composing) return;
|
|
1436
1760
|
const target = commentTargetFor(event.target);
|
|
1437
1761
|
if (target) setElementTarget(target);
|
|
1438
1762
|
});
|
|
1439
1763
|
|
|
1440
1764
|
document.addEventListener("mouseleave", () => {
|
|
1441
1765
|
if (resizing || moving) return;
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1766
|
+
hoverIntent.request(null, null);
|
|
1767
|
+
});
|
|
1768
|
+
const cancelHover = () => {
|
|
1769
|
+
hoverIntent.reset();
|
|
1770
|
+
pointerSelecting = false;
|
|
1771
|
+
clearHover();
|
|
1772
|
+
};
|
|
1773
|
+
window.addEventListener("blur", cancelHover);
|
|
1774
|
+
window.addEventListener("hashchange", cancelHover);
|
|
1775
|
+
window.addEventListener("popstate", cancelHover);
|
|
1776
|
+
window.addEventListener("pagehide", () => {
|
|
1777
|
+
disposed = true;
|
|
1778
|
+
cancelHover();
|
|
1779
|
+
clearTimeout(selectionTimer);
|
|
1780
|
+
clearTimeout(viewChangeTimer);
|
|
1781
|
+
viewObserver?.disconnect();
|
|
1782
|
+
clearTimeout(controlRestoreTimer);
|
|
1783
|
+
clearInterval(geometryWatchTimer);
|
|
1784
|
+
geometryWatchTimer = null;
|
|
1785
|
+
disconnectTargetObservers();
|
|
1786
|
+
});
|
|
1787
|
+
window.addEventListener("pageshow", () => {
|
|
1788
|
+
disposed = false;
|
|
1789
|
+
watchObservedView();
|
|
1790
|
+
renderBlockAnnotations();
|
|
1456
1791
|
});
|
|
1457
1792
|
|
|
1458
1793
|
els.commentAction.addEventListener("pointerdown", (event) => {
|
|
@@ -1565,6 +1900,15 @@ function boot() {
|
|
|
1565
1900
|
return !!(sel && !sel.isCollapsed && sel.rangeCount && document.body.contains(sel.getRangeAt(0).commonAncestorContainer));
|
|
1566
1901
|
};
|
|
1567
1902
|
|
|
1903
|
+
const restoreElementCommentTarget = () => {
|
|
1904
|
+
if (composeOpen || composing || pointerSelecting || commentOpenRequestGeneration || selectionIsActive()) return;
|
|
1905
|
+
const focused = document.activeElement;
|
|
1906
|
+
const target = focused && focused !== document.body && focused !== document.documentElement && !isOurs(focused)
|
|
1907
|
+
? commentTargetFor(focused)
|
|
1908
|
+
: hoverTarget?.isConnected ? commentTargetFor(hoverTarget) : null;
|
|
1909
|
+
if (target) setElementTarget(target);
|
|
1910
|
+
};
|
|
1911
|
+
|
|
1568
1912
|
const controlsAreSuppressed = () => Date.now() < typingUntil || selectionIsActive();
|
|
1569
1913
|
|
|
1570
1914
|
const hideActionControls = () => {
|
|
@@ -1599,16 +1943,20 @@ function boot() {
|
|
|
1599
1943
|
);
|
|
1600
1944
|
|
|
1601
1945
|
document.addEventListener("selectionchange", () => {
|
|
1946
|
+
hoverIntent.cancel();
|
|
1947
|
+
if (selectionIsActive()) place(els.outline, null);
|
|
1602
1948
|
clearTimeout(selectionTimer);
|
|
1603
|
-
if (!
|
|
1604
|
-
else els.commentAction.style.display = "none";
|
|
1949
|
+
if (!commentOpenRequestGeneration) els.commentAction.style.display = "none";
|
|
1605
1950
|
selectionTimer = setTimeout(() => {
|
|
1606
1951
|
if (selectionIsActive()) {
|
|
1607
1952
|
hideActionControls();
|
|
1608
|
-
settleSelection();
|
|
1953
|
+
if (!settleSelection() && !composeOpen && !commentOpenRequestGeneration) clearPending();
|
|
1609
1954
|
} else {
|
|
1610
1955
|
restoreActionControls();
|
|
1611
|
-
if (!composeOpen &&
|
|
1956
|
+
if (!composeOpen && !commentOpenRequestGeneration) {
|
|
1957
|
+
if (pending?.kind === "selection") clearPending();
|
|
1958
|
+
restoreElementCommentTarget();
|
|
1959
|
+
}
|
|
1612
1960
|
}
|
|
1613
1961
|
}, 40);
|
|
1614
1962
|
});
|
|
@@ -2097,7 +2445,9 @@ function boot() {
|
|
|
2097
2445
|
});
|
|
2098
2446
|
|
|
2099
2447
|
const reposition = () => {
|
|
2448
|
+
hoverIntent.cancel();
|
|
2100
2449
|
place(els.outline, hoverTarget);
|
|
2450
|
+
renderBlockAnnotations();
|
|
2101
2451
|
if (reviewMode === "edit" && !controlsAreSuppressed()) {
|
|
2102
2452
|
showChip(hoverTarget);
|
|
2103
2453
|
showMover(moving ? null : hoverMove);
|
|
@@ -2184,11 +2534,14 @@ function boot() {
|
|
|
2184
2534
|
break;
|
|
2185
2535
|
case "eh:modeMenuState":
|
|
2186
2536
|
modeMenuOpen = !!msg.open;
|
|
2537
|
+
hoverIntent.reset();
|
|
2187
2538
|
break;
|
|
2188
2539
|
case "eh:remove":
|
|
2189
2540
|
unwrap(msg.id);
|
|
2190
|
-
|
|
2541
|
+
blockTargets.delete(msg.id);
|
|
2542
|
+
if (activeCommentId === msg.id) activeCommentId = null;
|
|
2191
2543
|
place(els.activeBox, null);
|
|
2544
|
+
renderBlockAnnotations();
|
|
2192
2545
|
break;
|
|
2193
2546
|
case "eh:activate":
|
|
2194
2547
|
activate(msg.id, !!msg.scroll);
|
|
@@ -2197,7 +2550,17 @@ function boot() {
|
|
|
2197
2550
|
flushSave();
|
|
2198
2551
|
// Posted after the flushed eh:edit/eh:html messages, so when the
|
|
2199
2552
|
// chrome sees it, everything pending has already been handed over.
|
|
2200
|
-
post("eh:flushed", {
|
|
2553
|
+
post("eh:flushed", {
|
|
2554
|
+
...(typeof msg.requestId === "string" && msg.requestId.length > 0 && msg.requestId.length <= 128
|
|
2555
|
+
? { requestId: msg.requestId }
|
|
2556
|
+
: {}),
|
|
2557
|
+
});
|
|
2558
|
+
break;
|
|
2559
|
+
case "eh:captureSnapshot":
|
|
2560
|
+
void captureSnapshot(msg);
|
|
2561
|
+
break;
|
|
2562
|
+
case "eh:historyJump":
|
|
2563
|
+
jumpToHistoryTarget(msg);
|
|
2201
2564
|
break;
|
|
2202
2565
|
case "eh:abortSave":
|
|
2203
2566
|
// A revert is in flight: anything queued would re-write the edits.
|
|
@@ -2209,6 +2572,7 @@ function boot() {
|
|
|
2209
2572
|
if (savePolicy === "writable") checkDynamic(String(msg.html || ""));
|
|
2210
2573
|
break;
|
|
2211
2574
|
case "eh:configureReview": {
|
|
2575
|
+
cancelHover();
|
|
2212
2576
|
reviewMode = msg.mode === "edit" ? "edit" : "view";
|
|
2213
2577
|
savePolicy = msg.savePolicy === "feedback-only" ? "feedback-only" : "writable";
|
|
2214
2578
|
modeController.setMode(reviewMode);
|