@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/src/icons.js ADDED
@@ -0,0 +1,251 @@
1
+ // Generated by scripts/generate-icons.js from lucide-static. Do not edit.
2
+ export const ICON_NODES = Object.freeze({
3
+ "eye": [
4
+ [
5
+ "path",
6
+ {
7
+ "d": "M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"
8
+ }
9
+ ],
10
+ [
11
+ "circle",
12
+ {
13
+ "cx": "12",
14
+ "cy": "12",
15
+ "r": "3"
16
+ }
17
+ ]
18
+ ],
19
+ "pencil": [
20
+ [
21
+ "path",
22
+ {
23
+ "d": "M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"
24
+ }
25
+ ],
26
+ [
27
+ "path",
28
+ {
29
+ "d": "m15 5 4 4"
30
+ }
31
+ ]
32
+ ],
33
+ "chevronDown": [
34
+ [
35
+ "path",
36
+ {
37
+ "d": "m6 9 6 6 6-6"
38
+ }
39
+ ]
40
+ ],
41
+ "messageSquarePlus": [
42
+ [
43
+ "path",
44
+ {
45
+ "d": "M22 17a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 21.286V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2z"
46
+ }
47
+ ],
48
+ [
49
+ "path",
50
+ {
51
+ "d": "M12 8v6"
52
+ }
53
+ ],
54
+ [
55
+ "path",
56
+ {
57
+ "d": "M9 11h6"
58
+ }
59
+ ]
60
+ ],
61
+ "messages": [
62
+ [
63
+ "path",
64
+ {
65
+ "d": "M16 10a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 14.286V4a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"
66
+ }
67
+ ],
68
+ [
69
+ "path",
70
+ {
71
+ "d": "M20 9a2 2 0 0 1 2 2v10.286a.71.71 0 0 1-1.212.502l-2.202-2.202A2 2 0 0 0 17.172 19H10a2 2 0 0 1-2-2v-1"
72
+ }
73
+ ]
74
+ ],
75
+ "send": [
76
+ [
77
+ "path",
78
+ {
79
+ "d": "M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z"
80
+ }
81
+ ],
82
+ [
83
+ "path",
84
+ {
85
+ "d": "m21.854 2.147-10.94 10.939"
86
+ }
87
+ ]
88
+ ],
89
+ "trash": [
90
+ [
91
+ "path",
92
+ {
93
+ "d": "M10 11v6"
94
+ }
95
+ ],
96
+ [
97
+ "path",
98
+ {
99
+ "d": "M14 11v6"
100
+ }
101
+ ],
102
+ [
103
+ "path",
104
+ {
105
+ "d": "M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"
106
+ }
107
+ ],
108
+ [
109
+ "path",
110
+ {
111
+ "d": "M3 6h18"
112
+ }
113
+ ],
114
+ [
115
+ "path",
116
+ {
117
+ "d": "M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"
118
+ }
119
+ ]
120
+ ],
121
+ "moreHorizontal": [
122
+ [
123
+ "circle",
124
+ {
125
+ "cx": "12",
126
+ "cy": "12",
127
+ "r": "1"
128
+ }
129
+ ],
130
+ [
131
+ "circle",
132
+ {
133
+ "cx": "19",
134
+ "cy": "12",
135
+ "r": "1"
136
+ }
137
+ ],
138
+ [
139
+ "circle",
140
+ {
141
+ "cx": "5",
142
+ "cy": "12",
143
+ "r": "1"
144
+ }
145
+ ]
146
+ ],
147
+ "x": [
148
+ [
149
+ "path",
150
+ {
151
+ "d": "M18 6 6 18"
152
+ }
153
+ ],
154
+ [
155
+ "path",
156
+ {
157
+ "d": "m6 6 12 12"
158
+ }
159
+ ]
160
+ ],
161
+ "check": [
162
+ [
163
+ "path",
164
+ {
165
+ "d": "M20 6 9 17l-5-5"
166
+ }
167
+ ]
168
+ ],
169
+ "sun": [
170
+ [
171
+ "circle",
172
+ {
173
+ "cx": "12",
174
+ "cy": "12",
175
+ "r": "4"
176
+ }
177
+ ],
178
+ [
179
+ "path",
180
+ {
181
+ "d": "M12 2v2"
182
+ }
183
+ ],
184
+ [
185
+ "path",
186
+ {
187
+ "d": "M12 20v2"
188
+ }
189
+ ],
190
+ [
191
+ "path",
192
+ {
193
+ "d": "m4.93 4.93 1.41 1.41"
194
+ }
195
+ ],
196
+ [
197
+ "path",
198
+ {
199
+ "d": "m17.66 17.66 1.41 1.41"
200
+ }
201
+ ],
202
+ [
203
+ "path",
204
+ {
205
+ "d": "M2 12h2"
206
+ }
207
+ ],
208
+ [
209
+ "path",
210
+ {
211
+ "d": "M20 12h2"
212
+ }
213
+ ],
214
+ [
215
+ "path",
216
+ {
217
+ "d": "m6.34 17.66-1.41 1.41"
218
+ }
219
+ ],
220
+ [
221
+ "path",
222
+ {
223
+ "d": "m19.07 4.93-1.41 1.41"
224
+ }
225
+ ]
226
+ ],
227
+ "moon": [
228
+ [
229
+ "path",
230
+ {
231
+ "d": "M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401"
232
+ }
233
+ ]
234
+ ]
235
+ });
236
+
237
+ const escape = (value) => String(value).replace(/[&<>"]/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[char]);
238
+
239
+ export function iconMarkup(name, { title = "", className = "icon", size = 16 } = {}) {
240
+ const nodes = ICON_NODES[name];
241
+ if (!nodes) throw new Error(`Unknown icon: ${name}`);
242
+ const body = nodes.map(([tag, attrs]) => `<${tag} ${Object.entries(attrs).map(([key, value]) => `${key}="${escape(value)}"`).join(" ")}></${tag}>`).join("");
243
+ const label = title ? ` role="img" aria-label="${escape(title)}"` : ' aria-hidden="true"';
244
+ return `<svg class="${escape(className)}" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"${label}>${body}</svg>`;
245
+ }
246
+
247
+ export function createIcon(name, options = {}) {
248
+ const template = document.createElement("template");
249
+ template.innerHTML = iconMarkup(name, options);
250
+ return template.content.firstElementChild;
251
+ }
package/src/paths.js CHANGED
@@ -5,7 +5,11 @@ import fs from "node:fs";
5
5
 
6
6
  // Bump this when the CLI and detached server no longer share the same request
7
7
  // contract. A new CLI must not silently reuse an older background server.
8
- export const SERVER_PROTOCOL = 11;
8
+ export const SERVER_PROTOCOL = 13;
9
+
10
+ export function serverProtocolMatches(protocol) {
11
+ return Number(protocol) === SERVER_PROTOCOL;
12
+ }
9
13
 
10
14
  export function stateDir() {
11
15
  const override = process.env.DOC_REVIEW_STATE_DIR;
@@ -0,0 +1,222 @@
1
+ import http from "node:http";
2
+ import { performance } from "node:perf_hooks";
3
+
4
+ export const DEFAULT_POLL_SECONDS = 12 * 60 * 60;
5
+ const MAX_TIMER_MS = 2 ** 31 - 1;
6
+ const clock = {
7
+ now: () => performance.now(),
8
+ setTimeout: (fn, ms) => setTimeout(fn, ms),
9
+ clearTimeout: (timer) => clearTimeout(timer),
10
+ };
11
+
12
+ function codedError(message, code) {
13
+ return Object.assign(new Error(message), { code });
14
+ }
15
+
16
+ export function createDeadline(seconds = DEFAULT_POLL_SECONDS, time = clock) {
17
+ const end = time.now() + seconds * 1000;
18
+ const remaining = () => Math.max(0, end - time.now());
19
+ const check = () => {
20
+ if (remaining() <= 0) throw codedError("Polling deadline reached.", "POLL_DEADLINE");
21
+ };
22
+ return {
23
+ seconds,
24
+ time,
25
+ remaining,
26
+ check,
27
+ async sleep(ms) {
28
+ check();
29
+ await new Promise((resolve) => time.setTimeout(resolve, Math.min(ms, remaining(), MAX_TIMER_MS)));
30
+ check();
31
+ },
32
+ };
33
+ }
34
+
35
+ export function isRecoverableTransportError(err) {
36
+ return new Set([
37
+ "ECONNRESET", "ECONNREFUSED", "EPIPE", "ETIMEDOUT", "EHOSTUNREACH",
38
+ "ENETUNREACH", "ENETDOWN", "ECONNABORTED", "ERR_STREAM_PREMATURE_CLOSE",
39
+ "SERVER_START_PENDING",
40
+ ]).has(err?.code);
41
+ }
42
+
43
+ /**
44
+ * An absolute response deadline, not a socket-idle timeout: heartbeats and
45
+ * partially received JSON must not extend the caller's waiting budget.
46
+ */
47
+ export function requestRaw(server, options, body, {
48
+ timeoutMs = options.timeout,
49
+ time = clock,
50
+ signal,
51
+ timeoutCode = "ETIMEDOUT",
52
+ makeRequest = http.request,
53
+ } = {}) {
54
+ return new Promise((resolve, reject) => {
55
+ let req;
56
+ let res;
57
+ let timer;
58
+ let done = false;
59
+ let ended = false;
60
+ let raw = "";
61
+ const expires = timeoutMs == null ? null : time.now() + timeoutMs;
62
+ const premature = () => codedError("The server closed an incomplete response.", "ERR_STREAM_PREMATURE_CLOSE");
63
+ const cleanupResponse = () => {
64
+ res?.removeListener("data", onData);
65
+ res?.removeListener("end", onEnd);
66
+ res?.removeListener("aborted", onAborted);
67
+ };
68
+ const settle = (err, value) => {
69
+ if (done) return;
70
+ done = true;
71
+ time.clearTimeout(timer);
72
+ signal?.removeEventListener("abort", onAbort);
73
+ cleanupResponse();
74
+ // Keep error handlers until close: destroying a socket may emit one last
75
+ // error asynchronously. Close removes the remaining owned listeners.
76
+ res?.destroy();
77
+ req?.destroy();
78
+ if (err) reject(err);
79
+ else resolve(value);
80
+ };
81
+ const onError = (err) => settle(err);
82
+ const onData = (chunk) => { raw += chunk; };
83
+ const onEnd = () => {
84
+ ended = true;
85
+ if (!res.complete) return settle(premature());
86
+ settle(null, { status: res.statusCode, raw });
87
+ };
88
+ const onAborted = () => settle(premature());
89
+ const onResponseClose = () => {
90
+ if (!ended) settle(premature());
91
+ cleanupResponse();
92
+ res.removeListener("error", onError);
93
+ res.removeListener("close", onResponseClose);
94
+ };
95
+ const onRequestClose = () => {
96
+ if (!done) settle(premature());
97
+ req.removeListener("error", onError);
98
+ req.removeListener("close", onRequestClose);
99
+ req.removeListener("response", onResponse);
100
+ };
101
+ const onAbort = () => settle(codedError("Polling cancelled.", "ABORT_ERR"));
102
+ const armTimer = () => {
103
+ const left = expires - time.now();
104
+ if (left <= 0) return settle(codedError("Polling request timed out.", timeoutCode));
105
+ timer = time.setTimeout(armTimer, Math.min(left, MAX_TIMER_MS));
106
+ };
107
+ const onResponse = (response) => {
108
+ res = response;
109
+ if (done) {
110
+ res.destroy();
111
+ return;
112
+ }
113
+ res.setEncoding("utf8");
114
+ res.on("data", onData);
115
+ res.on("end", onEnd);
116
+ res.on("error", onError);
117
+ res.on("aborted", onAborted);
118
+ res.on("close", onResponseClose);
119
+ };
120
+ try {
121
+ if (signal?.aborted) return onAbort();
122
+ const port = typeof server === "number" ? server : server.port;
123
+ const token = typeof server === "number" ? "" : server.token || "";
124
+ const { timeout: _timeout, ...requestOptions } = options;
125
+ req = makeRequest({
126
+ host: "127.0.0.1",
127
+ port,
128
+ ...requestOptions,
129
+ headers: { ...(token ? { "x-doc-review-token": token } : {}), ...(options.headers || {}) },
130
+ });
131
+ req.on("response", onResponse);
132
+ req.on("error", onError);
133
+ req.on("close", onRequestClose);
134
+ signal?.addEventListener("abort", onAbort, { once: true });
135
+ if (expires !== null) armTimer();
136
+ if (done) return;
137
+ if (body) req.write(JSON.stringify(body));
138
+ req.end();
139
+ } catch (err) {
140
+ settle(err);
141
+ }
142
+ });
143
+ }
144
+
145
+ export function parseServerResponse(response) {
146
+ if (response.status !== 200) {
147
+ let detail = "";
148
+ try { detail = JSON.parse(response.raw).error || ""; } catch { /* HTTP status is sufficient. */ }
149
+ throw codedError(
150
+ `Doc-review server returned HTTP ${response.status}${detail ? `: ${detail}` : "."}`,
151
+ "SERVER_RESPONSE_ERROR",
152
+ );
153
+ }
154
+ try {
155
+ const parsed = JSON.parse(response.raw);
156
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error();
157
+ return parsed;
158
+ } catch {
159
+ throw codedError("Malformed response from the doc-review server. End the review and restart the server.", "SERVER_RESPONSE_INVALID");
160
+ }
161
+ }
162
+
163
+ export async function pollOnce(server, target, ackId, deadline) {
164
+ deadline.check();
165
+ const query = `target=${encodeURIComponent(target)}${ackId ? `&ack=${encodeURIComponent(ackId)}` : ""}`;
166
+ const response = await requestRaw(server, {
167
+ method: "GET",
168
+ path: `/api/poll?${query}`,
169
+ }, undefined, {
170
+ timeoutMs: deadline.remaining(),
171
+ time: deadline.time,
172
+ timeoutCode: "POLL_DEADLINE",
173
+ });
174
+ // Graceful server disposal ends the space heartbeats without a JSON payload.
175
+ // HTTP is complete, but this poll was interrupted just like a dropped socket.
176
+ if (response.status === 200 && /^ +$/.test(response.raw)) {
177
+ throw codedError("The server ended the poll before sending feedback.", "ERR_STREAM_PREMATURE_CLOSE");
178
+ }
179
+ const batch = parseServerResponse(response);
180
+ if (!["feedback", "closed", "timeout"].includes(batch.status) ||
181
+ (batch.status === "feedback" && (typeof batch.batch_id !== "string" || !batch.batch_id || !Array.isArray(batch.pages)))) {
182
+ throw codedError("Malformed polling response from the doc-review server.", "SERVER_RESPONSE_INVALID");
183
+ }
184
+ return batch;
185
+ }
186
+
187
+ export async function pollUntilDeadline({
188
+ target,
189
+ ackId = "",
190
+ deadline = createDeadline(),
191
+ discover,
192
+ poll = pollOnce,
193
+ diagnostic = () => {},
194
+ }) {
195
+ let failures = 0;
196
+ for (;;) {
197
+ try {
198
+ deadline.check();
199
+ const server = await discover(deadline);
200
+ deadline.check();
201
+ // An uncertain send must repeat the supplied receipt, never a newer ID.
202
+ const batch = await poll(server, target, ackId, deadline);
203
+ deadline.check();
204
+ return batch;
205
+ } catch (err) {
206
+ if (err.code === "POLL_DEADLINE" || deadline.remaining() <= 0) {
207
+ return {
208
+ status: "timeout",
209
+ waited_seconds: deadline.seconds,
210
+ next_step: "No feedback yet. Run the same poll command again to keep waiting, or `doc-review status <target>` to check without blocking.",
211
+ };
212
+ }
213
+ if (!isRecoverableTransportError(err)) throw err;
214
+ diagnostic(`Lost the connection (${err.message}); retrying.\n`);
215
+ try {
216
+ await deadline.sleep(Math.min(250 * 2 ** Math.min(failures++, 5), 5000));
217
+ } catch (sleepError) {
218
+ if (sleepError.code !== "POLL_DEADLINE") throw sleepError;
219
+ }
220
+ }
221
+ }
222
+ }
@@ -0,0 +1,107 @@
1
+ const GAP = 12;
2
+
3
+ export function visibleViewport(win = window) {
4
+ const viewport = win.visualViewport;
5
+ return viewport
6
+ ? { left: viewport.offsetLeft, top: viewport.offsetTop, width: viewport.width, height: viewport.height }
7
+ : { left: 0, top: 0, width: win.innerWidth, height: win.innerHeight };
8
+ }
9
+
10
+ export function lastVisibleRect(rects) {
11
+ if (!Array.isArray(rects)) return null;
12
+ for (let index = rects.length - 1; index >= 0; index -= 1) {
13
+ const rect = rects[index];
14
+ if (rect && rect.width >= 0 && rect.height >= 0) return rect;
15
+ }
16
+ return null;
17
+ }
18
+
19
+ const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
20
+
21
+ function frameRectToChrome(rect, frameRect) {
22
+ return {
23
+ left: frameRect.left + rect.left,
24
+ right: frameRect.left + rect.right,
25
+ top: frameRect.top + rect.top,
26
+ bottom: frameRect.top + rect.bottom,
27
+ };
28
+ }
29
+
30
+ export function placeContextualSurface(geometry, options) {
31
+ const {
32
+ frameRect,
33
+ viewport,
34
+ surfaceWidth = 340,
35
+ surfaceHeight = 220,
36
+ narrow = false,
37
+ toolbarHeight = 48,
38
+ } = options;
39
+ const bottomSheet = () => ({
40
+ kind: "sheet",
41
+ left: viewport.left,
42
+ top: viewport.top + viewport.height - Math.min(surfaceHeight, viewport.height),
43
+ width: viewport.width,
44
+ });
45
+ if (!geometry || geometry.relation === "unavailable" || !geometry.clip) return { kind: "hidden" };
46
+ if (narrow) return bottomSheet();
47
+ const clip = frameRectToChrome(geometry.clip, frameRect);
48
+ const minLeft = viewport.left + GAP;
49
+ const maxLeft = viewport.left + viewport.width - surfaceWidth - GAP;
50
+ const viewportMinTop = viewport.top + toolbarHeight + GAP;
51
+ const minTop = Math.max(viewportMinTop, clip.top + GAP);
52
+ const maxTop = viewport.top + viewport.height - surfaceHeight - GAP;
53
+ const clipBottomTop = clip.bottom - surfaceHeight - GAP;
54
+ const horizontal = Number.isFinite(geometry.horizontal)
55
+ ? frameRect.left + geometry.horizontal
56
+ : clip.left;
57
+ const edgeLeft = clamp(horizontal + GAP, minLeft, maxLeft);
58
+
59
+ if (geometry.relation === "above") {
60
+ return { kind: "edge-top", left: edgeLeft, top: minTop, width: surfaceWidth };
61
+ }
62
+ if (geometry.relation === "below") {
63
+ return {
64
+ kind: "edge-bottom",
65
+ left: edgeLeft,
66
+ top: Math.max(viewportMinTop, Math.min(maxTop, clipBottomTop)),
67
+ width: surfaceWidth,
68
+ };
69
+ }
70
+
71
+ const target = lastVisibleRect(geometry.rects);
72
+ if (!target) return { kind: "hidden" };
73
+ const rect = frameRectToChrome(target, frameRect);
74
+ const attachedMaxTop = Math.min(maxTop, clipBottomTop);
75
+ const clampTop = (top) => clamp(top, minTop, Math.max(minTop, attachedMaxTop));
76
+ const candidates = [
77
+ { left: rect.right + GAP, top: clampTop(rect.bottom - surfaceHeight) },
78
+ { left: rect.left - surfaceWidth - GAP, top: clampTop(rect.bottom - surfaceHeight) },
79
+ { left: clamp(rect.left, minLeft, maxLeft), top: rect.bottom + GAP },
80
+ { left: clamp(rect.left, minLeft, maxLeft), top: rect.top - surfaceHeight - GAP },
81
+ ];
82
+ const fit = candidates.find((candidate) =>
83
+ candidate.left >= minLeft &&
84
+ candidate.left <= maxLeft &&
85
+ candidate.top >= minTop &&
86
+ candidate.top <= attachedMaxTop &&
87
+ candidate.left + surfaceWidth <= Math.min(viewport.left + viewport.width - GAP, clip.right) &&
88
+ candidate.top + surfaceHeight <= clip.bottom
89
+ );
90
+ const fallback = fit || {
91
+ left: clamp(rect.right + GAP, minLeft, maxLeft),
92
+ top: clampTop(rect.top),
93
+ };
94
+ return { kind: "attached", ...fallback, width: surfaceWidth };
95
+ }
96
+
97
+ export function alignedCardPosition(rects, { frameRect, viewport, width = 300, height = 180 }) {
98
+ const target = lastVisibleRect(rects);
99
+ if (!target) return null;
100
+ const top = frameRect.top + target.top;
101
+ const right = frameRect.left + target.right;
102
+ if (top < frameRect.top || top > frameRect.bottom || right < frameRect.left || right > frameRect.right) return null;
103
+ return {
104
+ left: Math.max(viewport.left + 12, Math.min(viewport.left + viewport.width - width - 12, right + 12)),
105
+ top: Math.max(viewport.top + 12, Math.min(viewport.top + viewport.height - height - 12, top)),
106
+ };
107
+ }
@@ -0,0 +1,59 @@
1
+ export const REVIEW_MODES = Object.freeze(["view", "edit"]);
2
+
3
+ export function normalizeReviewMode(mode) {
4
+ return mode === "edit" ? "edit" : "view";
5
+ }
6
+
7
+ export function savePolicyForPage(page) {
8
+ return page && (page.kind === "url" || page.markdown || page.feedbackOnly)
9
+ ? "feedback-only"
10
+ : "writable";
11
+ }
12
+
13
+ export function reviewConfiguration(page, mode) {
14
+ return {
15
+ mode: normalizeReviewMode(mode),
16
+ savePolicy: savePolicyForPage(page),
17
+ };
18
+ }
19
+
20
+ export function applyBodyReviewMode(body, mode) {
21
+ const normalized = normalizeReviewMode(mode);
22
+ if (normalized === "edit") {
23
+ if (body.getAttribute("contenteditable") !== "true") body.setAttribute("contenteditable", "true");
24
+ if (body.getAttribute("spellcheck") !== "false") body.setAttribute("spellcheck", "false");
25
+ } else {
26
+ if (body.hasAttribute("contenteditable")) body.removeAttribute("contenteditable");
27
+ if (body.hasAttribute("spellcheck")) body.removeAttribute("spellcheck");
28
+ }
29
+ return normalized;
30
+ }
31
+
32
+ /** Keep the active mode intact when an application hydrates and rewrites body attributes. */
33
+ export function keepBodyInReviewMode(body, initialMode = "view") {
34
+ let mode = normalizeReviewMode(initialMode);
35
+ let applying = false;
36
+ const enforce = () => {
37
+ if (applying) return;
38
+ applying = true;
39
+ applyBodyReviewMode(body, mode);
40
+ applying = false;
41
+ };
42
+ const Observer = body.ownerDocument.defaultView.MutationObserver;
43
+ const observer = new Observer(enforce);
44
+ observer.observe(body, { attributes: true, attributeFilter: ["contenteditable", "spellcheck"] });
45
+ enforce();
46
+ return {
47
+ get mode() {
48
+ return mode;
49
+ },
50
+ setMode(nextMode) {
51
+ mode = normalizeReviewMode(nextMode);
52
+ enforce();
53
+ return mode;
54
+ },
55
+ disconnect() {
56
+ observer.disconnect();
57
+ },
58
+ };
59
+ }