@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/chrome-client.js
CHANGED
|
@@ -1,13 +1,30 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* doc-review chrome. Owns the
|
|
2
|
+
* doc-review chrome. Owns the toolbar, contextual surfaces, drawer, and every
|
|
3
|
+
* call to the local server.
|
|
3
4
|
* It never touches the artifact DOM directly — the SDK does that, over
|
|
4
5
|
* postMessage, because the artifact iframe lives on the other loopback
|
|
5
6
|
* hostname: a separate origin that can never reach this page or its token.
|
|
6
7
|
*/
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
8
|
+
import { tidyMiddle } from "./anchor-text.js";
|
|
9
|
+
import {
|
|
10
|
+
clearOwned,
|
|
11
|
+
createCommentUi,
|
|
12
|
+
migrateCommentUi,
|
|
13
|
+
mutationIsCurrent,
|
|
14
|
+
ownConfirmation,
|
|
15
|
+
ownEdit,
|
|
16
|
+
ownMenu,
|
|
17
|
+
newestComments,
|
|
18
|
+
pageUrl,
|
|
19
|
+
reconcileCommentUi,
|
|
20
|
+
replacePage,
|
|
21
|
+
} from "./chrome-session.js";
|
|
22
|
+
import { sanitizeClientRects, sanitizeClipRect, sanitizeRelation } from "./comment-target.js";
|
|
9
23
|
import { externalHref } from "./editing.js";
|
|
10
24
|
import { framePolicy } from "./frame-policy.js";
|
|
25
|
+
import { createIcon } from "./icons.js";
|
|
26
|
+
import { alignedCardPosition, placeContextualSurface, visibleViewport } from "./positioning.js";
|
|
27
|
+
import { normalizeReviewMode, reviewConfiguration } from "./review-mode.js";
|
|
11
28
|
|
|
12
29
|
const $ = (id) => document.getElementById(id);
|
|
13
30
|
const frame = $("frame");
|
|
@@ -18,7 +35,18 @@ const state = {
|
|
|
18
35
|
key: null,
|
|
19
36
|
page: null,
|
|
20
37
|
compose: null,
|
|
21
|
-
|
|
38
|
+
composeLifecycle: "closed",
|
|
39
|
+
composePlacement: "hidden",
|
|
40
|
+
target: null,
|
|
41
|
+
activeSavedCommentId: null,
|
|
42
|
+
activeGeometry: new Map(),
|
|
43
|
+
commentUi: createCommentUi(),
|
|
44
|
+
pageEpoch: 0,
|
|
45
|
+
reviewMode: "view",
|
|
46
|
+
savePolicy: "writable",
|
|
47
|
+
modeApplying: false,
|
|
48
|
+
modeMenuOpen: false,
|
|
49
|
+
drawerOpen: false,
|
|
22
50
|
agent: "idle",
|
|
23
51
|
save: "idle",
|
|
24
52
|
savedAt: "",
|
|
@@ -39,8 +67,180 @@ const state = {
|
|
|
39
67
|
readyGeneration: null,
|
|
40
68
|
frameReadyTimer: null,
|
|
41
69
|
frameReadyRetries: 0,
|
|
70
|
+
configurationGeneration: null,
|
|
71
|
+
saveConflict: false,
|
|
42
72
|
};
|
|
43
73
|
|
|
74
|
+
const diagnostic = (event, detail = {}) => {
|
|
75
|
+
console.info("[doc-review]", { event, ...detail });
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const patchFlights = new Map();
|
|
79
|
+
const deleteFlights = new Map();
|
|
80
|
+
let requestedFocus = null;
|
|
81
|
+
let editFocusRequested = false;
|
|
82
|
+
let skipEditCaptureOnce = false;
|
|
83
|
+
|
|
84
|
+
function advancePageEpoch(reason) {
|
|
85
|
+
state.pageEpoch += 1;
|
|
86
|
+
diagnostic("page-epoch-advanced", { epoch: state.pageEpoch, reason });
|
|
87
|
+
return state.pageEpoch;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function commentById(id) {
|
|
91
|
+
return state.page?.comments?.find((comment) => comment.id === id) || null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function domId(value) {
|
|
95
|
+
return String(value).replace(/[^a-zA-Z0-9_-]/g, (char) => `-${char.codePointAt(0).toString(16)}-`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function controlId(commentId, surface, action) {
|
|
99
|
+
return `comment-${surface}-${domId(commentId)}-${action}`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function requestControlFocus(id) {
|
|
103
|
+
requestedFocus = id;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function editTextarea(commentId) {
|
|
107
|
+
const root = state.drawerOpen ? $("cards") : $("alignedCard");
|
|
108
|
+
return root?.querySelector(`textarea[data-comment-edit="${CSS.escape(commentId)}"]`) || null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function captureEditState() {
|
|
112
|
+
const edit = state.commentUi.edit;
|
|
113
|
+
if (!edit) return false;
|
|
114
|
+
const input = editTextarea(edit.commentId);
|
|
115
|
+
if (!input) return false;
|
|
116
|
+
edit.draft = input.value;
|
|
117
|
+
edit.selectionStart = input.selectionStart;
|
|
118
|
+
edit.selectionEnd = input.selectionEnd;
|
|
119
|
+
return document.activeElement === input;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function restoreTransientFocus(editWasFocused = false) {
|
|
123
|
+
const focusId = requestedFocus;
|
|
124
|
+
requestedFocus = null;
|
|
125
|
+
const shouldFocusEdit = editWasFocused || editFocusRequested;
|
|
126
|
+
requestAnimationFrame(() => {
|
|
127
|
+
if (focusId) {
|
|
128
|
+
document.getElementById(focusId)?.focus();
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
const edit = state.commentUi.edit;
|
|
132
|
+
if (!shouldFocusEdit || !edit) return;
|
|
133
|
+
const input = editTextarea(edit.commentId);
|
|
134
|
+
if (!input || input.closest("[hidden]")) {
|
|
135
|
+
editFocusRequested = true;
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
editFocusRequested = false;
|
|
139
|
+
input.focus();
|
|
140
|
+
input.setSelectionRange(edit.selectionStart, edit.selectionEnd);
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function moveTransientSurface(surface) {
|
|
145
|
+
const menu = state.commentUi.menu;
|
|
146
|
+
if (menu) {
|
|
147
|
+
menu.surface = surface;
|
|
148
|
+
menu.triggerId = controlId(menu.commentId, surface, "more");
|
|
149
|
+
}
|
|
150
|
+
if (state.commentUi.confirmation) state.commentUi.confirmation.surface = surface;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function announce(message) {
|
|
154
|
+
const region = $("liveRegion");
|
|
155
|
+
region.textContent = "";
|
|
156
|
+
requestAnimationFrame(() => {
|
|
157
|
+
region.textContent = message;
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function replaceIcon(container, name, title = "") {
|
|
162
|
+
container.textContent = "";
|
|
163
|
+
container.append(createIcon(name, { title }));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function installStaticIcons() {
|
|
167
|
+
document.querySelectorAll("[data-icon]").forEach((container) => {
|
|
168
|
+
replaceIcon(container, container.dataset.icon);
|
|
169
|
+
});
|
|
170
|
+
replaceIcon($("modeChevron"), "chevronDown");
|
|
171
|
+
replaceIcon($("composeClose"), "x");
|
|
172
|
+
replaceIcon($("drawerClose"), "x");
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function openModeMenu() {
|
|
176
|
+
if (state.modeMenuOpen) return;
|
|
177
|
+
state.modeMenuOpen = true;
|
|
178
|
+
$("modeMenu").hidden = false;
|
|
179
|
+
$("modeButton").setAttribute("aria-expanded", "true");
|
|
180
|
+
toFrame({ type: "eh:modeMenuState", open: true });
|
|
181
|
+
$("modeMenu").querySelector(`[data-mode="${state.reviewMode}"]`)?.focus();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function closeModeMenu(reason = "dismissed", { restoreFocus = false } = {}) {
|
|
185
|
+
if (!state.modeMenuOpen) return false;
|
|
186
|
+
state.modeMenuOpen = false;
|
|
187
|
+
$("modeMenu").hidden = true;
|
|
188
|
+
$("modeButton").setAttribute("aria-expanded", "false");
|
|
189
|
+
toFrame({ type: "eh:modeMenuState", open: false });
|
|
190
|
+
diagnostic("mode-menu-close", { reason });
|
|
191
|
+
if (restoreFocus) $("modeButton").focus();
|
|
192
|
+
return true;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function openDrawer() {
|
|
196
|
+
captureEditState();
|
|
197
|
+
skipEditCaptureOnce = true;
|
|
198
|
+
state.drawerOpen = true;
|
|
199
|
+
moveTransientSurface("drawer");
|
|
200
|
+
editFocusRequested = !!state.commentUi.edit;
|
|
201
|
+
render();
|
|
202
|
+
if (!state.commentUi.edit) requestAnimationFrame(() => $("commentsSection").focus());
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function closeDrawer() {
|
|
206
|
+
if (!state.drawerOpen) return;
|
|
207
|
+
captureEditState();
|
|
208
|
+
skipEditCaptureOnce = true;
|
|
209
|
+
state.drawerOpen = false;
|
|
210
|
+
const transientOwner =
|
|
211
|
+
state.commentUi.edit?.commentId ||
|
|
212
|
+
state.commentUi.confirmation?.commentId ||
|
|
213
|
+
state.commentUi.menu?.commentId ||
|
|
214
|
+
null;
|
|
215
|
+
if (transientOwner && commentById(transientOwner)) {
|
|
216
|
+
state.activeSavedCommentId = transientOwner;
|
|
217
|
+
moveTransientSurface("aligned");
|
|
218
|
+
toFrame({ type: "eh:activate", id: transientOwner, scroll: false });
|
|
219
|
+
} else if (state.activeSavedCommentId) {
|
|
220
|
+
moveTransientSurface("aligned");
|
|
221
|
+
}
|
|
222
|
+
editFocusRequested = !!state.commentUi.edit;
|
|
223
|
+
render();
|
|
224
|
+
if (!state.commentUi.edit) $("commentsButton").focus();
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function setComposeLifecycle(next, reason) {
|
|
228
|
+
if (state.composeLifecycle === next) return;
|
|
229
|
+
const from = state.composeLifecycle;
|
|
230
|
+
state.composeLifecycle = next;
|
|
231
|
+
const submitting = next === "submitting";
|
|
232
|
+
$("composeCancel").disabled = submitting;
|
|
233
|
+
$("composeClose").disabled = submitting;
|
|
234
|
+
diagnostic("composer-lifecycle-transition", { from, to: next, reason });
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function setComposePlacement(next) {
|
|
238
|
+
if (state.composePlacement === next) return;
|
|
239
|
+
const from = state.composePlacement;
|
|
240
|
+
state.composePlacement = next;
|
|
241
|
+
diagnostic("placement-transition", { from, to: next });
|
|
242
|
+
}
|
|
243
|
+
|
|
44
244
|
/**
|
|
45
245
|
* Most reviewers drive an agent from a chat (Claude Code, Codex, Cursor), not
|
|
46
246
|
* a bare terminal — so the handoff is a prompt the agent can act on, with the
|
|
@@ -68,6 +268,102 @@ async function api(path, options) {
|
|
|
68
268
|
return res.json();
|
|
69
269
|
}
|
|
70
270
|
|
|
271
|
+
const editPipelines = new Map();
|
|
272
|
+
const editBacklogs = new Map();
|
|
273
|
+
const editErrors = new Map();
|
|
274
|
+
|
|
275
|
+
function editIdentity(payload) {
|
|
276
|
+
return `${String(payload.label || "")}\u0000${String(payload.kind || "")}`;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function editBacklog(key) {
|
|
280
|
+
let backlog = editBacklogs.get(key);
|
|
281
|
+
if (!backlog) {
|
|
282
|
+
backlog = new Map();
|
|
283
|
+
editBacklogs.set(key, backlog);
|
|
284
|
+
}
|
|
285
|
+
return backlog;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function persistEdit(key, payload) {
|
|
289
|
+
const identity = editIdentity(payload);
|
|
290
|
+
const backlog = editBacklog(key);
|
|
291
|
+
backlog.set(identity, payload);
|
|
292
|
+
const previous = editPipelines.get(key) || Promise.resolve();
|
|
293
|
+
const current = previous
|
|
294
|
+
.catch(() => {})
|
|
295
|
+
.then(async () => {
|
|
296
|
+
try {
|
|
297
|
+
const result = await api(`/api/page/${key}/edit`, {
|
|
298
|
+
method: "POST",
|
|
299
|
+
body: JSON.stringify(payload),
|
|
300
|
+
});
|
|
301
|
+
if (backlog.get(identity) === payload) backlog.delete(identity);
|
|
302
|
+
editErrors.delete(key);
|
|
303
|
+
if (state.key === key) {
|
|
304
|
+
state.page = result.page;
|
|
305
|
+
state.sent = false;
|
|
306
|
+
render();
|
|
307
|
+
}
|
|
308
|
+
return true;
|
|
309
|
+
} catch (err) {
|
|
310
|
+
editErrors.set(key, err);
|
|
311
|
+
if (state.key === key) {
|
|
312
|
+
toast(`${err.message}. Your edit is still queued locally.`);
|
|
313
|
+
announce("An edit could not be saved. Retry before leaving the page or sending feedback.");
|
|
314
|
+
}
|
|
315
|
+
return false;
|
|
316
|
+
}
|
|
317
|
+
});
|
|
318
|
+
editPipelines.set(key, current);
|
|
319
|
+
void current.finally(() => {
|
|
320
|
+
if (editPipelines.get(key) === current) editPipelines.delete(key);
|
|
321
|
+
});
|
|
322
|
+
return current;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
async function settleEditPersistence(key) {
|
|
326
|
+
const inFlight = editPipelines.get(key);
|
|
327
|
+
if (inFlight) await inFlight;
|
|
328
|
+
const backlog = editBacklogs.get(key);
|
|
329
|
+
if (backlog?.size) {
|
|
330
|
+
const retry = [...backlog.values()];
|
|
331
|
+
editErrors.delete(key);
|
|
332
|
+
for (const payload of retry) persistEdit(key, payload);
|
|
333
|
+
const retried = editPipelines.get(key);
|
|
334
|
+
if (retried) await retried;
|
|
335
|
+
}
|
|
336
|
+
if (editBacklogs.get(key)?.size) {
|
|
337
|
+
throw editErrors.get(key) || new Error("An edit could not be saved");
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function reconcilePage(page, { syncAnchors = false, reason = "mutation", advance = reason !== "mutation" } = {}) {
|
|
342
|
+
const previousIds = new Set((state.page?.comments || []).map((comment) => comment.id));
|
|
343
|
+
if (advance) advancePageEpoch(reason);
|
|
344
|
+
replacePage(state, page);
|
|
345
|
+
const commentIds = new Set((page.comments || []).map((comment) => comment.id));
|
|
346
|
+
for (const id of state.activeGeometry.keys()) {
|
|
347
|
+
if (!commentIds.has(id)) state.activeGeometry.delete(id);
|
|
348
|
+
}
|
|
349
|
+
if (state.activeSavedCommentId && !commentIds.has(state.activeSavedCommentId)) {
|
|
350
|
+
state.activeSavedCommentId = null;
|
|
351
|
+
}
|
|
352
|
+
const removedTransient = reconcileCommentUi(state.commentUi, page.comments || []);
|
|
353
|
+
state.orphans = new Set([...state.orphans].filter((id) => commentIds.has(id)));
|
|
354
|
+
if (syncAnchors && !state.frameLoading) {
|
|
355
|
+
toFrame({ type: "eh:anchors", comments: page.comments || [] });
|
|
356
|
+
}
|
|
357
|
+
if (reason === "acknowledgement") {
|
|
358
|
+
const removed = [...previousIds].filter((id) => !commentIds.has(id));
|
|
359
|
+
if (removed.length) {
|
|
360
|
+
announce(`${removed.length === 1 ? "Comment" : "Comments"} delivered and removed`);
|
|
361
|
+
if (removedTransient.size) requestAnimationFrame(() => (state.drawerOpen ? $("commentsSection") : frame).focus());
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
diagnostic("page-reconciled", { reason, epoch: state.pageEpoch });
|
|
365
|
+
}
|
|
366
|
+
|
|
71
367
|
// Keep the reviewed app on a different loopback origin from the review shell.
|
|
72
368
|
// This gives route-aware frameworks a real origin without exposing the parent UI.
|
|
73
369
|
const ARTIFACT_HOST = location.hostname === "127.0.0.1" ? "localhost" : "127.0.0.1";
|
|
@@ -96,6 +392,8 @@ function suspendFrame() {
|
|
|
96
392
|
state.renderId = null;
|
|
97
393
|
state.frameLoading = true;
|
|
98
394
|
state.readyGeneration = null;
|
|
395
|
+
state.configurationGeneration = null;
|
|
396
|
+
frame.removeAttribute("data-sdk-ready");
|
|
99
397
|
}
|
|
100
398
|
|
|
101
399
|
function beginFrameTransition() {
|
|
@@ -145,33 +443,90 @@ function rotateCurrentFrame() {
|
|
|
145
443
|
* typing. The timeout covers a torn-down or never-booted frame.
|
|
146
444
|
*/
|
|
147
445
|
let flushWaiter = null;
|
|
148
|
-
|
|
149
|
-
|
|
446
|
+
let configurationWaiter = null;
|
|
447
|
+
async function flushFrame() {
|
|
448
|
+
const key = state.key;
|
|
449
|
+
if (!state.frameLoading && state.frameCapability) {
|
|
450
|
+
await new Promise((resolve) => {
|
|
451
|
+
const settle = () => {
|
|
452
|
+
if (flushWaiter !== settle) return;
|
|
453
|
+
flushWaiter = null;
|
|
454
|
+
resolve();
|
|
455
|
+
};
|
|
456
|
+
flushWaiter = settle;
|
|
457
|
+
toFrame({ type: "eh:flush" });
|
|
458
|
+
setTimeout(settle, 400);
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
if (key) await settleEditPersistence(key);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function configureFrame() {
|
|
465
|
+
if (state.frameLoading || !state.frameCapability) return Promise.resolve(false);
|
|
466
|
+
const configuration = reviewConfiguration(state.page, state.reviewMode);
|
|
467
|
+
state.savePolicy = configuration.savePolicy;
|
|
150
468
|
return new Promise((resolve) => {
|
|
151
|
-
const settle = () => {
|
|
152
|
-
if (
|
|
153
|
-
|
|
154
|
-
resolve();
|
|
469
|
+
const settle = (applied) => {
|
|
470
|
+
if (configurationWaiter?.settle !== settle) return;
|
|
471
|
+
configurationWaiter = null;
|
|
472
|
+
resolve(applied);
|
|
155
473
|
};
|
|
156
|
-
|
|
157
|
-
toFrame({ type: "eh:
|
|
158
|
-
setTimeout(settle,
|
|
474
|
+
configurationWaiter = { ...configuration, settle };
|
|
475
|
+
toFrame({ type: "eh:configureReview", ...configuration });
|
|
476
|
+
setTimeout(() => settle(false), 3000);
|
|
159
477
|
});
|
|
160
478
|
}
|
|
161
479
|
|
|
480
|
+
async function setReviewMode(nextMode) {
|
|
481
|
+
const next = normalizeReviewMode(nextMode);
|
|
482
|
+
closeModeMenu("selection");
|
|
483
|
+
if (next === state.reviewMode || state.modeApplying) return;
|
|
484
|
+
state.modeApplying = true;
|
|
485
|
+
render();
|
|
486
|
+
try {
|
|
487
|
+
if (state.reviewMode === "edit" && next === "view") {
|
|
488
|
+
await flushFrame();
|
|
489
|
+
await activeSavePromise;
|
|
490
|
+
if (state.saveConflict) {
|
|
491
|
+
toast("Resolve the save conflict before leaving Edit");
|
|
492
|
+
announce("Save conflict. Edit mode remains active.");
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
state.reviewMode = next;
|
|
497
|
+
const applied = await configureFrame();
|
|
498
|
+
if (!applied) throw new Error("The reviewed page did not confirm the mode change");
|
|
499
|
+
diagnostic("mode-change", { mode: next, savePolicy: state.savePolicy });
|
|
500
|
+
announce(`${next === "edit" ? "Edit" : "View"} mode active`);
|
|
501
|
+
} catch (err) {
|
|
502
|
+
state.reviewMode = next === "edit" ? "view" : "edit";
|
|
503
|
+
toast(`${err.message}. Stay in ${state.reviewMode === "edit" ? "Edit" : "View"} and retry.`);
|
|
504
|
+
} finally {
|
|
505
|
+
state.modeApplying = false;
|
|
506
|
+
render();
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
162
510
|
async function loadPage(key, { reload = true } = {}) {
|
|
163
511
|
const returning = state.page;
|
|
512
|
+
advancePageEpoch(returning ? "navigation" : "reload");
|
|
164
513
|
const generation = beginFrameTransition();
|
|
165
514
|
state.frameReadyRetries = 0;
|
|
166
515
|
state.key = key;
|
|
167
516
|
const page = await api(pageUrl(key, state.sessionId));
|
|
168
517
|
if (state.key !== key || state.renderGeneration !== generation) return;
|
|
169
|
-
|
|
518
|
+
reconcilePage(page, { reason: "reload", advance: false });
|
|
519
|
+
state.savePolicy = reviewConfiguration(state.page, state.reviewMode).savePolicy;
|
|
170
520
|
state.framePolicy = framePolicy(state.page, ARTIFACT_ORIGIN);
|
|
171
521
|
frame.setAttribute("sandbox", state.framePolicy.sandbox);
|
|
172
522
|
state.orphans = new Set();
|
|
173
523
|
state.compose = null;
|
|
174
|
-
|
|
524
|
+
setComposeLifecycle("closed", "page-change");
|
|
525
|
+
setComposePlacement("hidden");
|
|
526
|
+
state.target = null;
|
|
527
|
+
state.activeSavedCommentId = null;
|
|
528
|
+
state.activeGeometry.clear();
|
|
529
|
+
state.commentUi = createCommentUi();
|
|
175
530
|
state.sent = false;
|
|
176
531
|
state.dynamic = false;
|
|
177
532
|
state.baseHash = null;
|
|
@@ -208,116 +563,51 @@ const clock = () => new Date().toLocaleTimeString([], { hour: "numeric", minute:
|
|
|
208
563
|
function render() {
|
|
209
564
|
const page = state.page;
|
|
210
565
|
if (!page) return;
|
|
566
|
+
const editWasFocused = skipEditCaptureOnce ? false : captureEditState();
|
|
567
|
+
skipEditCaptureOnce = false;
|
|
211
568
|
document.title = page.filename || 'doc-review';
|
|
212
569
|
|
|
213
570
|
const comments = newestComments(page.comments);
|
|
214
571
|
const edits = page.edits || [];
|
|
215
572
|
|
|
216
573
|
$("count").textContent = String(comments.length);
|
|
574
|
+
$("toolbarCount").textContent = String(comments.length);
|
|
217
575
|
$("empty").hidden = comments.length > 0 || !!state.compose;
|
|
576
|
+
$("modeLabel").textContent = state.reviewMode === "edit" ? "Edit" : "View";
|
|
577
|
+
replaceIcon($("modeIcon"), state.reviewMode === "edit" ? "pencil" : "eye");
|
|
578
|
+
$("modeButton").disabled = state.modeApplying;
|
|
579
|
+
for (const item of $("modeMenu").querySelectorAll("[data-mode]")) {
|
|
580
|
+
const checked = item.dataset.mode === state.reviewMode;
|
|
581
|
+
item.setAttribute("aria-checked", String(checked));
|
|
582
|
+
const check = item.querySelector(".menu-check");
|
|
583
|
+
check.textContent = "";
|
|
584
|
+
if (checked) check.append(createIcon("check"));
|
|
585
|
+
}
|
|
586
|
+
$("drawer").classList.toggle("open", state.drawerOpen);
|
|
587
|
+
$("drawer").setAttribute("aria-hidden", String(!state.drawerOpen));
|
|
588
|
+
$("commentsButton").setAttribute("aria-expanded", String(state.drawerOpen));
|
|
589
|
+
$("drawerBackdrop").hidden = !state.drawerOpen;
|
|
218
590
|
|
|
219
591
|
// --- compose
|
|
220
592
|
const composeWrap = $("compose");
|
|
221
593
|
if (state.compose) {
|
|
222
594
|
composeWrap.hidden = false;
|
|
223
595
|
$("composeKind").textContent = state.compose.kind === "element" ? "Element" : "Selection";
|
|
224
|
-
$("composeQuote").textContent =
|
|
596
|
+
$("composeQuote").textContent = tidyMiddle(state.compose.quote, 260);
|
|
597
|
+
requestAnimationFrame(positionCompose);
|
|
225
598
|
} else {
|
|
226
599
|
composeWrap.hidden = true;
|
|
227
600
|
$("composeText").value = "";
|
|
601
|
+
$("composeError").hidden = true;
|
|
228
602
|
}
|
|
229
603
|
|
|
230
604
|
// --- comment cards
|
|
231
605
|
const list = $("cards");
|
|
232
606
|
list.textContent = "";
|
|
233
607
|
for (const comment of comments) {
|
|
234
|
-
|
|
235
|
-
card.className = `comment${state.active === comment.id ? " active" : ""}`;
|
|
236
|
-
card.dataset.id = comment.id;
|
|
237
|
-
|
|
238
|
-
const head = document.createElement("div");
|
|
239
|
-
head.className = "comment-head";
|
|
240
|
-
|
|
241
|
-
const who = document.createElement("span");
|
|
242
|
-
who.className = "who";
|
|
243
|
-
who.append("You");
|
|
244
|
-
const sep = document.createElement("span");
|
|
245
|
-
sep.className = "sep";
|
|
246
|
-
sep.textContent = "·";
|
|
247
|
-
const when = document.createElement("span");
|
|
248
|
-
when.className = "when";
|
|
249
|
-
when.textContent = ago(comment.updatedAt || comment.createdAt);
|
|
250
|
-
who.append(sep, when);
|
|
251
|
-
|
|
252
|
-
if (comment.correction) {
|
|
253
|
-
const badge = document.createElement("span");
|
|
254
|
-
badge.className = "badge correction";
|
|
255
|
-
badge.textContent = "correction";
|
|
256
|
-
who.append(badge);
|
|
257
|
-
} else if (comment.updatedAt) {
|
|
258
|
-
const badge = document.createElement("span");
|
|
259
|
-
badge.className = "badge";
|
|
260
|
-
badge.textContent = "edited";
|
|
261
|
-
who.append(badge);
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
if (state.orphans.has(comment.id)) {
|
|
265
|
-
const badge = document.createElement("span");
|
|
266
|
-
badge.className = "badge";
|
|
267
|
-
badge.textContent = "orphaned";
|
|
268
|
-
who.append(badge);
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
const jump = document.createElement("button");
|
|
272
|
-
jump.type = "button";
|
|
273
|
-
jump.className = "jump";
|
|
274
|
-
jump.textContent = "Jump to";
|
|
275
|
-
jump.addEventListener("click", (event) => {
|
|
276
|
-
event.stopPropagation();
|
|
277
|
-
setActive(comment.id, true);
|
|
278
|
-
});
|
|
279
|
-
|
|
280
|
-
const edit = document.createElement("button");
|
|
281
|
-
edit.type = "button";
|
|
282
|
-
edit.className = "jump edit-comment";
|
|
283
|
-
edit.textContent = "Edit";
|
|
284
|
-
edit.addEventListener("click", (event) => {
|
|
285
|
-
event.stopPropagation();
|
|
286
|
-
editComment(card, body, comment);
|
|
287
|
-
});
|
|
288
|
-
|
|
289
|
-
const remove = document.createElement("button");
|
|
290
|
-
remove.type = "button";
|
|
291
|
-
remove.className = "remove";
|
|
292
|
-
remove.title = "Delete comment";
|
|
293
|
-
remove.setAttribute("aria-label", "Delete comment");
|
|
294
|
-
remove.textContent = "✕";
|
|
295
|
-
remove.addEventListener("click", async (event) => {
|
|
296
|
-
event.stopPropagation();
|
|
297
|
-
toFrame({ type: "eh:remove", id: comment.id });
|
|
298
|
-
state.page = (await api(`/api/page/${state.key}/comment/${comment.id}`, { method: "DELETE" })).page;
|
|
299
|
-
render();
|
|
300
|
-
});
|
|
301
|
-
|
|
302
|
-
const quote = document.createElement("p");
|
|
303
|
-
quote.className = "quote";
|
|
304
|
-
quote.textContent = tidy(comment.quote, 140);
|
|
305
|
-
|
|
306
|
-
const body = document.createElement("p");
|
|
307
|
-
body.className = "body";
|
|
308
|
-
body.textContent = comment.feedback;
|
|
309
|
-
body.title = "Click to edit";
|
|
310
|
-
body.addEventListener("click", (event) => {
|
|
311
|
-
event.stopPropagation();
|
|
312
|
-
editComment(card, body, comment);
|
|
313
|
-
});
|
|
314
|
-
|
|
315
|
-
head.append(who, jump, edit, remove);
|
|
316
|
-
|
|
317
|
-
card.append(head, quote, body);
|
|
318
|
-
card.addEventListener("click", () => setActive(comment.id, false));
|
|
319
|
-
list.append(card);
|
|
608
|
+
list.append(renderCommentCard(comment, "drawer"));
|
|
320
609
|
}
|
|
610
|
+
renderAlignedCard(comments);
|
|
321
611
|
|
|
322
612
|
// --- your edits
|
|
323
613
|
const box = $("editsBox");
|
|
@@ -376,14 +666,18 @@ function render() {
|
|
|
376
666
|
count.textContent = String(other.count);
|
|
377
667
|
row.append(label, count);
|
|
378
668
|
row.addEventListener("click", async () => {
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
669
|
+
try {
|
|
670
|
+
await flushFrame();
|
|
671
|
+
suspendFrame();
|
|
672
|
+
await api(`/api/session/${state.sessionId}/goto`, {
|
|
673
|
+
method: "POST",
|
|
674
|
+
body: JSON.stringify({ key: other.key }),
|
|
675
|
+
});
|
|
676
|
+
state.scroll = { x: 0, y: 0 };
|
|
677
|
+
await loadPage(other.key);
|
|
678
|
+
} catch (err) {
|
|
679
|
+
toast(`${err.message}. Stay on this page and retry.`);
|
|
680
|
+
}
|
|
387
681
|
});
|
|
388
682
|
list.append(row);
|
|
389
683
|
}
|
|
@@ -411,13 +705,6 @@ function render() {
|
|
|
411
705
|
: hasNote
|
|
412
706
|
? "Send note to agent"
|
|
413
707
|
: "Nothing to send yet";
|
|
414
|
-
if (!send.disabled) {
|
|
415
|
-
const key = document.createElement("span");
|
|
416
|
-
key.className = "key";
|
|
417
|
-
key.textContent = "⌘⏎";
|
|
418
|
-
send.append(" ", key);
|
|
419
|
-
}
|
|
420
|
-
|
|
421
708
|
// After sending, say what happens next. If nothing is polling, the loop would
|
|
422
709
|
// otherwise dead-end silently, so hand over the exact command to run.
|
|
423
710
|
$("agentLine").hidden = !delivered;
|
|
@@ -426,6 +713,506 @@ function render() {
|
|
|
426
713
|
// Server-authoritative, so it survives a browser refresh.
|
|
427
714
|
$("handoff").hidden = !stranded;
|
|
428
715
|
if (stranded) $("handoffCmd").textContent = handoffPrompt(state.pollCommand || page.pollCommand);
|
|
716
|
+
restoreTransientFocus(editWasFocused);
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
function positionCompose() {
|
|
720
|
+
if (!state.compose || state.composeLifecycle === "closed") {
|
|
721
|
+
setComposePlacement("hidden");
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
724
|
+
const viewport = visibleViewport();
|
|
725
|
+
const frameRect = frame.getBoundingClientRect();
|
|
726
|
+
const surface = $("compose");
|
|
727
|
+
if (state.compose.relation !== "unavailable") surface.hidden = false;
|
|
728
|
+
const placement = placeContextualSurface(state.compose, {
|
|
729
|
+
frameRect,
|
|
730
|
+
viewport,
|
|
731
|
+
surfaceWidth: Math.min(340, viewport.width - 24),
|
|
732
|
+
surfaceHeight: Math.max(190, surface.offsetHeight),
|
|
733
|
+
narrow: matchMedia("(max-width: 720px)").matches,
|
|
734
|
+
toolbarHeight: 48,
|
|
735
|
+
});
|
|
736
|
+
surface.classList.toggle("sheet", placement.kind === "sheet");
|
|
737
|
+
surface.classList.toggle("edge-top", placement.kind === "edge-top");
|
|
738
|
+
surface.classList.toggle("edge-bottom", placement.kind === "edge-bottom");
|
|
739
|
+
setComposePlacement(placement.kind);
|
|
740
|
+
const edge = placement.kind === "edge-top" || placement.kind === "edge-bottom";
|
|
741
|
+
$("composeDirection").hidden = !edge;
|
|
742
|
+
$("composeDirectionText").textContent = placement.kind === "edge-top"
|
|
743
|
+
? "Selection is above"
|
|
744
|
+
: placement.kind === "edge-bottom"
|
|
745
|
+
? "Selection is below"
|
|
746
|
+
: "";
|
|
747
|
+
surface.hidden = placement.kind === "hidden";
|
|
748
|
+
if (placement.kind !== "sheet" && placement.kind !== "hidden") {
|
|
749
|
+
surface.style.left = `${placement.left}px`;
|
|
750
|
+
surface.style.top = `${placement.top}px`;
|
|
751
|
+
surface.style.width = `${placement.width}px`;
|
|
752
|
+
} else {
|
|
753
|
+
surface.style.left = "";
|
|
754
|
+
surface.style.top = "";
|
|
755
|
+
surface.style.width = "";
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
function makeAction(label, className = "card-action") {
|
|
760
|
+
const button = document.createElement("button");
|
|
761
|
+
button.type = "button";
|
|
762
|
+
button.className = className;
|
|
763
|
+
button.textContent = label;
|
|
764
|
+
return button;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
function makeWho(comment, surface) {
|
|
768
|
+
const who = document.createElement("span");
|
|
769
|
+
who.className = "who";
|
|
770
|
+
who.append("You");
|
|
771
|
+
if (surface === "drawer") {
|
|
772
|
+
const sep = document.createElement("span");
|
|
773
|
+
sep.className = "sep";
|
|
774
|
+
sep.textContent = "·";
|
|
775
|
+
const when = document.createElement("span");
|
|
776
|
+
when.className = "when";
|
|
777
|
+
when.textContent = ago(comment.updatedAt || comment.createdAt);
|
|
778
|
+
who.append(sep, when);
|
|
779
|
+
}
|
|
780
|
+
if (comment.correction) {
|
|
781
|
+
const badge = document.createElement("span");
|
|
782
|
+
badge.className = "badge correction";
|
|
783
|
+
badge.textContent = "correction";
|
|
784
|
+
who.append(badge);
|
|
785
|
+
} else if (comment.updatedAt) {
|
|
786
|
+
const badge = document.createElement("span");
|
|
787
|
+
badge.className = "badge";
|
|
788
|
+
badge.textContent = "edited";
|
|
789
|
+
who.append(badge);
|
|
790
|
+
}
|
|
791
|
+
if (state.orphans.has(comment.id)) {
|
|
792
|
+
const badge = document.createElement("span");
|
|
793
|
+
badge.className = "badge";
|
|
794
|
+
badge.textContent = "orphaned";
|
|
795
|
+
who.append(badge);
|
|
796
|
+
}
|
|
797
|
+
return who;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
function commentUiKind(commentId) {
|
|
801
|
+
if (state.commentUi.edit?.commentId === commentId) return "edit";
|
|
802
|
+
if (state.commentUi.confirmation?.commentId === commentId) return "confirmation";
|
|
803
|
+
if (state.commentUi.menu?.commentId === commentId) return "menu";
|
|
804
|
+
return "normal";
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
function renderMore(comment, surface, open, disabled = false) {
|
|
808
|
+
const triggerId = controlId(comment.id, surface, "more");
|
|
809
|
+
const menuId = controlId(comment.id, surface, "menu");
|
|
810
|
+
const trigger = makeAction("More", "card-action more-trigger");
|
|
811
|
+
trigger.id = triggerId;
|
|
812
|
+
trigger.setAttribute("aria-haspopup", "menu");
|
|
813
|
+
trigger.setAttribute("aria-expanded", String(open));
|
|
814
|
+
trigger.setAttribute("aria-controls", menuId);
|
|
815
|
+
trigger.disabled = disabled;
|
|
816
|
+
trigger.prepend(createIcon("moreHorizontal", { size: 14 }));
|
|
817
|
+
trigger.addEventListener("click", (event) => {
|
|
818
|
+
event.stopPropagation();
|
|
819
|
+
if (open) {
|
|
820
|
+
closeCommentMenu({ restoreFocus: true });
|
|
821
|
+
} else {
|
|
822
|
+
if (!ownMenu(state.commentUi, comment.id, surface, triggerId)) {
|
|
823
|
+
focusCurrentCommentEdit();
|
|
824
|
+
return;
|
|
825
|
+
}
|
|
826
|
+
toFrame({ type: "eh:modeMenuState", open: true });
|
|
827
|
+
requestControlFocus(controlId(comment.id, surface, "delete-item"));
|
|
828
|
+
render();
|
|
829
|
+
}
|
|
830
|
+
});
|
|
831
|
+
return trigger;
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
function renderCommentCard(comment, surface) {
|
|
835
|
+
const card = document.createElement(surface === "aligned" ? "article" : "div");
|
|
836
|
+
if (surface === "drawer") {
|
|
837
|
+
card.className = `comment${state.activeSavedCommentId === comment.id ? " active" : ""}`;
|
|
838
|
+
}
|
|
839
|
+
card.dataset.id = comment.id;
|
|
840
|
+
card.dataset.surface = surface;
|
|
841
|
+
|
|
842
|
+
const kind = commentUiKind(comment.id);
|
|
843
|
+
const editOwnedElsewhere = !!(
|
|
844
|
+
state.commentUi.edit &&
|
|
845
|
+
state.commentUi.edit.commentId !== comment.id
|
|
846
|
+
);
|
|
847
|
+
const head = document.createElement("div");
|
|
848
|
+
head.className = "comment-head";
|
|
849
|
+
head.append(makeWho(comment, surface));
|
|
850
|
+
|
|
851
|
+
if (kind === "normal") {
|
|
852
|
+
if (surface === "drawer") {
|
|
853
|
+
const jump = makeAction("Jump to");
|
|
854
|
+
jump.disabled = editOwnedElsewhere;
|
|
855
|
+
jump.addEventListener("click", (event) => {
|
|
856
|
+
event.stopPropagation();
|
|
857
|
+
setActive(comment.id, true);
|
|
858
|
+
});
|
|
859
|
+
head.append(jump);
|
|
860
|
+
}
|
|
861
|
+
const edit = makeAction("Edit");
|
|
862
|
+
edit.id = controlId(comment.id, surface, "edit");
|
|
863
|
+
edit.setAttribute("aria-label", "Edit comment");
|
|
864
|
+
edit.disabled = editOwnedElsewhere;
|
|
865
|
+
edit.addEventListener("click", (event) => {
|
|
866
|
+
event.stopPropagation();
|
|
867
|
+
startCommentEdit(comment, surface);
|
|
868
|
+
});
|
|
869
|
+
head.append(edit);
|
|
870
|
+
if (surface === "aligned") {
|
|
871
|
+
const close = makeAction("Close");
|
|
872
|
+
close.setAttribute("aria-label", "Close comment card");
|
|
873
|
+
close.addEventListener("click", (event) => {
|
|
874
|
+
event.stopPropagation();
|
|
875
|
+
dismissActiveComment("close", { restoreFocus: true });
|
|
876
|
+
});
|
|
877
|
+
head.append(close);
|
|
878
|
+
}
|
|
879
|
+
head.append(renderMore(comment, surface, false, editOwnedElsewhere));
|
|
880
|
+
} else if (kind === "menu") {
|
|
881
|
+
head.append(renderMore(comment, surface, true));
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
const quote = document.createElement("p");
|
|
885
|
+
quote.className = "quote";
|
|
886
|
+
quote.textContent = tidyMiddle(comment.quote, 140);
|
|
887
|
+
card.append(head, quote);
|
|
888
|
+
|
|
889
|
+
if (kind === "edit") {
|
|
890
|
+
const edit = state.commentUi.edit;
|
|
891
|
+
const input = document.createElement("textarea");
|
|
892
|
+
input.className = "body-edit";
|
|
893
|
+
input.rows = 3;
|
|
894
|
+
input.value = edit.draft;
|
|
895
|
+
input.readOnly = edit.status === "saving";
|
|
896
|
+
input.dataset.commentEdit = comment.id;
|
|
897
|
+
input.setAttribute("aria-label", "Edit comment text");
|
|
898
|
+
input.addEventListener("input", () => {
|
|
899
|
+
edit.draft = input.value;
|
|
900
|
+
edit.selectionStart = input.selectionStart;
|
|
901
|
+
edit.selectionEnd = input.selectionEnd;
|
|
902
|
+
edit.validation = "";
|
|
903
|
+
});
|
|
904
|
+
const rememberSelection = () => {
|
|
905
|
+
edit.selectionStart = input.selectionStart;
|
|
906
|
+
edit.selectionEnd = input.selectionEnd;
|
|
907
|
+
};
|
|
908
|
+
input.addEventListener("select", rememberSelection);
|
|
909
|
+
input.addEventListener("keyup", rememberSelection);
|
|
910
|
+
input.addEventListener("click", (event) => {
|
|
911
|
+
event.stopPropagation();
|
|
912
|
+
rememberSelection();
|
|
913
|
+
});
|
|
914
|
+
input.addEventListener("compositionstart", () => {
|
|
915
|
+
edit.composing = true;
|
|
916
|
+
});
|
|
917
|
+
input.addEventListener("compositionend", () => {
|
|
918
|
+
edit.composing = false;
|
|
919
|
+
rememberSelection();
|
|
920
|
+
});
|
|
921
|
+
input.addEventListener("keydown", (event) => {
|
|
922
|
+
event.stopPropagation();
|
|
923
|
+
rememberSelection();
|
|
924
|
+
if (event.key === "Enter" && !event.shiftKey) {
|
|
925
|
+
if (edit.composing || event.isComposing) return;
|
|
926
|
+
event.preventDefault();
|
|
927
|
+
void saveCommentEdit();
|
|
928
|
+
} else if (event.key === "Escape") {
|
|
929
|
+
event.preventDefault();
|
|
930
|
+
cancelCommentEdit();
|
|
931
|
+
}
|
|
932
|
+
});
|
|
933
|
+
card.append(input);
|
|
934
|
+
|
|
935
|
+
const helper = document.createElement("p");
|
|
936
|
+
helper.className = "edit-help";
|
|
937
|
+
helper.textContent = "Enter to save · Shift+Enter for new line";
|
|
938
|
+
card.append(helper);
|
|
939
|
+
if (edit.validation) {
|
|
940
|
+
const validation = document.createElement("p");
|
|
941
|
+
validation.className = "comment-validation";
|
|
942
|
+
validation.setAttribute("role", "alert");
|
|
943
|
+
validation.textContent = edit.validation;
|
|
944
|
+
card.append(validation);
|
|
945
|
+
}
|
|
946
|
+
const actions = document.createElement("div");
|
|
947
|
+
actions.className = "comment-edit-actions";
|
|
948
|
+
const save = makeAction(edit.status === "saving" ? "Saving…" : "Save", "btn-primary");
|
|
949
|
+
save.disabled = edit.status === "saving";
|
|
950
|
+
save.addEventListener("click", (event) => {
|
|
951
|
+
event.stopPropagation();
|
|
952
|
+
void saveCommentEdit();
|
|
953
|
+
});
|
|
954
|
+
const cancel = makeAction("Cancel", "btn-ghost");
|
|
955
|
+
cancel.disabled = edit.status === "saving";
|
|
956
|
+
cancel.addEventListener("click", (event) => {
|
|
957
|
+
event.stopPropagation();
|
|
958
|
+
cancelCommentEdit();
|
|
959
|
+
});
|
|
960
|
+
actions.append(save, cancel);
|
|
961
|
+
card.append(actions);
|
|
962
|
+
} else if (kind === "confirmation") {
|
|
963
|
+
const confirmation = state.commentUi.confirmation;
|
|
964
|
+
const prompt = document.createElement("p");
|
|
965
|
+
prompt.className = "delete-prompt";
|
|
966
|
+
prompt.textContent = "Delete this comment?";
|
|
967
|
+
const actions = document.createElement("div");
|
|
968
|
+
actions.className = "delete-actions";
|
|
969
|
+
const cancel = makeAction("Cancel", "btn-ghost");
|
|
970
|
+
cancel.disabled = confirmation.status === "deleting";
|
|
971
|
+
cancel.addEventListener("click", (event) => {
|
|
972
|
+
event.stopPropagation();
|
|
973
|
+
cancelDeleteConfirmation();
|
|
974
|
+
});
|
|
975
|
+
const remove = makeAction(confirmation.status === "deleting" ? "Deleting…" : "Delete", "btn-danger");
|
|
976
|
+
remove.id = controlId(comment.id, surface, "confirm-delete");
|
|
977
|
+
remove.disabled = confirmation.status === "deleting";
|
|
978
|
+
remove.addEventListener("click", (event) => {
|
|
979
|
+
event.stopPropagation();
|
|
980
|
+
void deleteComment(comment.id);
|
|
981
|
+
});
|
|
982
|
+
actions.append(cancel, remove);
|
|
983
|
+
card.append(prompt, actions);
|
|
984
|
+
} else {
|
|
985
|
+
const body = document.createElement("p");
|
|
986
|
+
body.className = "body";
|
|
987
|
+
body.textContent = comment.feedback;
|
|
988
|
+
body.title = "Click to edit";
|
|
989
|
+
if (!editOwnedElsewhere) {
|
|
990
|
+
body.addEventListener("click", (event) => {
|
|
991
|
+
event.stopPropagation();
|
|
992
|
+
startCommentEdit(comment, surface);
|
|
993
|
+
});
|
|
994
|
+
}
|
|
995
|
+
card.append(body);
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
if (kind === "menu") {
|
|
999
|
+
const menu = document.createElement("div");
|
|
1000
|
+
menu.id = controlId(comment.id, surface, "menu");
|
|
1001
|
+
menu.className = "comment-menu";
|
|
1002
|
+
menu.setAttribute("role", "menu");
|
|
1003
|
+
menu.setAttribute("aria-labelledby", controlId(comment.id, surface, "more"));
|
|
1004
|
+
const remove = makeAction("Delete", "comment-menu-item");
|
|
1005
|
+
remove.id = controlId(comment.id, surface, "delete-item");
|
|
1006
|
+
remove.setAttribute("role", "menuitem");
|
|
1007
|
+
remove.prepend(createIcon("trash", { size: 14 }));
|
|
1008
|
+
remove.addEventListener("click", (event) => {
|
|
1009
|
+
event.stopPropagation();
|
|
1010
|
+
toFrame({ type: "eh:modeMenuState", open: false });
|
|
1011
|
+
if (!ownConfirmation(state.commentUi, comment.id, surface)) {
|
|
1012
|
+
focusCurrentCommentEdit();
|
|
1013
|
+
return;
|
|
1014
|
+
}
|
|
1015
|
+
requestControlFocus(controlId(comment.id, surface, "confirm-delete"));
|
|
1016
|
+
render();
|
|
1017
|
+
});
|
|
1018
|
+
menu.append(remove);
|
|
1019
|
+
card.append(menu);
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
if (surface === "drawer") {
|
|
1023
|
+
card.addEventListener("click", () => {
|
|
1024
|
+
if (kind === "normal" && !editOwnedElsewhere) setActive(comment.id, false);
|
|
1025
|
+
});
|
|
1026
|
+
}
|
|
1027
|
+
return card;
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
function positionAlignedCard() {
|
|
1031
|
+
const host = $("alignedCard");
|
|
1032
|
+
const comment = commentById(state.activeSavedCommentId);
|
|
1033
|
+
const geometry = comment ? state.activeGeometry.get(comment.id) : null;
|
|
1034
|
+
if (!comment || state.drawerOpen || matchMedia("(max-width: 720px)").matches || !geometry?.visible) {
|
|
1035
|
+
host.hidden = true;
|
|
1036
|
+
return false;
|
|
1037
|
+
}
|
|
1038
|
+
const viewport = visibleViewport();
|
|
1039
|
+
const position = alignedCardPosition(geometry.rects, {
|
|
1040
|
+
frameRect: frame.getBoundingClientRect(),
|
|
1041
|
+
viewport,
|
|
1042
|
+
});
|
|
1043
|
+
if (!position) {
|
|
1044
|
+
host.hidden = true;
|
|
1045
|
+
return false;
|
|
1046
|
+
}
|
|
1047
|
+
host.style.left = `${position.left}px`;
|
|
1048
|
+
host.style.top = `${position.top}px`;
|
|
1049
|
+
host.hidden = false;
|
|
1050
|
+
return true;
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
function renderAlignedCard(comments) {
|
|
1054
|
+
const host = $("alignedCard");
|
|
1055
|
+
host.textContent = "";
|
|
1056
|
+
const comment = comments.find((item) => item.id === state.activeSavedCommentId);
|
|
1057
|
+
if (!comment) {
|
|
1058
|
+
host.hidden = true;
|
|
1059
|
+
return;
|
|
1060
|
+
}
|
|
1061
|
+
const card = renderCommentCard(comment, "aligned");
|
|
1062
|
+
while (card.firstChild) host.append(card.firstChild);
|
|
1063
|
+
positionAlignedCard();
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
async function deleteComment(id) {
|
|
1067
|
+
if (deleteFlights.has(id)) return deleteFlights.get(id);
|
|
1068
|
+
const confirmation = state.commentUi.confirmation;
|
|
1069
|
+
if (!confirmation || confirmation.commentId !== id || confirmation.status === "deleting") return false;
|
|
1070
|
+
confirmation.status = "deleting";
|
|
1071
|
+
render();
|
|
1072
|
+
const startEpoch = state.pageEpoch;
|
|
1073
|
+
const flight = (async () => {
|
|
1074
|
+
try {
|
|
1075
|
+
const result = await api(`/api/page/${state.key}/comment/${id}`, { method: "DELETE" });
|
|
1076
|
+
if (!mutationIsCurrent(startEpoch, state.pageEpoch, state.page?.comments, id)) return false;
|
|
1077
|
+
reconcilePage(result.page, { reason: "mutation" });
|
|
1078
|
+
toFrame({ type: "eh:remove", id });
|
|
1079
|
+
state.activeGeometry.delete(id);
|
|
1080
|
+
if (state.activeSavedCommentId === id) state.activeSavedCommentId = null;
|
|
1081
|
+
render();
|
|
1082
|
+
return true;
|
|
1083
|
+
} catch (err) {
|
|
1084
|
+
if (mutationIsCurrent(startEpoch, state.pageEpoch, state.page?.comments, id)) {
|
|
1085
|
+
if (state.commentUi.confirmation?.commentId === id) state.commentUi.confirmation.status = "idle";
|
|
1086
|
+
toast(err.message);
|
|
1087
|
+
render();
|
|
1088
|
+
}
|
|
1089
|
+
return false;
|
|
1090
|
+
}
|
|
1091
|
+
})().finally(() => {
|
|
1092
|
+
if (deleteFlights.get(id) === flight) deleteFlights.delete(id);
|
|
1093
|
+
});
|
|
1094
|
+
deleteFlights.set(id, flight);
|
|
1095
|
+
return flight;
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
function cancelDeleteConfirmation() {
|
|
1099
|
+
const confirmation = state.commentUi.confirmation;
|
|
1100
|
+
if (!confirmation || confirmation.status === "deleting") return false;
|
|
1101
|
+
const triggerId = controlId(confirmation.commentId, confirmation.surface, "more");
|
|
1102
|
+
clearOwned(state.commentUi, "confirmation");
|
|
1103
|
+
requestControlFocus(triggerId);
|
|
1104
|
+
render();
|
|
1105
|
+
return true;
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
function startCommentEdit(comment, surface) {
|
|
1109
|
+
if (!ownEdit(state.commentUi, comment, surface)) {
|
|
1110
|
+
focusCurrentCommentEdit();
|
|
1111
|
+
return false;
|
|
1112
|
+
}
|
|
1113
|
+
editFocusRequested = true;
|
|
1114
|
+
render();
|
|
1115
|
+
return true;
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
function focusCurrentCommentEdit() {
|
|
1119
|
+
editFocusRequested = true;
|
|
1120
|
+
announce("Save or cancel the current comment edit first");
|
|
1121
|
+
requestAnimationFrame(() => {
|
|
1122
|
+
const edit = state.commentUi.edit;
|
|
1123
|
+
editTextarea(edit?.commentId)?.focus();
|
|
1124
|
+
});
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
function cancelCommentEdit() {
|
|
1128
|
+
const edit = state.commentUi.edit;
|
|
1129
|
+
if (!edit || edit.status === "saving") return false;
|
|
1130
|
+
const surface = state.drawerOpen ? "drawer" : edit.originSurface;
|
|
1131
|
+
const focusId = controlId(edit.commentId, surface, "edit");
|
|
1132
|
+
clearOwned(state.commentUi, "edit");
|
|
1133
|
+
requestControlFocus(focusId);
|
|
1134
|
+
render();
|
|
1135
|
+
return true;
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
function saveCommentEdit() {
|
|
1139
|
+
const edit = state.commentUi.edit;
|
|
1140
|
+
if (!edit || edit.status === "saving") return edit ? patchFlights.get(edit.commentId) : false;
|
|
1141
|
+
captureEditState();
|
|
1142
|
+
const feedback = edit.draft.trim();
|
|
1143
|
+
if (!feedback) {
|
|
1144
|
+
edit.validation = "Comment text is required.";
|
|
1145
|
+
editFocusRequested = true;
|
|
1146
|
+
render();
|
|
1147
|
+
return false;
|
|
1148
|
+
}
|
|
1149
|
+
if (feedback === edit.original) {
|
|
1150
|
+
clearOwned(state.commentUi, "edit");
|
|
1151
|
+
requestControlFocus(controlId(edit.commentId, state.drawerOpen ? "drawer" : edit.originSurface, "edit"));
|
|
1152
|
+
render();
|
|
1153
|
+
return true;
|
|
1154
|
+
}
|
|
1155
|
+
if (patchFlights.has(edit.commentId)) return patchFlights.get(edit.commentId);
|
|
1156
|
+
edit.status = "saving";
|
|
1157
|
+
edit.validation = "";
|
|
1158
|
+
const id = edit.commentId;
|
|
1159
|
+
const startEpoch = state.pageEpoch;
|
|
1160
|
+
editFocusRequested = true;
|
|
1161
|
+
render();
|
|
1162
|
+
const flight = executeCommentPatch({ ...edit, draft: feedback }, startEpoch).finally(() => {
|
|
1163
|
+
if (patchFlights.get(id) === flight) patchFlights.delete(id);
|
|
1164
|
+
});
|
|
1165
|
+
patchFlights.set(id, flight);
|
|
1166
|
+
return flight;
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
async function executeCommentPatch(edit, startEpoch) {
|
|
1170
|
+
const id = edit.commentId;
|
|
1171
|
+
try {
|
|
1172
|
+
const commentIndex = state.page.comments.findIndex((item) => item.id === id);
|
|
1173
|
+
const previousGeometry = state.activeGeometry.get(id);
|
|
1174
|
+
const wasOrphaned = state.orphans.has(id);
|
|
1175
|
+
const remainsActive = state.activeSavedCommentId === id;
|
|
1176
|
+
const result = await api(`/api/page/${state.key}/comment/${id}`, {
|
|
1177
|
+
method: "PATCH",
|
|
1178
|
+
body: JSON.stringify({ feedback: edit.draft }),
|
|
1179
|
+
});
|
|
1180
|
+
if (!mutationIsCurrent(startEpoch, state.pageEpoch, state.page?.comments, id)) return false;
|
|
1181
|
+
const replacement = commentIndex >= 0 ? result.page.comments[commentIndex] : null;
|
|
1182
|
+
if (replacement && replacement.id !== id) {
|
|
1183
|
+
migrateCommentUi(state.commentUi, id, replacement.id);
|
|
1184
|
+
if (remainsActive && previousGeometry) state.activeGeometry.set(replacement.id, previousGeometry);
|
|
1185
|
+
if (remainsActive) state.activeSavedCommentId = replacement.id;
|
|
1186
|
+
if (wasOrphaned) state.orphans.add(replacement.id);
|
|
1187
|
+
}
|
|
1188
|
+
reconcilePage(result.page, { reason: "mutation" });
|
|
1189
|
+
clearOwned(state.commentUi, "edit");
|
|
1190
|
+
if (result.delivery === "updated-pending") {
|
|
1191
|
+
toast("Updated the feedback waiting for your agent");
|
|
1192
|
+
} else if (result.delivery === "correction") {
|
|
1193
|
+
state.sent = false;
|
|
1194
|
+
toFrame({ type: "eh:remove", id });
|
|
1195
|
+
toFrame({ type: "eh:anchors", comments: state.page.comments });
|
|
1196
|
+
if (remainsActive && replacement) toFrame({ type: "eh:activate", id: replacement.id, scroll: false });
|
|
1197
|
+
toast("Saved as a correction — send it after the current batch is acknowledged");
|
|
1198
|
+
} else {
|
|
1199
|
+
state.sent = false;
|
|
1200
|
+
}
|
|
1201
|
+
render();
|
|
1202
|
+
return true;
|
|
1203
|
+
} catch (err) {
|
|
1204
|
+
if (mutationIsCurrent(startEpoch, state.pageEpoch, state.page?.comments, id)) {
|
|
1205
|
+
const current = state.commentUi.edit;
|
|
1206
|
+
if (current?.commentId === id) {
|
|
1207
|
+
current.status = "idle";
|
|
1208
|
+
current.validation = "";
|
|
1209
|
+
editFocusRequested = true;
|
|
1210
|
+
}
|
|
1211
|
+
toast(err.message);
|
|
1212
|
+
render();
|
|
1213
|
+
}
|
|
1214
|
+
return false;
|
|
1215
|
+
}
|
|
429
1216
|
}
|
|
430
1217
|
|
|
431
1218
|
function renderSave() {
|
|
@@ -454,62 +1241,6 @@ function renderSave() {
|
|
|
454
1241
|
else $("saveText").textContent = state.savedAt ? `Saved to ${name} · ${state.savedAt}` : `Saved to ${name}`;
|
|
455
1242
|
}
|
|
456
1243
|
|
|
457
|
-
/** Swap a comment's text for a textarea until the new wording is committed. */
|
|
458
|
-
function editComment(card, body, comment) {
|
|
459
|
-
if (card.querySelector("textarea")) return;
|
|
460
|
-
const input = document.createElement("textarea");
|
|
461
|
-
input.className = "body-edit";
|
|
462
|
-
input.rows = 3;
|
|
463
|
-
input.value = comment.feedback;
|
|
464
|
-
let done = false;
|
|
465
|
-
const finish = () => {
|
|
466
|
-
done = true;
|
|
467
|
-
render();
|
|
468
|
-
};
|
|
469
|
-
const commit = async () => {
|
|
470
|
-
if (done) return;
|
|
471
|
-
done = true;
|
|
472
|
-
const feedback = input.value.trim();
|
|
473
|
-
if (!feedback || feedback === comment.feedback) return render();
|
|
474
|
-
try {
|
|
475
|
-
const result = await api(`/api/page/${state.key}/comment/${comment.id}`, {
|
|
476
|
-
method: "PATCH",
|
|
477
|
-
body: JSON.stringify({ feedback }),
|
|
478
|
-
});
|
|
479
|
-
state.page = result.page;
|
|
480
|
-
if (result.delivery === "updated-pending") {
|
|
481
|
-
toast("Updated the feedback waiting for your agent");
|
|
482
|
-
} else if (result.delivery === "correction") {
|
|
483
|
-
state.sent = false;
|
|
484
|
-
toFrame({ type: "eh:remove", id: comment.id });
|
|
485
|
-
toFrame({ type: "eh:anchors", comments: state.page.comments });
|
|
486
|
-
toast("Saved as a correction — send it after the current batch is acknowledged");
|
|
487
|
-
} else {
|
|
488
|
-
state.sent = false;
|
|
489
|
-
}
|
|
490
|
-
} catch (err) {
|
|
491
|
-
toast(err.message);
|
|
492
|
-
}
|
|
493
|
-
render();
|
|
494
|
-
};
|
|
495
|
-
input.addEventListener("keydown", (event) => {
|
|
496
|
-
event.stopPropagation();
|
|
497
|
-
if (event.key === "Enter" && !event.shiftKey) {
|
|
498
|
-
event.preventDefault();
|
|
499
|
-
commit();
|
|
500
|
-
}
|
|
501
|
-
if (event.key === "Escape") {
|
|
502
|
-
event.preventDefault();
|
|
503
|
-
finish();
|
|
504
|
-
}
|
|
505
|
-
});
|
|
506
|
-
input.addEventListener("blur", commit);
|
|
507
|
-
input.addEventListener("click", (event) => event.stopPropagation());
|
|
508
|
-
body.replaceWith(input);
|
|
509
|
-
input.focus();
|
|
510
|
-
input.setSelectionRange(input.value.length, input.value.length);
|
|
511
|
-
}
|
|
512
|
-
|
|
513
1244
|
function toast(message) {
|
|
514
1245
|
const el = document.createElement("div");
|
|
515
1246
|
el.className = "toast";
|
|
@@ -519,64 +1250,150 @@ function toast(message) {
|
|
|
519
1250
|
}
|
|
520
1251
|
|
|
521
1252
|
function setActive(id, scroll) {
|
|
522
|
-
state.
|
|
1253
|
+
state.activeSavedCommentId = id;
|
|
523
1254
|
toFrame({ type: "eh:activate", id, scroll: !!scroll });
|
|
524
1255
|
render();
|
|
525
1256
|
}
|
|
526
1257
|
|
|
1258
|
+
function dismissActiveComment(reason, { restoreFocus = false } = {}) {
|
|
1259
|
+
const id = state.activeSavedCommentId;
|
|
1260
|
+
if (!id) return false;
|
|
1261
|
+
state.activeSavedCommentId = null;
|
|
1262
|
+
state.activeGeometry.delete(id);
|
|
1263
|
+
toFrame({ type: "eh:deactivateComment" });
|
|
1264
|
+
render();
|
|
1265
|
+
announce("Comment card closed");
|
|
1266
|
+
diagnostic("saved-card-deactivated", { reason });
|
|
1267
|
+
if (restoreFocus) frame.focus();
|
|
1268
|
+
return true;
|
|
1269
|
+
}
|
|
1270
|
+
|
|
527
1271
|
// ------------------------------------------------------------------ compose
|
|
528
1272
|
|
|
529
|
-
/**
|
|
530
|
-
* The card deliberately does not steal focus. The selection stays live in the
|
|
531
|
-
* document so you can type over it or delete it; click the card when you want
|
|
532
|
-
* to comment on it instead.
|
|
533
|
-
*/
|
|
534
1273
|
async function openCompose(detail) {
|
|
535
|
-
if (state.compose &&
|
|
1274
|
+
if (state.compose && state.compose.generation === detail.generation) {
|
|
1275
|
+
if (state.composeLifecycle === "closed") setComposeLifecycle("open", "accepted");
|
|
1276
|
+
$("composeText").focus();
|
|
1277
|
+
return true;
|
|
1278
|
+
}
|
|
1279
|
+
if (state.compose && $("composeText").value.trim()) {
|
|
1280
|
+
const submitted = await commitCompose();
|
|
1281
|
+
if (!submitted) {
|
|
1282
|
+
diagnostic("comment-retarget-blocked", { reason: "submit-failed" });
|
|
1283
|
+
return false;
|
|
1284
|
+
}
|
|
1285
|
+
} else if (state.compose) {
|
|
1286
|
+
cancelCompose({ restoreFocus: false, preserveRetarget: true });
|
|
1287
|
+
}
|
|
1288
|
+
dismissActiveComment("composer-open");
|
|
536
1289
|
state.compose = detail;
|
|
537
|
-
|
|
538
|
-
toFrame({ type: "eh:composeOpen" });
|
|
1290
|
+
setComposeLifecycle("open", "accepted");
|
|
539
1291
|
$("composeText").value = "";
|
|
1292
|
+
$("composeError").hidden = true;
|
|
1293
|
+
$("composeAddLabel").textContent = "Comment";
|
|
1294
|
+
render();
|
|
1295
|
+
requestAnimationFrame(() => {
|
|
1296
|
+
positionCompose();
|
|
1297
|
+
$("composeText").focus();
|
|
1298
|
+
});
|
|
1299
|
+
announce(`Comment dialog opened for ${detail.kind === "element" ? "element" : "selected text"}`);
|
|
1300
|
+
return true;
|
|
540
1301
|
}
|
|
541
1302
|
|
|
542
|
-
function cancelCompose() {
|
|
543
|
-
if (!state.compose) return;
|
|
1303
|
+
function cancelCompose({ restoreFocus = true, preserveRetarget = false } = {}) {
|
|
1304
|
+
if (!state.compose || state.composeLifecycle === "submitting") return false;
|
|
1305
|
+
const generation = state.compose.generation;
|
|
1306
|
+
const discardThroughGeneration = Math.max(
|
|
1307
|
+
generation,
|
|
1308
|
+
Number(state.compose.rejectedTargetGeneration) || generation
|
|
1309
|
+
);
|
|
544
1310
|
state.compose = null;
|
|
545
|
-
|
|
1311
|
+
setComposeLifecycle("closed", "cancel");
|
|
1312
|
+
setComposePlacement("hidden");
|
|
1313
|
+
toFrame({
|
|
1314
|
+
type: "eh:cancel",
|
|
1315
|
+
targetGeneration: generation,
|
|
1316
|
+
discardThroughGeneration,
|
|
1317
|
+
restoreFocus,
|
|
1318
|
+
preserveRetarget,
|
|
1319
|
+
});
|
|
546
1320
|
render();
|
|
1321
|
+
return true;
|
|
547
1322
|
}
|
|
548
1323
|
|
|
549
|
-
|
|
1324
|
+
let composeSubmitPromise = null;
|
|
1325
|
+
function commitCompose() {
|
|
1326
|
+
if (composeSubmitPromise) {
|
|
1327
|
+
diagnostic("comment-submit-suppressed", { reason: "in-flight" });
|
|
1328
|
+
return composeSubmitPromise;
|
|
1329
|
+
}
|
|
1330
|
+
composeSubmitPromise = executeComposeSubmit().finally(() => {
|
|
1331
|
+
composeSubmitPromise = null;
|
|
1332
|
+
});
|
|
1333
|
+
return composeSubmitPromise;
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
async function executeComposeSubmit() {
|
|
550
1337
|
const compose = state.compose;
|
|
551
1338
|
const feedback = $("composeText").value.trim();
|
|
552
|
-
if (!compose || !feedback) return;
|
|
553
|
-
const
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
1339
|
+
if (!compose || !feedback) return false;
|
|
1340
|
+
const button = $("composeAdd");
|
|
1341
|
+
button.disabled = true;
|
|
1342
|
+
setComposeLifecycle("submitting", "submit");
|
|
1343
|
+
diagnostic("comment-submit-executed");
|
|
1344
|
+
$("composeError").hidden = true;
|
|
1345
|
+
try {
|
|
1346
|
+
const result = await api(`/api/page/${state.key}/comment`, {
|
|
1347
|
+
method: "POST",
|
|
1348
|
+
body: JSON.stringify({ kind: compose.kind, quote: compose.quote, anchor: compose.anchor, feedback }),
|
|
1349
|
+
});
|
|
1350
|
+
toFrame({
|
|
1351
|
+
type: "eh:commit",
|
|
1352
|
+
id: result.comment.id,
|
|
1353
|
+
targetGeneration: compose.generation,
|
|
1354
|
+
restoreFocus: true,
|
|
1355
|
+
});
|
|
1356
|
+
state.compose = null;
|
|
1357
|
+
setComposeLifecycle("closed", "submitted");
|
|
1358
|
+
setComposePlacement("hidden");
|
|
1359
|
+
state.page = result.page;
|
|
1360
|
+
state.sent = false;
|
|
1361
|
+
render();
|
|
1362
|
+
announce("Comment added");
|
|
1363
|
+
return true;
|
|
1364
|
+
} catch (err) {
|
|
1365
|
+
$("composeError").textContent = `${err.message}. Retry or cancel.`;
|
|
1366
|
+
$("composeError").hidden = false;
|
|
1367
|
+
$("composeAddLabel").textContent = "Retry";
|
|
1368
|
+
diagnostic("comment-request-failure", { status: err.status || 0 });
|
|
1369
|
+
announce("Comment could not be saved. Your draft is still here.");
|
|
1370
|
+
setComposeLifecycle("open", "submit-failed");
|
|
1371
|
+
$("compose").classList.remove("pass-through");
|
|
1372
|
+
requestAnimationFrame(() => $("composeText").focus());
|
|
1373
|
+
return false;
|
|
1374
|
+
} finally {
|
|
1375
|
+
button.disabled = false;
|
|
1376
|
+
}
|
|
563
1377
|
}
|
|
564
1378
|
|
|
565
1379
|
// -------------------------------------------------------------------- saving
|
|
566
1380
|
|
|
567
1381
|
let retryTimer = null;
|
|
568
1382
|
let saveAttempts = 0;
|
|
1383
|
+
let activeSavePromise = Promise.resolve(true);
|
|
569
1384
|
|
|
570
1385
|
/** A fresh serialization from the SDK always starts a fresh attempt budget. */
|
|
571
1386
|
function saveNow(html) {
|
|
572
1387
|
saveAttempts = 0;
|
|
573
|
-
|
|
1388
|
+
state.saveConflict = false;
|
|
1389
|
+
activeSavePromise = saveHtml(html, state.key);
|
|
1390
|
+
return activeSavePromise;
|
|
574
1391
|
}
|
|
575
1392
|
|
|
576
1393
|
async function saveHtml(html, key) {
|
|
577
1394
|
clearTimeout(retryTimer);
|
|
578
1395
|
// A retry that outlived a page switch must never write into the new page.
|
|
579
|
-
if (key !== state.key) return;
|
|
1396
|
+
if (key !== state.key) return false;
|
|
580
1397
|
if (!state.baseHash) {
|
|
581
1398
|
// The on-disk baseline hasn't arrived yet; wait for it rather than write blind.
|
|
582
1399
|
saveAttempts += 1;
|
|
@@ -585,7 +1402,7 @@ async function saveHtml(html, key) {
|
|
|
585
1402
|
state.save = "failed";
|
|
586
1403
|
renderSave();
|
|
587
1404
|
}
|
|
588
|
-
return;
|
|
1405
|
+
return false;
|
|
589
1406
|
}
|
|
590
1407
|
try {
|
|
591
1408
|
const result = await api(`/api/page/${key}/save`, { method: "POST", body: JSON.stringify({ html, baseHash: state.baseHash }) });
|
|
@@ -593,26 +1410,56 @@ async function saveHtml(html, key) {
|
|
|
593
1410
|
state.save = "saved";
|
|
594
1411
|
state.savedAt = clock();
|
|
595
1412
|
saveAttempts = 0;
|
|
1413
|
+
renderSave();
|
|
1414
|
+
return true;
|
|
596
1415
|
} catch (err) {
|
|
597
1416
|
if (err.status === 409) {
|
|
598
1417
|
// Someone else — usually the agent — wrote the file first. Their version
|
|
599
1418
|
// arrives via the reload event; this save is abandoned, not retried.
|
|
600
1419
|
state.baseHash = null;
|
|
601
1420
|
state.save = "idle";
|
|
1421
|
+
state.saveConflict = true;
|
|
602
1422
|
saveAttempts = 0;
|
|
603
1423
|
renderSave();
|
|
604
|
-
|
|
1424
|
+
announce("Save conflict. Edit mode remains active so you can reload safely.");
|
|
1425
|
+
diagnostic("save-conflict");
|
|
1426
|
+
return false;
|
|
605
1427
|
}
|
|
606
1428
|
saveAttempts += 1;
|
|
607
1429
|
state.save = "failed";
|
|
608
1430
|
if (saveAttempts < 5) retryTimer = setTimeout(() => saveHtml(html, key), 2000);
|
|
609
1431
|
else toast("Couldn't save — your edits still reach the agent as feedback");
|
|
1432
|
+
return false;
|
|
610
1433
|
}
|
|
611
|
-
renderSave();
|
|
612
1434
|
}
|
|
613
1435
|
|
|
614
1436
|
// ------------------------------------------------------------ frame messages
|
|
615
1437
|
|
|
1438
|
+
function targetPayload(msg) {
|
|
1439
|
+
const viewport = {
|
|
1440
|
+
width: Number(msg.viewport?.width),
|
|
1441
|
+
height: Number(msg.viewport?.height),
|
|
1442
|
+
};
|
|
1443
|
+
const generation = Number(msg.targetGeneration);
|
|
1444
|
+
const rawRelation = String(msg.relation || "");
|
|
1445
|
+
if (!["visible", "above", "below", "unavailable"].includes(rawRelation)) return null;
|
|
1446
|
+
const relation = sanitizeRelation(rawRelation);
|
|
1447
|
+
const clip = sanitizeClipRect(msg.clip, viewport);
|
|
1448
|
+
const rects = sanitizeClientRects(msg.rects, viewport);
|
|
1449
|
+
if (!Number.isSafeInteger(generation) || generation < 1 || !clip) return null;
|
|
1450
|
+
if (relation === "visible" && !rects.length) return null;
|
|
1451
|
+
return {
|
|
1452
|
+
kind: msg.kind === "element" ? "element" : "selection",
|
|
1453
|
+
quote: String(msg.quote || ""),
|
|
1454
|
+
anchor: msg.anchor || null,
|
|
1455
|
+
rects,
|
|
1456
|
+
generation,
|
|
1457
|
+
relation,
|
|
1458
|
+
clip,
|
|
1459
|
+
horizontal: Number.isFinite(msg.horizontal) ? Number(msg.horizontal) : null,
|
|
1460
|
+
};
|
|
1461
|
+
}
|
|
1462
|
+
|
|
616
1463
|
window.addEventListener("message", async (event) => {
|
|
617
1464
|
if (!frame.contentWindow || event.source !== frame.contentWindow) return;
|
|
618
1465
|
if (!state.framePolicy || event.origin !== state.framePolicy.incomingOrigin) return;
|
|
@@ -633,6 +1480,7 @@ window.addEventListener("message", async (event) => {
|
|
|
633
1480
|
state.frameReadyRetries = 0;
|
|
634
1481
|
state.readyGeneration = state.renderGeneration;
|
|
635
1482
|
state.frameLoading = false;
|
|
1483
|
+
frame.dataset.sdkReady = "true";
|
|
636
1484
|
const readyCapability = state.frameCapability;
|
|
637
1485
|
void api(`/api/session/${state.sessionId}/render/${state.renderId}/ready`, {
|
|
638
1486
|
method: "POST",
|
|
@@ -647,10 +1495,10 @@ window.addEventListener("message", async (event) => {
|
|
|
647
1495
|
toFrame({ type: "eh:restoreScroll", x: state.scroll.x, y: state.scroll.y });
|
|
648
1496
|
state.reloading = false;
|
|
649
1497
|
}
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
1498
|
+
const configuration = reviewConfiguration(state.page, state.reviewMode);
|
|
1499
|
+
state.savePolicy = configuration.savePolicy;
|
|
1500
|
+
toFrame({ type: "eh:configureReview", ...configuration });
|
|
1501
|
+
if (configuration.savePolicy === "writable") {
|
|
654
1502
|
// Hand the SDK the on-disk HTML so it can spot self-rendering pages.
|
|
655
1503
|
api(`/api/page/${state.key}/raw`)
|
|
656
1504
|
.then((raw) => {
|
|
@@ -661,8 +1509,90 @@ window.addEventListener("message", async (event) => {
|
|
|
661
1509
|
}
|
|
662
1510
|
break;
|
|
663
1511
|
}
|
|
664
|
-
case "eh:
|
|
665
|
-
|
|
1512
|
+
case "eh:configurationApplied":
|
|
1513
|
+
state.configurationGeneration = state.renderGeneration;
|
|
1514
|
+
if (
|
|
1515
|
+
configurationWaiter &&
|
|
1516
|
+
msg.mode === configurationWaiter.mode &&
|
|
1517
|
+
msg.savePolicy === configurationWaiter.savePolicy
|
|
1518
|
+
) configurationWaiter.settle(true);
|
|
1519
|
+
announce(`${state.reviewMode === "edit" ? "Edit" : "View"} mode active`);
|
|
1520
|
+
break;
|
|
1521
|
+
case "eh:target": {
|
|
1522
|
+
const detail = targetPayload(msg);
|
|
1523
|
+
if (!detail) return;
|
|
1524
|
+
if (state.target && detail.generation < state.target.generation) return;
|
|
1525
|
+
state.target = detail;
|
|
1526
|
+
break;
|
|
1527
|
+
}
|
|
1528
|
+
case "eh:openComment": {
|
|
1529
|
+
const requestedGeneration = Number(msg.targetGeneration);
|
|
1530
|
+
const detail = targetPayload(msg);
|
|
1531
|
+
let accepted = false;
|
|
1532
|
+
if (detail && (!state.target || detail.generation >= state.target.generation)) {
|
|
1533
|
+
accepted = await openCompose(detail);
|
|
1534
|
+
}
|
|
1535
|
+
if (!accepted && state.compose) {
|
|
1536
|
+
state.compose.rejectedTargetGeneration = Math.max(
|
|
1537
|
+
Number(state.compose.rejectedTargetGeneration) || 0,
|
|
1538
|
+
requestedGeneration
|
|
1539
|
+
);
|
|
1540
|
+
}
|
|
1541
|
+
toFrame({
|
|
1542
|
+
type: "eh:commentOpenResult",
|
|
1543
|
+
accepted,
|
|
1544
|
+
requestedGeneration,
|
|
1545
|
+
targetGeneration: state.compose?.generation || null,
|
|
1546
|
+
});
|
|
1547
|
+
break;
|
|
1548
|
+
}
|
|
1549
|
+
case "eh:targetGeometry":
|
|
1550
|
+
if (state.compose && msg.targetGeneration !== state.compose.generation) {
|
|
1551
|
+
diagnostic("geometry-rejected", { reason: "stale" });
|
|
1552
|
+
break;
|
|
1553
|
+
}
|
|
1554
|
+
if (state.compose) {
|
|
1555
|
+
const geometry = targetPayload({ ...msg, kind: state.compose.kind });
|
|
1556
|
+
if (!geometry) {
|
|
1557
|
+
diagnostic("geometry-rejected", { reason: "invalid" });
|
|
1558
|
+
break;
|
|
1559
|
+
}
|
|
1560
|
+
state.compose.rects = geometry.rects;
|
|
1561
|
+
state.compose.relation = geometry.relation;
|
|
1562
|
+
state.compose.clip = geometry.clip;
|
|
1563
|
+
state.compose.horizontal = geometry.horizontal ?? state.compose.horizontal;
|
|
1564
|
+
positionCompose();
|
|
1565
|
+
}
|
|
1566
|
+
break;
|
|
1567
|
+
case "eh:commentGeometry": {
|
|
1568
|
+
if (!msg.id) break;
|
|
1569
|
+
const id = String(msg.id);
|
|
1570
|
+
if (!state.page?.comments?.some((comment) => comment.id === id)) {
|
|
1571
|
+
state.activeGeometry.delete(id);
|
|
1572
|
+
break;
|
|
1573
|
+
}
|
|
1574
|
+
const rects = sanitizeClientRects(msg.rects, {
|
|
1575
|
+
width: Number(msg.viewport?.width),
|
|
1576
|
+
height: Number(msg.viewport?.height),
|
|
1577
|
+
});
|
|
1578
|
+
state.activeGeometry.set(id, {
|
|
1579
|
+
rects,
|
|
1580
|
+
visible: msg.visible !== false && rects.length > 0,
|
|
1581
|
+
});
|
|
1582
|
+
if (state.activeSavedCommentId === id) {
|
|
1583
|
+
positionAlignedCard();
|
|
1584
|
+
restoreTransientFocus(false);
|
|
1585
|
+
}
|
|
1586
|
+
break;
|
|
1587
|
+
}
|
|
1588
|
+
case "eh:revealTargetResult":
|
|
1589
|
+
if (!state.compose || msg.targetGeneration !== state.compose.generation) break;
|
|
1590
|
+
announce(msg.success ? "Selection revealed" : "Selection is no longer available");
|
|
1591
|
+
diagnostic(msg.success ? "reveal-target-succeeded" : "reveal-target-failed");
|
|
1592
|
+
break;
|
|
1593
|
+
case "eh:interaction":
|
|
1594
|
+
closeModeMenu("frame-interaction");
|
|
1595
|
+
closeCommentMenu();
|
|
666
1596
|
break;
|
|
667
1597
|
case "eh:dismiss":
|
|
668
1598
|
if (!$("composeText").value.trim()) cancelCompose();
|
|
@@ -677,23 +1607,21 @@ window.addEventListener("message", async (event) => {
|
|
|
677
1607
|
case "eh:notInView":
|
|
678
1608
|
toast("That comment is not visible in this view");
|
|
679
1609
|
break;
|
|
1610
|
+
case "eh:formBlocked":
|
|
1611
|
+
toast(String(msg.reason || "This form cannot be submitted from View mode"));
|
|
1612
|
+
break;
|
|
680
1613
|
case "eh:edit":
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
staged_assets: msg.staged_assets,
|
|
693
|
-
}),
|
|
694
|
-
})).page;
|
|
695
|
-
state.sent = false;
|
|
696
|
-
render();
|
|
1614
|
+
void persistEdit(state.key, {
|
|
1615
|
+
label: msg.label,
|
|
1616
|
+
kind: msg.kind,
|
|
1617
|
+
before: msg.before,
|
|
1618
|
+
after: msg.after,
|
|
1619
|
+
before_html: msg.before_html,
|
|
1620
|
+
after_html: msg.after_html,
|
|
1621
|
+
moved_after: msg.moved_after,
|
|
1622
|
+
moved_before: msg.moved_before,
|
|
1623
|
+
staged_assets: msg.staged_assets,
|
|
1624
|
+
});
|
|
697
1625
|
break;
|
|
698
1626
|
case "eh:asset":
|
|
699
1627
|
try {
|
|
@@ -742,6 +1670,7 @@ window.addEventListener("message", async (event) => {
|
|
|
742
1670
|
}
|
|
743
1671
|
case "eh:navigate":
|
|
744
1672
|
try {
|
|
1673
|
+
await flushFrame();
|
|
745
1674
|
const result = await api(`/api/session/${state.sessionId}/navigate`, {
|
|
746
1675
|
method: "POST",
|
|
747
1676
|
body: JSON.stringify({ href: msg.href }),
|
|
@@ -757,31 +1686,100 @@ window.addEventListener("message", async (event) => {
|
|
|
757
1686
|
}
|
|
758
1687
|
});
|
|
759
1688
|
|
|
760
|
-
//
|
|
1689
|
+
// --------------------------------------------------------------- UI wiring
|
|
1690
|
+
|
|
1691
|
+
let composeComposing = false;
|
|
761
1692
|
|
|
762
1693
|
$("composeAdd").addEventListener("click", commitCompose);
|
|
763
1694
|
$("composeCancel").addEventListener("click", cancelCompose);
|
|
1695
|
+
$("composeClose").addEventListener("click", cancelCompose);
|
|
1696
|
+
$("composeReveal").addEventListener("click", () => {
|
|
1697
|
+
if (!state.compose) return;
|
|
1698
|
+
diagnostic("reveal-target-requested");
|
|
1699
|
+
toFrame({ type: "eh:revealTarget", targetGeneration: state.compose.generation });
|
|
1700
|
+
});
|
|
764
1701
|
|
|
765
|
-
// Clicking anywhere on the card is the "I meant to comment" gesture.
|
|
766
1702
|
$("compose").addEventListener("mousedown", (event) => {
|
|
767
1703
|
if (event.target.closest("button")) return;
|
|
768
|
-
event.
|
|
769
|
-
$("composeText").focus();
|
|
1704
|
+
if (event.target !== $("composeText")) $("composeText").focus();
|
|
770
1705
|
});
|
|
771
1706
|
|
|
1707
|
+
$("composeText").addEventListener("compositionstart", () => {
|
|
1708
|
+
composeComposing = true;
|
|
1709
|
+
});
|
|
1710
|
+
$("composeText").addEventListener("compositionend", () => {
|
|
1711
|
+
composeComposing = false;
|
|
1712
|
+
});
|
|
772
1713
|
$("composeText").addEventListener("keydown", (event) => {
|
|
773
|
-
if (event.key === "Enter"
|
|
1714
|
+
if (event.key === "Enter") {
|
|
1715
|
+
event.stopPropagation();
|
|
1716
|
+
if (composeComposing || event.isComposing || event.shiftKey) return;
|
|
774
1717
|
event.preventDefault();
|
|
775
|
-
commitCompose();
|
|
1718
|
+
void commitCompose();
|
|
776
1719
|
}
|
|
777
1720
|
if (event.key === "Escape") {
|
|
1721
|
+
event.stopPropagation();
|
|
778
1722
|
event.preventDefault();
|
|
779
1723
|
cancelCompose();
|
|
780
1724
|
}
|
|
781
1725
|
});
|
|
782
1726
|
|
|
1727
|
+
$("modeButton").addEventListener("click", () => {
|
|
1728
|
+
if ($("modeMenu").hidden) openModeMenu();
|
|
1729
|
+
else closeModeMenu("trigger", { restoreFocus: true });
|
|
1730
|
+
});
|
|
1731
|
+
$("modeMenu").addEventListener("click", (event) => {
|
|
1732
|
+
const item = event.target.closest("[data-mode]");
|
|
1733
|
+
if (item) void setReviewMode(item.dataset.mode);
|
|
1734
|
+
});
|
|
1735
|
+
$("modeMenu").addEventListener("keydown", (event) => {
|
|
1736
|
+
const items = [...$("modeMenu").querySelectorAll("[data-mode]")];
|
|
1737
|
+
const current = items.indexOf(document.activeElement);
|
|
1738
|
+
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
1739
|
+
event.preventDefault();
|
|
1740
|
+
const delta = event.key === "ArrowDown" ? 1 : -1;
|
|
1741
|
+
items[(current + delta + items.length) % items.length].focus();
|
|
1742
|
+
} else if (event.key === "Escape") {
|
|
1743
|
+
event.preventDefault();
|
|
1744
|
+
event.stopPropagation();
|
|
1745
|
+
closeModeMenu("escape", { restoreFocus: true });
|
|
1746
|
+
}
|
|
1747
|
+
});
|
|
1748
|
+
$("commentsButton").addEventListener("click", openDrawer);
|
|
1749
|
+
$("drawerClose").addEventListener("click", closeDrawer);
|
|
1750
|
+
$("drawerBackdrop").addEventListener("click", closeDrawer);
|
|
1751
|
+
|
|
1752
|
+
const dismissModeMenuOutside = (event) => {
|
|
1753
|
+
if (!state.modeMenuOpen || event.target.closest(".mode-control")) return;
|
|
1754
|
+
closeModeMenu(event.type === "focusin" ? "parent-focus" : "parent-pointer");
|
|
1755
|
+
};
|
|
1756
|
+
document.addEventListener("pointerdown", dismissModeMenuOutside, true);
|
|
1757
|
+
document.addEventListener("focusin", dismissModeMenuOutside, true);
|
|
1758
|
+
|
|
1759
|
+
function closeCommentMenu({ restoreFocus = false } = {}) {
|
|
1760
|
+
const menu = state.commentUi.menu;
|
|
1761
|
+
if (!menu) return false;
|
|
1762
|
+
clearOwned(state.commentUi, "menu");
|
|
1763
|
+
toFrame({ type: "eh:modeMenuState", open: false });
|
|
1764
|
+
if (restoreFocus) requestControlFocus(menu.triggerId);
|
|
1765
|
+
render();
|
|
1766
|
+
return true;
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
const dismissCommentMenuOutside = (event) => {
|
|
1770
|
+
const menu = state.commentUi.menu;
|
|
1771
|
+
if (!menu) return;
|
|
1772
|
+
const menuElement = document.getElementById(controlId(menu.commentId, menu.surface, "menu"));
|
|
1773
|
+
const trigger = document.getElementById(menu.triggerId);
|
|
1774
|
+
if (menuElement?.contains(event.target) || trigger?.contains(event.target)) return;
|
|
1775
|
+
closeCommentMenu();
|
|
1776
|
+
};
|
|
1777
|
+
document.addEventListener("pointerdown", dismissCommentMenuOutside, true);
|
|
1778
|
+
document.addEventListener("focusin", dismissCommentMenuOutside, true);
|
|
1779
|
+
|
|
783
1780
|
$("send").addEventListener("click", async () => {
|
|
784
1781
|
try {
|
|
1782
|
+
await flushFrame();
|
|
785
1783
|
await api(`/api/page/${state.key}/send`, {
|
|
786
1784
|
method: "POST",
|
|
787
1785
|
body: JSON.stringify({ sessionId: state.sessionId, note: $("note").value.trim() }),
|
|
@@ -803,6 +1801,9 @@ $("revert").addEventListener("click", async () => {
|
|
|
803
1801
|
clearTimeout(retryTimer);
|
|
804
1802
|
state.baseHash = null;
|
|
805
1803
|
try {
|
|
1804
|
+
await settleEditPersistence(state.key).catch(() => {});
|
|
1805
|
+
editBacklogs.delete(state.key);
|
|
1806
|
+
editErrors.delete(state.key);
|
|
806
1807
|
state.page = (await api(`/api/page/${state.key}/revert`, { method: "POST" })).page;
|
|
807
1808
|
state.save = "idle";
|
|
808
1809
|
state.savedAt = "";
|
|
@@ -835,9 +1836,9 @@ $("endReview").addEventListener("click", async () => {
|
|
|
835
1836
|
? `End this review? ${unsent} unsent ${unsent === 1 ? "item" : "items"} will be kept for next time.`
|
|
836
1837
|
: "End this review? The waiting agent will be told to stop polling.";
|
|
837
1838
|
if (!window.confirm(message)) return;
|
|
838
|
-
// Ship anything still sitting in the SDK's debounce windows first.
|
|
839
|
-
await flushFrame();
|
|
840
1839
|
try {
|
|
1840
|
+
// Ship anything still sitting in the SDK's debounce windows first.
|
|
1841
|
+
await flushFrame();
|
|
841
1842
|
await api(`/api/session/${state.sessionId}/end`, { method: "POST" });
|
|
842
1843
|
showEnded();
|
|
843
1844
|
} catch (err) {
|
|
@@ -865,17 +1866,6 @@ $("note").addEventListener("input", (event) => {
|
|
|
865
1866
|
render(); // keep the send button in step with note-only feedback
|
|
866
1867
|
});
|
|
867
1868
|
|
|
868
|
-
$("handle").addEventListener("click", () => {
|
|
869
|
-
const collapsed = document.body.classList.toggle("collapsed");
|
|
870
|
-
const handle = $("handle");
|
|
871
|
-
handle.textContent = collapsed ? "‹" : "›";
|
|
872
|
-
handle.title = collapsed ? "Show comments panel" : "Hide comments panel";
|
|
873
|
-
handle.setAttribute("aria-label", handle.title);
|
|
874
|
-
try {
|
|
875
|
-
localStorage.setItem("doc-review:collapsed", collapsed ? "1" : "0");
|
|
876
|
-
} catch {}
|
|
877
|
-
});
|
|
878
|
-
|
|
879
1869
|
$("theme").addEventListener("click", () => {
|
|
880
1870
|
const dark = document.documentElement.dataset.theme !== "dark";
|
|
881
1871
|
applyTheme(dark);
|
|
@@ -887,18 +1877,13 @@ $("theme").addEventListener("click", () => {
|
|
|
887
1877
|
function applyTheme(dark) {
|
|
888
1878
|
document.documentElement.dataset.theme = dark ? "dark" : "light";
|
|
889
1879
|
const button = $("theme");
|
|
890
|
-
button.textContent = dark ? "☀" : "☾";
|
|
891
1880
|
button.title = dark ? "Switch chrome to light" : "Switch chrome to dark";
|
|
892
1881
|
button.setAttribute("aria-label", button.title);
|
|
1882
|
+
replaceIcon(button, dark ? "sun" : "moon");
|
|
893
1883
|
}
|
|
894
1884
|
|
|
895
1885
|
document.addEventListener("keydown", (event) => {
|
|
896
1886
|
const meta = event.metaKey || event.ctrlKey;
|
|
897
|
-
if (meta && event.key === "Enter") {
|
|
898
|
-
event.preventDefault();
|
|
899
|
-
if (!$("send").disabled) $("send").click();
|
|
900
|
-
return;
|
|
901
|
-
}
|
|
902
1887
|
// ⌘S is reassurance only: flush pending keystrokes, never a state change.
|
|
903
1888
|
if (meta && event.key.toLowerCase() === "s") {
|
|
904
1889
|
event.preventDefault();
|
|
@@ -906,7 +1891,25 @@ document.addEventListener("keydown", (event) => {
|
|
|
906
1891
|
renderSave();
|
|
907
1892
|
return;
|
|
908
1893
|
}
|
|
909
|
-
if (event.key === "
|
|
1894
|
+
if (event.key === "Tab" && state.commentUi.menu) {
|
|
1895
|
+
closeCommentMenu();
|
|
1896
|
+
return;
|
|
1897
|
+
}
|
|
1898
|
+
if (event.key !== "Escape") return;
|
|
1899
|
+
let handled = false;
|
|
1900
|
+
if (state.commentUi.confirmation) handled = cancelDeleteConfirmation();
|
|
1901
|
+
else if (state.commentUi.menu) handled = closeCommentMenu({ restoreFocus: true });
|
|
1902
|
+
else if (state.commentUi.edit) handled = cancelCommentEdit();
|
|
1903
|
+
else if (state.compose) handled = cancelCompose();
|
|
1904
|
+
else if (state.modeMenuOpen) handled = closeModeMenu("escape", { restoreFocus: true });
|
|
1905
|
+
else if (state.drawerOpen) {
|
|
1906
|
+
closeDrawer();
|
|
1907
|
+
handled = true;
|
|
1908
|
+
} else if (state.activeSavedCommentId) handled = dismissActiveComment("escape", { restoreFocus: true });
|
|
1909
|
+
if (handled) {
|
|
1910
|
+
event.preventDefault();
|
|
1911
|
+
event.stopPropagation();
|
|
1912
|
+
}
|
|
910
1913
|
});
|
|
911
1914
|
|
|
912
1915
|
// ------------------------------------------------------------------ events
|
|
@@ -920,6 +1923,7 @@ function connect() {
|
|
|
920
1923
|
source.addEventListener("ended", () => showEnded());
|
|
921
1924
|
source.addEventListener("reload", () => {
|
|
922
1925
|
const key = state.key;
|
|
1926
|
+
advancePageEpoch("reload");
|
|
923
1927
|
const generation = beginFrameTransition();
|
|
924
1928
|
state.frameReadyRetries = 0;
|
|
925
1929
|
const hadEdits = state.page ? state.page.edits.length : 0;
|
|
@@ -949,9 +1953,10 @@ function connect() {
|
|
|
949
1953
|
});
|
|
950
1954
|
source.addEventListener("refresh", async () => {
|
|
951
1955
|
const key = state.key;
|
|
1956
|
+
advancePageEpoch("acknowledgement");
|
|
952
1957
|
const page = await api(pageUrl(key, state.sessionId));
|
|
953
1958
|
if (state.key !== key) return;
|
|
954
|
-
|
|
1959
|
+
reconcilePage(page, { syncAnchors: true, reason: "acknowledgement", advance: false });
|
|
955
1960
|
state.sent = false;
|
|
956
1961
|
render();
|
|
957
1962
|
});
|
|
@@ -963,9 +1968,9 @@ function connect() {
|
|
|
963
1968
|
// -------------------------------------------------------------------- start
|
|
964
1969
|
|
|
965
1970
|
(async function start() {
|
|
1971
|
+
installStaticIcons();
|
|
966
1972
|
try {
|
|
967
1973
|
applyTheme(localStorage.getItem("doc-review:theme") === "dark");
|
|
968
|
-
if (localStorage.getItem("doc-review:collapsed") === "1") $("handle").click();
|
|
969
1974
|
} catch {}
|
|
970
1975
|
|
|
971
1976
|
const bootstrap = await api(`/api/session/${state.sessionId}/page`).catch(() => null);
|
|
@@ -976,6 +1981,15 @@ function connect() {
|
|
|
976
1981
|
connect();
|
|
977
1982
|
})();
|
|
978
1983
|
|
|
1984
|
+
window.addEventListener("resize", () => {
|
|
1985
|
+
positionCompose();
|
|
1986
|
+
if (state.activeSavedCommentId) positionAlignedCard();
|
|
1987
|
+
});
|
|
1988
|
+
if (window.visualViewport) {
|
|
1989
|
+
window.visualViewport.addEventListener("resize", positionCompose);
|
|
1990
|
+
window.visualViewport.addEventListener("scroll", positionCompose);
|
|
1991
|
+
}
|
|
1992
|
+
|
|
979
1993
|
frame.addEventListener("load", () => {
|
|
980
1994
|
if (!state.renderId || !state.frameCapability) return;
|
|
981
1995
|
if (state.pendingInitialLoad) {
|
|
@@ -984,3 +1998,9 @@ frame.addEventListener("load", () => {
|
|
|
984
1998
|
}
|
|
985
1999
|
rotateCurrentFrame();
|
|
986
2000
|
});
|
|
2001
|
+
|
|
2002
|
+
frame.addEventListener("focus", () => {
|
|
2003
|
+
closeCommentMenu();
|
|
2004
|
+
if (state.compose) $("compose").classList.add("pass-through");
|
|
2005
|
+
});
|
|
2006
|
+
$("compose").addEventListener("focusin", () => $("compose").classList.remove("pass-through"));
|