html-collab 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +164 -0
- package/dist/cli.js +4159 -0
- package/package.json +61 -0
- package/skill/SKILL.md +70 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,4159 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { realpathSync } from "node:fs";
|
|
5
|
+
import { basename as basename2 } from "node:path";
|
|
6
|
+
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
7
|
+
|
|
8
|
+
// src/commands/extract.ts
|
|
9
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
10
|
+
import { dirname, relative, resolve, sep } from "node:path";
|
|
11
|
+
|
|
12
|
+
// src/format/reduce.ts
|
|
13
|
+
function reduceReviewState(state) {
|
|
14
|
+
const invalidOps = [];
|
|
15
|
+
const uniqueOps = dedupeOps(state.ops, invalidOps).sort(compareOps);
|
|
16
|
+
const threadById = new Map;
|
|
17
|
+
const messageById = new Map;
|
|
18
|
+
const editById = new Map;
|
|
19
|
+
for (const op of uniqueOps) {
|
|
20
|
+
if (op.type === "comment.create") {
|
|
21
|
+
const threadId = op.payload.threadId;
|
|
22
|
+
if (!threadId) {
|
|
23
|
+
invalidOps.push({ opId: op.opId, reason: "comment.create missing threadId" });
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
if (threadById.has(threadId)) {
|
|
27
|
+
invalidOps.push({ opId: op.opId, reason: `duplicate threadId ${threadId}` });
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
const root = {
|
|
31
|
+
messageId: op.opId,
|
|
32
|
+
actorId: op.actorId,
|
|
33
|
+
body: op.payload.body,
|
|
34
|
+
deleted: false,
|
|
35
|
+
createOp: op,
|
|
36
|
+
updateOp: op
|
|
37
|
+
};
|
|
38
|
+
const thread = {
|
|
39
|
+
threadId,
|
|
40
|
+
status: "open",
|
|
41
|
+
anchor: op.target,
|
|
42
|
+
root,
|
|
43
|
+
replies: [],
|
|
44
|
+
createdOp: op
|
|
45
|
+
};
|
|
46
|
+
threadById.set(threadId, thread);
|
|
47
|
+
messageById.set(root.messageId, root);
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (op.type === "edit.suggest") {
|
|
51
|
+
const editId = op.payload.editId;
|
|
52
|
+
if (!editId) {
|
|
53
|
+
invalidOps.push({ opId: op.opId, reason: "edit.suggest missing editId" });
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (editById.has(editId)) {
|
|
57
|
+
invalidOps.push({ opId: op.opId, reason: `duplicate editId ${editId}` });
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if ((op.payload.kind === "replace" || op.payload.kind === "insert") && !op.payload.replacement) {
|
|
61
|
+
invalidOps.push({ opId: op.opId, reason: `${op.payload.kind} edit missing replacement` });
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
editById.set(editId, {
|
|
65
|
+
editId,
|
|
66
|
+
status: "open",
|
|
67
|
+
kind: op.payload.kind,
|
|
68
|
+
anchor: op.target,
|
|
69
|
+
replacement: op.payload.replacement,
|
|
70
|
+
note: op.payload.note,
|
|
71
|
+
createdOp: op
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
for (const op of uniqueOps) {
|
|
76
|
+
switch (op.type) {
|
|
77
|
+
case "comment.create":
|
|
78
|
+
break;
|
|
79
|
+
case "reply.create": {
|
|
80
|
+
const thread = threadById.get(op.target.threadId);
|
|
81
|
+
if (!thread) {
|
|
82
|
+
invalidOps.push({ opId: op.opId, reason: `reply targets missing thread ${op.target.threadId}` });
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
if (messageById.has(op.opId)) {
|
|
86
|
+
invalidOps.push({ opId: op.opId, reason: `duplicate messageId ${op.opId}` });
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
const reply = {
|
|
90
|
+
messageId: op.opId,
|
|
91
|
+
actorId: op.actorId,
|
|
92
|
+
body: op.payload.body,
|
|
93
|
+
deleted: false,
|
|
94
|
+
createOp: op,
|
|
95
|
+
updateOp: op,
|
|
96
|
+
parentId: op.target.parentId
|
|
97
|
+
};
|
|
98
|
+
thread.replies.push(reply);
|
|
99
|
+
messageById.set(reply.messageId, reply);
|
|
100
|
+
break;
|
|
101
|
+
}
|
|
102
|
+
case "comment.edit":
|
|
103
|
+
case "reply.edit": {
|
|
104
|
+
const message = messageById.get(op.target.messageId);
|
|
105
|
+
if (!message) {
|
|
106
|
+
invalidOps.push({ opId: op.opId, reason: `edit targets missing message ${op.target.messageId}` });
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
109
|
+
if (compareOps(message.updateOp, op) <= 0) {
|
|
110
|
+
message.body = op.payload.body;
|
|
111
|
+
message.updateOp = op;
|
|
112
|
+
}
|
|
113
|
+
break;
|
|
114
|
+
}
|
|
115
|
+
case "comment.delete":
|
|
116
|
+
case "reply.delete": {
|
|
117
|
+
const message = messageById.get(op.target.messageId);
|
|
118
|
+
if (!message) {
|
|
119
|
+
invalidOps.push({ opId: op.opId, reason: `delete targets missing message ${op.target.messageId}` });
|
|
120
|
+
break;
|
|
121
|
+
}
|
|
122
|
+
if (compareOps(message.updateOp, op) <= 0) {
|
|
123
|
+
message.deleted = true;
|
|
124
|
+
message.updateOp = op;
|
|
125
|
+
}
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
case "thread.resolve":
|
|
129
|
+
case "thread.reopen": {
|
|
130
|
+
const thread = threadById.get(op.target.threadId);
|
|
131
|
+
if (!thread) {
|
|
132
|
+
invalidOps.push({ opId: op.opId, reason: `status targets missing thread ${op.target.threadId}` });
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
if (!thread.statusOp || compareOps(thread.statusOp, op) <= 0) {
|
|
136
|
+
thread.status = op.type === "thread.resolve" ? "resolved" : "open";
|
|
137
|
+
thread.statusOp = op;
|
|
138
|
+
}
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
case "edit.suggest":
|
|
142
|
+
break;
|
|
143
|
+
case "edit.accept":
|
|
144
|
+
case "edit.reject":
|
|
145
|
+
case "edit.delete": {
|
|
146
|
+
const edit = editById.get(op.target.editId);
|
|
147
|
+
if (!edit) {
|
|
148
|
+
invalidOps.push({ opId: op.opId, reason: `edit status targets missing edit ${op.target.editId}` });
|
|
149
|
+
break;
|
|
150
|
+
}
|
|
151
|
+
if (!edit.statusOp || compareOps(edit.statusOp, op) <= 0) {
|
|
152
|
+
edit.status = op.type === "edit.accept" ? "accepted" : op.type === "edit.reject" ? "rejected" : "deleted";
|
|
153
|
+
edit.statusOp = op;
|
|
154
|
+
}
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
default: {
|
|
158
|
+
const unknownOp = op;
|
|
159
|
+
invalidOps.push({ opId: unknownOp.opId, reason: `unsupported operation ${unknownOp.type}` });
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
const threads = Array.from(threadById.values()).map((thread) => reduceThread(thread)).sort((left, right) => {
|
|
164
|
+
const created = left.createdAt.localeCompare(right.createdAt);
|
|
165
|
+
if (created !== 0) {
|
|
166
|
+
return created;
|
|
167
|
+
}
|
|
168
|
+
return left.threadId.localeCompare(right.threadId);
|
|
169
|
+
});
|
|
170
|
+
const edits = Array.from(editById.values()).map((edit) => reduceEdit(edit)).sort((left, right) => {
|
|
171
|
+
const created = left.createdAt.localeCompare(right.createdAt);
|
|
172
|
+
if (created !== 0) {
|
|
173
|
+
return created;
|
|
174
|
+
}
|
|
175
|
+
return left.editId.localeCompare(right.editId);
|
|
176
|
+
});
|
|
177
|
+
return {
|
|
178
|
+
schemaVersion: 1,
|
|
179
|
+
docId: state.docId,
|
|
180
|
+
sourceFingerprint: state.sourceFingerprint,
|
|
181
|
+
title: state.title,
|
|
182
|
+
actors: state.actors,
|
|
183
|
+
threads,
|
|
184
|
+
edits,
|
|
185
|
+
invalidOps
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
function compareOps(left, right) {
|
|
189
|
+
if (left.clock !== right.clock) {
|
|
190
|
+
return left.clock - right.clock;
|
|
191
|
+
}
|
|
192
|
+
const timeComparison = left.time.localeCompare(right.time);
|
|
193
|
+
if (timeComparison !== 0) {
|
|
194
|
+
return timeComparison;
|
|
195
|
+
}
|
|
196
|
+
return left.opId.localeCompare(right.opId);
|
|
197
|
+
}
|
|
198
|
+
function dedupeOps(ops, invalidOps) {
|
|
199
|
+
const byId = new Map;
|
|
200
|
+
for (const op of ops) {
|
|
201
|
+
if (!op || typeof op !== "object" || !("opId" in op) || typeof op.opId !== "string") {
|
|
202
|
+
invalidOps.push({ reason: "operation missing opId" });
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
const existing = byId.get(op.opId);
|
|
206
|
+
if (!existing || compareOps(existing, op) <= 0) {
|
|
207
|
+
byId.set(op.opId, op);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return Array.from(byId.values());
|
|
211
|
+
}
|
|
212
|
+
function reduceThread(thread) {
|
|
213
|
+
const messages = [thread.root, ...thread.replies];
|
|
214
|
+
const updatedAt = messages.map((message) => message.updateOp.time).concat(thread.statusOp?.time ?? thread.createdOp.time).sort().at(-1) ?? thread.createdOp.time;
|
|
215
|
+
return {
|
|
216
|
+
threadId: thread.threadId,
|
|
217
|
+
status: thread.status,
|
|
218
|
+
anchor: thread.anchor,
|
|
219
|
+
root: reduceMessage(thread.root),
|
|
220
|
+
replies: thread.replies.sort((left, right) => compareOps(left.createOp, right.createOp)).map(reduceMessage),
|
|
221
|
+
createdAt: thread.createdOp.time,
|
|
222
|
+
updatedAt
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
function reduceMessage(message) {
|
|
226
|
+
return {
|
|
227
|
+
messageId: message.messageId,
|
|
228
|
+
actorId: message.actorId,
|
|
229
|
+
body: message.body,
|
|
230
|
+
deleted: message.deleted,
|
|
231
|
+
createdAt: message.createOp.time,
|
|
232
|
+
updatedAt: message.updateOp.time
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
function reduceEdit(edit) {
|
|
236
|
+
return {
|
|
237
|
+
editId: edit.editId,
|
|
238
|
+
status: edit.status,
|
|
239
|
+
kind: edit.kind,
|
|
240
|
+
anchor: edit.anchor,
|
|
241
|
+
replacement: edit.replacement,
|
|
242
|
+
note: edit.note,
|
|
243
|
+
actorId: edit.createdOp.actorId,
|
|
244
|
+
createdAt: edit.createdOp.time,
|
|
245
|
+
updatedAt: edit.statusOp?.time ?? edit.createdOp.time
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// src/format/extract.ts
|
|
250
|
+
function extractReview(state, format, options = {}) {
|
|
251
|
+
const bundle = createReviewBundle(state);
|
|
252
|
+
if (format === "json") {
|
|
253
|
+
return `${JSON.stringify(bundle, null, 2)}
|
|
254
|
+
`;
|
|
255
|
+
}
|
|
256
|
+
if (format === "text") {
|
|
257
|
+
return renderTextBrief(bundle);
|
|
258
|
+
}
|
|
259
|
+
if (format === "agent") {
|
|
260
|
+
return renderAgentBrief(bundle, options);
|
|
261
|
+
}
|
|
262
|
+
return renderMarkdownBrief(bundle, options);
|
|
263
|
+
}
|
|
264
|
+
function createReviewBundle(state) {
|
|
265
|
+
const reduced = reduceReviewState(state);
|
|
266
|
+
const reviewers = Object.values(reduced.actors).map((actor) => actor.name).filter(Boolean).sort((left, right) => left.localeCompare(right));
|
|
267
|
+
return {
|
|
268
|
+
schemaVersion: 1,
|
|
269
|
+
docId: reduced.docId,
|
|
270
|
+
title: reduced.title,
|
|
271
|
+
sourceFingerprint: reduced.sourceFingerprint,
|
|
272
|
+
summary: {
|
|
273
|
+
openThreads: reduced.threads.filter((thread) => thread.status === "open").length,
|
|
274
|
+
resolvedThreads: reduced.threads.filter((thread) => thread.status === "resolved").length,
|
|
275
|
+
openEdits: reduced.edits.filter((edit) => edit.status === "open").length,
|
|
276
|
+
acceptedEdits: reduced.edits.filter((edit) => edit.status === "accepted").length,
|
|
277
|
+
rejectedEdits: reduced.edits.filter((edit) => edit.status === "rejected").length,
|
|
278
|
+
deletedEdits: reduced.edits.filter((edit) => edit.status === "deleted").length,
|
|
279
|
+
reviewers
|
|
280
|
+
},
|
|
281
|
+
threads: reduced.threads.map((thread) => ({
|
|
282
|
+
threadId: thread.threadId,
|
|
283
|
+
status: thread.status,
|
|
284
|
+
anchor: {
|
|
285
|
+
kind: thread.anchor.kind,
|
|
286
|
+
quote: thread.anchor.quote,
|
|
287
|
+
confidence: thread.anchor.position ? "high" : "medium",
|
|
288
|
+
position: thread.anchor.position,
|
|
289
|
+
prefix: thread.anchor.prefix,
|
|
290
|
+
suffix: thread.anchor.suffix,
|
|
291
|
+
elementFingerprint: thread.anchor.elementFingerprint,
|
|
292
|
+
headingPath: thread.anchor.headingPath
|
|
293
|
+
},
|
|
294
|
+
messages: [
|
|
295
|
+
toBundleMessage(thread.root, "comment", reduced),
|
|
296
|
+
...thread.replies.map((reply) => toBundleMessage(reply, "reply", reduced))
|
|
297
|
+
]
|
|
298
|
+
})),
|
|
299
|
+
edits: reduced.edits.map((edit) => toBundleEdit(edit, reduced)),
|
|
300
|
+
invalidOps: reduced.invalidOps
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
function renderMarkdownBrief(bundle, options) {
|
|
304
|
+
const lines = [
|
|
305
|
+
`# Review Brief: ${bundle.title ?? bundle.docId}`,
|
|
306
|
+
"",
|
|
307
|
+
`**Reviewers:** ${bundle.summary.reviewers.length ? bundle.summary.reviewers.join(", ") : "None"}`,
|
|
308
|
+
`**Comments:** ${bundle.summary.openThreads} open, ${bundle.summary.resolvedThreads} resolved`,
|
|
309
|
+
`**Suggested edits:** ${bundle.summary.openEdits} open, ${bundle.summary.acceptedEdits} accepted, ${bundle.summary.rejectedEdits} rejected, ${bundle.summary.deletedEdits} deleted`,
|
|
310
|
+
"",
|
|
311
|
+
"## Comments",
|
|
312
|
+
""
|
|
313
|
+
];
|
|
314
|
+
if (bundle.threads.length === 0) {
|
|
315
|
+
lines.push("No comments.", "");
|
|
316
|
+
}
|
|
317
|
+
bundle.threads.forEach((thread, index) => {
|
|
318
|
+
const link = threadLink(options.reviewHref, thread.threadId);
|
|
319
|
+
lines.push(`### Comment ${index + 1}: ${thread.status}`);
|
|
320
|
+
lines.push("");
|
|
321
|
+
if (link) {
|
|
322
|
+
lines.push(`- [Open in review](${link})`);
|
|
323
|
+
}
|
|
324
|
+
const location = locationLabel(thread.anchor);
|
|
325
|
+
if (location) {
|
|
326
|
+
lines.push(`- Location: ${location}`);
|
|
327
|
+
}
|
|
328
|
+
lines.push(`- ID: \`${thread.threadId}\``);
|
|
329
|
+
lines.push("");
|
|
330
|
+
lines.push("Context:");
|
|
331
|
+
lines.push("");
|
|
332
|
+
lines.push("> " + renderAnchorContextMarkdown(thread.anchor));
|
|
333
|
+
lines.push("");
|
|
334
|
+
lines.push("Messages:");
|
|
335
|
+
for (const message of thread.messages) {
|
|
336
|
+
if (message.deleted) {
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
lines.push(`- ${message.actor}: ${message.body}`);
|
|
340
|
+
}
|
|
341
|
+
lines.push("");
|
|
342
|
+
});
|
|
343
|
+
lines.push("## Suggested Edits", "");
|
|
344
|
+
if (bundle.edits.length === 0) {
|
|
345
|
+
lines.push("No suggested edits.", "");
|
|
346
|
+
}
|
|
347
|
+
bundle.edits.forEach((edit, index) => {
|
|
348
|
+
const link = editLink(options.reviewHref, edit.editId);
|
|
349
|
+
lines.push(`### Edit ${index + 1}: ${edit.status} ${edit.kind}`);
|
|
350
|
+
lines.push("");
|
|
351
|
+
if (link) {
|
|
352
|
+
lines.push(`- [Open in review](${link})`);
|
|
353
|
+
}
|
|
354
|
+
const location = locationLabel(edit.anchor);
|
|
355
|
+
if (location) {
|
|
356
|
+
lines.push(`- Location: ${location}`);
|
|
357
|
+
}
|
|
358
|
+
lines.push(`- ID: \`${edit.editId}\``);
|
|
359
|
+
lines.push(`- Reviewer: ${edit.actor}`);
|
|
360
|
+
lines.push("");
|
|
361
|
+
lines.push("Context:");
|
|
362
|
+
lines.push("");
|
|
363
|
+
lines.push("> " + renderAnchorContextMarkdown(edit.anchor));
|
|
364
|
+
lines.push("");
|
|
365
|
+
if (edit.kind === "replace") {
|
|
366
|
+
lines.push(`Replace with: ${edit.replacement ?? ""}`);
|
|
367
|
+
} else if (edit.kind === "insert") {
|
|
368
|
+
lines.push(`Insert after selection: ${edit.replacement ?? ""}`);
|
|
369
|
+
} else {
|
|
370
|
+
lines.push("Delete selected text.");
|
|
371
|
+
}
|
|
372
|
+
if (edit.note) {
|
|
373
|
+
lines.push(`Note: ${edit.note}`);
|
|
374
|
+
}
|
|
375
|
+
lines.push("");
|
|
376
|
+
});
|
|
377
|
+
if (bundle.invalidOps.length > 0) {
|
|
378
|
+
lines.push("## Invalid Operations", "");
|
|
379
|
+
for (const invalid of bundle.invalidOps) {
|
|
380
|
+
lines.push(`- ${invalid.opId ?? "unknown"}: ${invalid.reason}`);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
return `${lines.join(`
|
|
384
|
+
`).trimEnd()}
|
|
385
|
+
`;
|
|
386
|
+
}
|
|
387
|
+
function renderTextBrief(bundle) {
|
|
388
|
+
const lines = [
|
|
389
|
+
`Review Brief: ${bundle.title ?? bundle.docId}`,
|
|
390
|
+
`Reviewers: ${bundle.summary.reviewers.length ? bundle.summary.reviewers.join(", ") : "None"}`,
|
|
391
|
+
`Comments: ${bundle.summary.openThreads} open, ${bundle.summary.resolvedThreads} resolved`,
|
|
392
|
+
`Suggested edits: ${bundle.summary.openEdits} open, ${bundle.summary.acceptedEdits} accepted, ${bundle.summary.rejectedEdits} rejected, ${bundle.summary.deletedEdits} deleted`
|
|
393
|
+
];
|
|
394
|
+
bundle.threads.forEach((thread, index) => {
|
|
395
|
+
lines.push("");
|
|
396
|
+
lines.push(`Comment ${index + 1}: ${thread.status}`);
|
|
397
|
+
const location = locationLabel(thread.anchor);
|
|
398
|
+
if (location) {
|
|
399
|
+
lines.push(`Location: ${location}`);
|
|
400
|
+
}
|
|
401
|
+
lines.push(`ID: ${thread.threadId}`);
|
|
402
|
+
lines.push(`Context: ${renderAnchorContextPlain(thread.anchor)}`);
|
|
403
|
+
for (const message of thread.messages) {
|
|
404
|
+
if (!message.deleted) {
|
|
405
|
+
lines.push(`${message.actor}: ${message.body}`);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
});
|
|
409
|
+
bundle.edits.forEach((edit, index) => {
|
|
410
|
+
lines.push("");
|
|
411
|
+
lines.push(`Edit ${index + 1}: ${edit.status} ${edit.kind}`);
|
|
412
|
+
const location = locationLabel(edit.anchor);
|
|
413
|
+
if (location) {
|
|
414
|
+
lines.push(`Location: ${location}`);
|
|
415
|
+
}
|
|
416
|
+
lines.push(`ID: ${edit.editId}`);
|
|
417
|
+
lines.push(`Reviewer: ${edit.actor}`);
|
|
418
|
+
lines.push(`Context: ${renderAnchorContextPlain(edit.anchor)}`);
|
|
419
|
+
if (edit.kind === "replace") {
|
|
420
|
+
lines.push(`Replace with: ${edit.replacement ?? ""}`);
|
|
421
|
+
} else if (edit.kind === "insert") {
|
|
422
|
+
lines.push(`Insert after selection: ${edit.replacement ?? ""}`);
|
|
423
|
+
} else {
|
|
424
|
+
lines.push("Delete selected text.");
|
|
425
|
+
}
|
|
426
|
+
if (edit.note) {
|
|
427
|
+
lines.push(`Note: ${edit.note}`);
|
|
428
|
+
}
|
|
429
|
+
});
|
|
430
|
+
return `${lines.join(`
|
|
431
|
+
`).trimEnd()}
|
|
432
|
+
`;
|
|
433
|
+
}
|
|
434
|
+
function renderAgentBrief(bundle, options) {
|
|
435
|
+
const inferredThreadActions = bundle.threads.filter((thread) => thread.status === "open").map((thread) => ({ thread, inference: inferThreadReplacement(thread) })).filter((entry) => Boolean(entry.inference));
|
|
436
|
+
const inferredThreadIds = new Set(inferredThreadActions.map((entry) => entry.thread.threadId));
|
|
437
|
+
const actionableEdits = bundle.edits.filter((edit) => edit.status === "open" || edit.status === "accepted");
|
|
438
|
+
const questionThreads = bundle.threads.filter((thread) => thread.status === "open" && !inferredThreadIds.has(thread.threadId));
|
|
439
|
+
const lines = [
|
|
440
|
+
`# Agent Review Plan: ${bundle.title ?? bundle.docId}`,
|
|
441
|
+
"",
|
|
442
|
+
`Reviewers: ${bundle.summary.reviewers.length ? bundle.summary.reviewers.join(", ") : "None"}`,
|
|
443
|
+
`Open comments: ${bundle.summary.openThreads}`,
|
|
444
|
+
`Open suggested edits: ${bundle.summary.openEdits}`,
|
|
445
|
+
"",
|
|
446
|
+
"Use `html-collab extract --format json` as the deterministic source of truth when applying these actions.",
|
|
447
|
+
"Preserve thread/edit IDs in work notes and report any inferred changes as inferred.",
|
|
448
|
+
"",
|
|
449
|
+
"## Direct Actions",
|
|
450
|
+
""
|
|
451
|
+
];
|
|
452
|
+
if (actionableEdits.length === 0 && inferredThreadActions.length === 0) {
|
|
453
|
+
lines.push("No direct actions found.", "");
|
|
454
|
+
}
|
|
455
|
+
for (const edit of actionableEdits) {
|
|
456
|
+
const link = editLink(options.reviewHref, edit.editId);
|
|
457
|
+
lines.push(`- ${agentEditActionLabel(edit)}`);
|
|
458
|
+
lines.push(` - Source: edit \`${edit.editId}\`${link ? ` (${link})` : ""}`);
|
|
459
|
+
lines.push(` - Status: ${edit.status}`);
|
|
460
|
+
lines.push(" - Confidence: high");
|
|
461
|
+
lines.push(` - Context: ${renderAnchorContextPlain(edit.anchor)}`);
|
|
462
|
+
if (edit.note) {
|
|
463
|
+
lines.push(` - Reviewer note: ${edit.note}`);
|
|
464
|
+
}
|
|
465
|
+
lines.push("");
|
|
466
|
+
}
|
|
467
|
+
for (const { thread, inference } of inferredThreadActions) {
|
|
468
|
+
const link = threadLink(options.reviewHref, thread.threadId);
|
|
469
|
+
lines.push(`- Replace selected text \`${thread.anchor.quote}\` with \`${inference.replacement}\``);
|
|
470
|
+
lines.push(` - Source: comment \`${thread.threadId}\`${link ? ` (${link})` : ""}`);
|
|
471
|
+
lines.push(" - Confidence: medium");
|
|
472
|
+
lines.push(` - Reason: ${inference.reason}`);
|
|
473
|
+
lines.push(` - Context: ${renderAnchorContextPlain(thread.anchor)}`);
|
|
474
|
+
lines.push("");
|
|
475
|
+
}
|
|
476
|
+
lines.push("## Questions", "");
|
|
477
|
+
if (questionThreads.length === 0) {
|
|
478
|
+
lines.push("No blocking questions from open comments.", "");
|
|
479
|
+
}
|
|
480
|
+
for (const thread of questionThreads) {
|
|
481
|
+
const link = threadLink(options.reviewHref, thread.threadId);
|
|
482
|
+
lines.push(`- Clarify comment \`${thread.threadId}\`${link ? ` (${link})` : ""}`);
|
|
483
|
+
lines.push(` - Context: ${renderAnchorContextPlain(thread.anchor)}`);
|
|
484
|
+
for (const message of thread.messages) {
|
|
485
|
+
if (!message.deleted) {
|
|
486
|
+
lines.push(` - ${message.actor}: ${message.body}`);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
lines.push("");
|
|
490
|
+
}
|
|
491
|
+
lines.push("## Suggested Edits", "");
|
|
492
|
+
if (bundle.edits.length === 0) {
|
|
493
|
+
lines.push("No suggested edits.", "");
|
|
494
|
+
}
|
|
495
|
+
for (const edit of bundle.edits) {
|
|
496
|
+
const link = editLink(options.reviewHref, edit.editId);
|
|
497
|
+
lines.push(`- \`${edit.editId}\`: ${edit.status} ${edit.kind}${link ? ` (${link})` : ""}`);
|
|
498
|
+
if (edit.kind === "replace") {
|
|
499
|
+
lines.push(` - Replace \`${edit.anchor.quote}\` with \`${edit.replacement ?? ""}\``);
|
|
500
|
+
} else if (edit.kind === "insert") {
|
|
501
|
+
lines.push(` - Insert \`${edit.replacement ?? ""}\` after \`${edit.anchor.quote}\``);
|
|
502
|
+
} else {
|
|
503
|
+
lines.push(` - Delete \`${edit.anchor.quote}\``);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
if (bundle.invalidOps.length > 0) {
|
|
507
|
+
lines.push("", "## Invalid Operations", "");
|
|
508
|
+
for (const invalid of bundle.invalidOps) {
|
|
509
|
+
lines.push(`- ${invalid.opId ?? "unknown"}: ${invalid.reason}`);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
return `${lines.join(`
|
|
513
|
+
`).trimEnd()}
|
|
514
|
+
`;
|
|
515
|
+
}
|
|
516
|
+
function toBundleMessage(message, type, reduced) {
|
|
517
|
+
return {
|
|
518
|
+
messageId: message.messageId,
|
|
519
|
+
actor: reduced.actors[message.actorId]?.name ?? message.actorId,
|
|
520
|
+
actorId: message.actorId,
|
|
521
|
+
type,
|
|
522
|
+
body: message.body,
|
|
523
|
+
deleted: message.deleted
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
function toBundleEdit(edit, reduced) {
|
|
527
|
+
return {
|
|
528
|
+
editId: edit.editId,
|
|
529
|
+
status: edit.status,
|
|
530
|
+
kind: edit.kind,
|
|
531
|
+
actor: reduced.actors[edit.actorId]?.name ?? edit.actorId,
|
|
532
|
+
actorId: edit.actorId,
|
|
533
|
+
anchor: {
|
|
534
|
+
kind: edit.anchor.kind,
|
|
535
|
+
quote: edit.anchor.quote,
|
|
536
|
+
confidence: edit.anchor.position ? "high" : "medium",
|
|
537
|
+
position: edit.anchor.position,
|
|
538
|
+
prefix: edit.anchor.prefix,
|
|
539
|
+
suffix: edit.anchor.suffix,
|
|
540
|
+
elementFingerprint: edit.anchor.elementFingerprint,
|
|
541
|
+
headingPath: edit.anchor.headingPath
|
|
542
|
+
},
|
|
543
|
+
replacement: edit.replacement,
|
|
544
|
+
note: edit.note,
|
|
545
|
+
createdAt: edit.createdAt,
|
|
546
|
+
updatedAt: edit.updatedAt
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
function agentEditActionLabel(edit) {
|
|
550
|
+
if (edit.kind === "replace") {
|
|
551
|
+
return `Replace selected text \`${edit.anchor.quote}\` with \`${edit.replacement ?? ""}\``;
|
|
552
|
+
}
|
|
553
|
+
if (edit.kind === "insert") {
|
|
554
|
+
return `Insert \`${edit.replacement ?? ""}\` after selected text \`${edit.anchor.quote}\``;
|
|
555
|
+
}
|
|
556
|
+
return `Delete selected text \`${edit.anchor.quote}\``;
|
|
557
|
+
}
|
|
558
|
+
function inferThreadReplacement(thread) {
|
|
559
|
+
const message = lastVisibleMessage(thread);
|
|
560
|
+
if (!message) {
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
const body = collapseWhitespace(message.body);
|
|
564
|
+
const quoted = extractQuotedReplacement(body);
|
|
565
|
+
if (quoted) {
|
|
566
|
+
return {
|
|
567
|
+
replacement: quoted,
|
|
568
|
+
reason: `${message.actor} said "${body}" on selected text "${thread.anchor.quote}".`
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
const patterns = [
|
|
572
|
+
/^(?:please\s+)?(?:change|replace)\s+(?:this|it|that|selection|selected text|the selected text)?\s*(?:with|to|into)\s+(.+)$/i,
|
|
573
|
+
/^(?:please\s+)?(?:use)\s+(.+?)\s+instead$/i,
|
|
574
|
+
/^(?:please\s+)?(?:make)\s+(?:this|it|that|selection|selected text|the selected text)\s+(.+)$/i,
|
|
575
|
+
/^(?:please\s+)?(?:this|it|that|selection|selected text|the selected text)\s+should\s+be\s+(.+)$/i
|
|
576
|
+
];
|
|
577
|
+
for (const pattern of patterns) {
|
|
578
|
+
const match = pattern.exec(body);
|
|
579
|
+
if (!match?.[1]) {
|
|
580
|
+
continue;
|
|
581
|
+
}
|
|
582
|
+
const replacement = cleanReplacementLiteral(match[1]);
|
|
583
|
+
if (isLikelyLiteralReplacement(replacement, thread.anchor.quote)) {
|
|
584
|
+
return {
|
|
585
|
+
replacement,
|
|
586
|
+
reason: `${message.actor} said "${body}" on selected text "${thread.anchor.quote}".`
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
function lastVisibleMessage(thread) {
|
|
593
|
+
return thread.messages.filter((message) => !message.deleted).at(-1);
|
|
594
|
+
}
|
|
595
|
+
function extractQuotedReplacement(body) {
|
|
596
|
+
const match = /(?:change|replace|make|use|should be|to|with)\s+["'“”‘’`](.+?)["'“”‘’`]/i.exec(body);
|
|
597
|
+
return match?.[1] ? cleanReplacementLiteral(match[1]) : undefined;
|
|
598
|
+
}
|
|
599
|
+
function cleanReplacementLiteral(value) {
|
|
600
|
+
return value.trim().replace(/^["'“”‘’`]+|["'“”‘’`]+$/g, "").replace(/[.!?]$/g, "").trim();
|
|
601
|
+
}
|
|
602
|
+
function isLikelyLiteralReplacement(replacement, quote) {
|
|
603
|
+
if (!replacement) {
|
|
604
|
+
return false;
|
|
605
|
+
}
|
|
606
|
+
if (/^-?\d+(?:\.\d+)?$/.test(replacement)) {
|
|
607
|
+
return true;
|
|
608
|
+
}
|
|
609
|
+
if (replacement.length <= 24 && !/\s/.test(replacement) && !/[.,;:!?]/.test(replacement) && quote.length <= 40) {
|
|
610
|
+
return true;
|
|
611
|
+
}
|
|
612
|
+
return false;
|
|
613
|
+
}
|
|
614
|
+
function threadLink(reviewHref, threadId) {
|
|
615
|
+
if (!reviewHref) {
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
return `${reviewHref}#${threadElementId(threadId)}`;
|
|
619
|
+
}
|
|
620
|
+
function threadElementId(threadId) {
|
|
621
|
+
return `html-collab-thread-${encodeURIComponent(threadId)}`;
|
|
622
|
+
}
|
|
623
|
+
function editLink(reviewHref, editId) {
|
|
624
|
+
if (!reviewHref) {
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
return `${reviewHref}#${editElementId(editId)}`;
|
|
628
|
+
}
|
|
629
|
+
function editElementId(editId) {
|
|
630
|
+
return `html-collab-edit-${encodeURIComponent(editId)}`;
|
|
631
|
+
}
|
|
632
|
+
function locationLabel(anchor) {
|
|
633
|
+
const heading = anchor.headingPath?.at(-1);
|
|
634
|
+
if (heading && !sameText(heading, anchor.quote) && !containsText(heading, anchor.quote)) {
|
|
635
|
+
return truncate(heading, 96);
|
|
636
|
+
}
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
function renderAnchorContextMarkdown(anchor) {
|
|
640
|
+
const prefix = collapseWhitespace(anchor.prefix ?? "");
|
|
641
|
+
const quote = collapseWhitespace(anchor.quote);
|
|
642
|
+
const suffix = collapseWhitespace(anchor.suffix ?? "");
|
|
643
|
+
const head = prefix ? `…${escapeMarkdownInline(prefix)} ` : "";
|
|
644
|
+
const tail = suffix ? ` ${escapeMarkdownInline(suffix)}…` : "";
|
|
645
|
+
return `${head}**${escapeMarkdownInline(quote)}**${tail}`;
|
|
646
|
+
}
|
|
647
|
+
function renderAnchorContextPlain(anchor) {
|
|
648
|
+
const prefix = collapseWhitespace(anchor.prefix ?? "");
|
|
649
|
+
const quote = collapseWhitespace(anchor.quote);
|
|
650
|
+
const suffix = collapseWhitespace(anchor.suffix ?? "");
|
|
651
|
+
const head = prefix ? `…${prefix} ` : "";
|
|
652
|
+
const tail = suffix ? ` ${suffix}…` : "";
|
|
653
|
+
return `${head}«${quote}»${tail}`;
|
|
654
|
+
}
|
|
655
|
+
function collapseWhitespace(value) {
|
|
656
|
+
return value.replace(/\s+/g, " ").trim();
|
|
657
|
+
}
|
|
658
|
+
function truncate(value, max) {
|
|
659
|
+
const collapsed = collapseWhitespace(value);
|
|
660
|
+
if (collapsed.length <= max) {
|
|
661
|
+
return collapsed;
|
|
662
|
+
}
|
|
663
|
+
return `${collapsed.slice(0, max - 1)}…`;
|
|
664
|
+
}
|
|
665
|
+
function sameText(left, right) {
|
|
666
|
+
return collapseWhitespace(left).toLowerCase() === collapseWhitespace(right).toLowerCase();
|
|
667
|
+
}
|
|
668
|
+
function containsText(haystack, needle) {
|
|
669
|
+
return collapseWhitespace(haystack).toLowerCase().includes(collapseWhitespace(needle).toLowerCase());
|
|
670
|
+
}
|
|
671
|
+
function escapeMarkdownInline(value) {
|
|
672
|
+
return value.replace(/([\\`*_{}\[\]()#+\-!|>])/g, "\\$1");
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// src/format/html-envelope.ts
|
|
676
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
677
|
+
import { basename } from "node:path";
|
|
678
|
+
|
|
679
|
+
// src/runtime/index.ts
|
|
680
|
+
var iframeLoaderRuntime = String.raw`
|
|
681
|
+
(() => {
|
|
682
|
+
const SOURCE_SCRIPT_ID = "html-collab-source";
|
|
683
|
+
const STATE_SCRIPT_ID = "html-collab-state";
|
|
684
|
+
const ACTOR_STORAGE_PREFIX = "html-collab.actor.";
|
|
685
|
+
const AUTOSAVE_DELAY_MS = 700;
|
|
686
|
+
const EDIT_VIEW_STORAGE_KEY = "html-collab.editView";
|
|
687
|
+
|
|
688
|
+
let state;
|
|
689
|
+
let sourceHtml = "";
|
|
690
|
+
let selectedAnchor = null;
|
|
691
|
+
let editViewMode = "markup";
|
|
692
|
+
let autosaveHandle = null;
|
|
693
|
+
let autosaveEnabled = false;
|
|
694
|
+
let autosaveTimer = 0;
|
|
695
|
+
let autosaveInFlight = false;
|
|
696
|
+
let autosaveQueued = false;
|
|
697
|
+
let autosavePrompted = false;
|
|
698
|
+
let autosavePromptInFlight = false;
|
|
699
|
+
|
|
700
|
+
function readJsonScript(id) {
|
|
701
|
+
const node = document.getElementById(id);
|
|
702
|
+
if (!node) {
|
|
703
|
+
throw new Error("Missing " + id + " script");
|
|
704
|
+
}
|
|
705
|
+
return JSON.parse(node.textContent || "{}");
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
function writeJsonScript(id, value) {
|
|
709
|
+
const node = document.getElementById(id);
|
|
710
|
+
if (!node) {
|
|
711
|
+
throw new Error("Missing " + id + " script");
|
|
712
|
+
}
|
|
713
|
+
node.textContent = JSON.stringify(value, null, 2).replace(/[<>&]/g, (character) => {
|
|
714
|
+
if (character === "<") return "\\u003c";
|
|
715
|
+
if (character === ">") return "\\u003e";
|
|
716
|
+
return "\\u0026";
|
|
717
|
+
});
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function decodeBase64Utf8(value) {
|
|
721
|
+
const binary = atob(value);
|
|
722
|
+
const bytes = new Uint8Array(binary.length);
|
|
723
|
+
for (let index = 0; index < binary.length; index += 1) {
|
|
724
|
+
bytes[index] = binary.charCodeAt(index);
|
|
725
|
+
}
|
|
726
|
+
return new TextDecoder().decode(bytes);
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
function loadSourceFrame() {
|
|
730
|
+
const frame = getFrame();
|
|
731
|
+
frame.addEventListener("load", () => {
|
|
732
|
+
installFrameSelectionHandlers();
|
|
733
|
+
renderHighlights();
|
|
734
|
+
renderThreads();
|
|
735
|
+
focusFromLocationHash();
|
|
736
|
+
});
|
|
737
|
+
frame.srcdoc = sourceHtml;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
function reloadSourceFrame() {
|
|
741
|
+
getFrame().srcdoc = sourceHtml;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
function refreshHighlights() {
|
|
745
|
+
const viewport = captureFrameViewport();
|
|
746
|
+
clearHighlights();
|
|
747
|
+
renderHighlights();
|
|
748
|
+
restoreFrameViewport(viewport);
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
function captureFrameViewport() {
|
|
752
|
+
const frame = getFrame();
|
|
753
|
+
const frameWindow = frame.contentWindow;
|
|
754
|
+
const frameDocument = frame.contentDocument;
|
|
755
|
+
if (!frameWindow || !frameDocument || !frameDocument.documentElement) {
|
|
756
|
+
return null;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
const width = frameDocument.documentElement.clientWidth || frameWindow.innerWidth || 0;
|
|
760
|
+
const height = frameDocument.documentElement.clientHeight || frameWindow.innerHeight || 0;
|
|
761
|
+
const points = [
|
|
762
|
+
[0.5, 0.25],
|
|
763
|
+
[0.5, 0.5],
|
|
764
|
+
[0.35, 0.25],
|
|
765
|
+
[0.65, 0.25],
|
|
766
|
+
];
|
|
767
|
+
|
|
768
|
+
for (const [xRatio, yRatio] of points) {
|
|
769
|
+
const element = restorableViewportElement(
|
|
770
|
+
frameDocument,
|
|
771
|
+
frameDocument.elementFromPoint(Math.round(width * xRatio), Math.round(height * yRatio)),
|
|
772
|
+
);
|
|
773
|
+
if (element) {
|
|
774
|
+
return {
|
|
775
|
+
x: frameWindow.scrollX,
|
|
776
|
+
y: frameWindow.scrollY,
|
|
777
|
+
anchor: element,
|
|
778
|
+
anchorTop: element.getBoundingClientRect().top,
|
|
779
|
+
};
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
return {
|
|
784
|
+
x: frameWindow.scrollX,
|
|
785
|
+
y: frameWindow.scrollY,
|
|
786
|
+
};
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
function restorableViewportElement(frameDocument, element) {
|
|
790
|
+
let current = element;
|
|
791
|
+
while (current && current !== frameDocument.body && current !== frameDocument.documentElement) {
|
|
792
|
+
if (
|
|
793
|
+
current.matches?.(
|
|
794
|
+
"mark[data-html-collab-thread],mark[data-html-collab-edit],del,ins,.html-collab-edit-preview-replacement",
|
|
795
|
+
)
|
|
796
|
+
) {
|
|
797
|
+
current = current.parentElement;
|
|
798
|
+
continue;
|
|
799
|
+
}
|
|
800
|
+
return current;
|
|
801
|
+
}
|
|
802
|
+
return null;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
function restoreFrameViewport(viewport) {
|
|
806
|
+
if (!viewport) {
|
|
807
|
+
return;
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
const restore = () => {
|
|
811
|
+
const frameWindow = getFrame().contentWindow;
|
|
812
|
+
if (!frameWindow) {
|
|
813
|
+
return;
|
|
814
|
+
}
|
|
815
|
+
if (viewport.anchor?.isConnected && typeof viewport.anchorTop === "number") {
|
|
816
|
+
const delta = viewport.anchor.getBoundingClientRect().top - viewport.anchorTop;
|
|
817
|
+
frameWindow.scrollTo(viewport.x, frameWindow.scrollY + delta);
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
frameWindow.scrollTo(viewport.x, viewport.y);
|
|
821
|
+
};
|
|
822
|
+
|
|
823
|
+
restore();
|
|
824
|
+
window.requestAnimationFrame(restore);
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
function clearHighlights() {
|
|
828
|
+
const frameDocument = getFrame().contentDocument;
|
|
829
|
+
if (!frameDocument || !frameDocument.body) {
|
|
830
|
+
return;
|
|
831
|
+
}
|
|
832
|
+
const marks = frameDocument.querySelectorAll('mark[data-html-collab-thread],mark[data-html-collab-edit]');
|
|
833
|
+
marks.forEach((mark) => {
|
|
834
|
+
const parent = mark.parentNode;
|
|
835
|
+
if (!parent) {
|
|
836
|
+
return;
|
|
837
|
+
}
|
|
838
|
+
if (mark.dataset.htmlCollabEdit && typeof mark.dataset.htmlCollabOriginal === "string") {
|
|
839
|
+
parent.insertBefore(frameDocument.createTextNode(mark.dataset.htmlCollabOriginal), mark);
|
|
840
|
+
parent.removeChild(mark);
|
|
841
|
+
return;
|
|
842
|
+
}
|
|
843
|
+
while (mark.firstChild) {
|
|
844
|
+
parent.insertBefore(mark.firstChild, mark);
|
|
845
|
+
}
|
|
846
|
+
parent.removeChild(mark);
|
|
847
|
+
});
|
|
848
|
+
frameDocument.body.normalize();
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
function getFrame() {
|
|
852
|
+
const frame = document.getElementById("html-collab-source-frame");
|
|
853
|
+
if (!(frame instanceof HTMLIFrameElement)) {
|
|
854
|
+
throw new Error("Missing html-collab source iframe");
|
|
855
|
+
}
|
|
856
|
+
return frame;
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
function init() {
|
|
860
|
+
const source = readJsonScript(SOURCE_SCRIPT_ID);
|
|
861
|
+
if (source.encoding !== "base64" || typeof source.html !== "string") {
|
|
862
|
+
throw new Error("Unsupported html-collab source payload");
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
sourceHtml = decodeBase64Utf8(source.html);
|
|
866
|
+
state = readJsonScript(STATE_SCRIPT_ID);
|
|
867
|
+
hydrateReviewer();
|
|
868
|
+
hydrateEditView();
|
|
869
|
+
bindShellEvents();
|
|
870
|
+
loadSourceFrame();
|
|
871
|
+
renderThreads();
|
|
872
|
+
updateAutosaveButton();
|
|
873
|
+
focusFromLocationHash();
|
|
874
|
+
setStatus("Ready");
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
function bindShellEvents() {
|
|
878
|
+
const addButton = document.getElementById("html-collab-add-comment");
|
|
879
|
+
const editButton = document.getElementById("html-collab-suggest-edit");
|
|
880
|
+
const autosaveButton = document.getElementById("html-collab-autosave");
|
|
881
|
+
const mergeButton = document.getElementById("html-collab-merge");
|
|
882
|
+
const mergeInput = document.getElementById("html-collab-merge-files");
|
|
883
|
+
const briefButton = document.getElementById("html-collab-brief");
|
|
884
|
+
const reviewerInput = document.getElementById("html-collab-reviewer");
|
|
885
|
+
const editView = document.getElementById("html-collab-edit-view");
|
|
886
|
+
const cancelButton = document.getElementById("html-collab-cancel-comment");
|
|
887
|
+
const submitButton = document.getElementById("html-collab-submit-comment");
|
|
888
|
+
const commentBody = document.getElementById("html-collab-comment-body");
|
|
889
|
+
const editKind = document.getElementById("html-collab-edit-kind");
|
|
890
|
+
const editReplacement = document.getElementById("html-collab-edit-replacement");
|
|
891
|
+
const editNote = document.getElementById("html-collab-edit-note");
|
|
892
|
+
const cancelEditButton = document.getElementById("html-collab-cancel-edit");
|
|
893
|
+
const submitEditButton = document.getElementById("html-collab-submit-edit");
|
|
894
|
+
const contextCommentButton = document.getElementById("html-collab-context-comment");
|
|
895
|
+
const contextEditButton = document.getElementById("html-collab-context-edit");
|
|
896
|
+
const hotkeysButton = document.getElementById("html-collab-hotkeys-button");
|
|
897
|
+
|
|
898
|
+
addButton?.addEventListener("click", () => openCommentComposer());
|
|
899
|
+
editButton?.addEventListener("click", () => openEditComposer());
|
|
900
|
+
contextCommentButton?.addEventListener("click", () => {
|
|
901
|
+
hideSelectionMenu();
|
|
902
|
+
openCommentComposer();
|
|
903
|
+
});
|
|
904
|
+
contextEditButton?.addEventListener("click", () => {
|
|
905
|
+
hideSelectionMenu();
|
|
906
|
+
openEditComposer();
|
|
907
|
+
});
|
|
908
|
+
hotkeysButton?.addEventListener("click", () => toggleHotkeys());
|
|
909
|
+
autosaveButton?.addEventListener("click", () => requestAutosave());
|
|
910
|
+
mergeButton?.addEventListener("click", () => mergeInput?.click());
|
|
911
|
+
briefButton?.addEventListener("click", () => openBriefModal());
|
|
912
|
+
document.getElementById("html-collab-brief-copy")?.addEventListener("click", () => copyBriefToClipboard());
|
|
913
|
+
document.getElementById("html-collab-brief-download")?.addEventListener("click", () => downloadReviewBrief());
|
|
914
|
+
document.getElementById("html-collab-brief-close")?.addEventListener("click", () => closeBriefModal());
|
|
915
|
+
document.getElementById("html-collab-brief-modal")?.addEventListener("click", (event) => {
|
|
916
|
+
if (event.target === event.currentTarget) closeBriefModal();
|
|
917
|
+
});
|
|
918
|
+
mergeInput?.addEventListener("change", () => mergeSelectedFiles(mergeInput));
|
|
919
|
+
cancelButton?.addEventListener("click", () => closeCommentComposer());
|
|
920
|
+
submitButton?.addEventListener("click", () => submitComment());
|
|
921
|
+
commentBody?.addEventListener("keydown", (event) => handleCommentComposerKeydown(event));
|
|
922
|
+
editKind?.addEventListener("change", () => updateEditComposerKind());
|
|
923
|
+
editKind?.addEventListener("keydown", (event) => handleEditComposerKeydown(event));
|
|
924
|
+
editReplacement?.addEventListener("keydown", (event) => handleEditComposerKeydown(event));
|
|
925
|
+
editNote?.addEventListener("keydown", (event) => handleEditComposerKeydown(event));
|
|
926
|
+
cancelEditButton?.addEventListener("click", () => closeEditComposer());
|
|
927
|
+
submitEditButton?.addEventListener("click", () => submitEditSuggestion());
|
|
928
|
+
editView?.addEventListener("change", () => updateEditView(editView));
|
|
929
|
+
window.addEventListener("hashchange", () => focusFromLocationHash());
|
|
930
|
+
window.addEventListener("resize", () => hideSelectionMenu());
|
|
931
|
+
document.addEventListener("click", (event) => {
|
|
932
|
+
const menu = document.getElementById("html-collab-context-menu");
|
|
933
|
+
if (menu instanceof HTMLElement && !menu.contains(event.target)) {
|
|
934
|
+
hideSelectionMenu();
|
|
935
|
+
}
|
|
936
|
+
const hotkeys = document.getElementById("html-collab-hotkeys");
|
|
937
|
+
const hotkeysButton = document.getElementById("html-collab-hotkeys-button");
|
|
938
|
+
if (
|
|
939
|
+
hotkeys instanceof HTMLElement &&
|
|
940
|
+
hotkeysButton instanceof HTMLElement &&
|
|
941
|
+
!hotkeys.hidden &&
|
|
942
|
+
!hotkeys.contains(event.target) &&
|
|
943
|
+
!hotkeysButton.contains(event.target)
|
|
944
|
+
) {
|
|
945
|
+
hideHotkeys();
|
|
946
|
+
}
|
|
947
|
+
});
|
|
948
|
+
document.addEventListener("keydown", (event) => handleReviewShortcut(event));
|
|
949
|
+
reviewerInput?.addEventListener("change", () => {
|
|
950
|
+
if (!currentReviewerName()) {
|
|
951
|
+
return;
|
|
952
|
+
}
|
|
953
|
+
const actor = ensureActor();
|
|
954
|
+
if (!actor) {
|
|
955
|
+
return;
|
|
956
|
+
}
|
|
957
|
+
actor.name = currentReviewerName();
|
|
958
|
+
state.actors[actor.actorId] = actor;
|
|
959
|
+
persistState();
|
|
960
|
+
renderThreads();
|
|
961
|
+
});
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
function hydrateReviewer() {
|
|
965
|
+
const input = document.getElementById("html-collab-reviewer");
|
|
966
|
+
if (!(input instanceof HTMLInputElement)) {
|
|
967
|
+
return;
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
const storedName = localStorage.getItem("html-collab.reviewerName");
|
|
971
|
+
input.value = storedName || "";
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
function hydrateEditView() {
|
|
975
|
+
const input = document.getElementById("html-collab-edit-view");
|
|
976
|
+
const stored = localStorage.getItem(EDIT_VIEW_STORAGE_KEY);
|
|
977
|
+
editViewMode = stored === "preview" ? "preview" : "markup";
|
|
978
|
+
if (input instanceof HTMLSelectElement) {
|
|
979
|
+
input.value = editViewMode;
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
function updateEditView(input) {
|
|
984
|
+
if (!(input instanceof HTMLSelectElement)) {
|
|
985
|
+
return;
|
|
986
|
+
}
|
|
987
|
+
editViewMode = input.value === "preview" ? "preview" : "markup";
|
|
988
|
+
localStorage.setItem(EDIT_VIEW_STORAGE_KEY, editViewMode);
|
|
989
|
+
refreshHighlights();
|
|
990
|
+
setStatus(editViewMode === "preview" ? "Previewing replacements" : "Showing tracked changes");
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
function installFrameSelectionHandlers() {
|
|
994
|
+
const frame = getFrame();
|
|
995
|
+
const frameDocument = frame.contentDocument;
|
|
996
|
+
if (!frameDocument) {
|
|
997
|
+
return;
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
frameDocument.addEventListener("mouseup", captureSelection);
|
|
1001
|
+
frameDocument.addEventListener("keyup", captureSelection);
|
|
1002
|
+
frameDocument.addEventListener("selectionchange", captureSelection);
|
|
1003
|
+
frameDocument.addEventListener("contextmenu", showSelectionMenu);
|
|
1004
|
+
frameDocument.addEventListener("keydown", (event) => handleReviewShortcut(event));
|
|
1005
|
+
frameDocument.addEventListener("scroll", () => hideSelectionMenu(), true);
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
function captureSelection() {
|
|
1009
|
+
const anchor = getSelectionAnchor();
|
|
1010
|
+
selectedAnchor = anchor;
|
|
1011
|
+
if (!anchor) {
|
|
1012
|
+
hideSelectionMenu();
|
|
1013
|
+
}
|
|
1014
|
+
const addButton = document.getElementById("html-collab-add-comment");
|
|
1015
|
+
const editButton = document.getElementById("html-collab-suggest-edit");
|
|
1016
|
+
if (addButton instanceof HTMLButtonElement) {
|
|
1017
|
+
addButton.disabled = !anchor;
|
|
1018
|
+
}
|
|
1019
|
+
if (editButton instanceof HTMLButtonElement) {
|
|
1020
|
+
editButton.disabled = !anchor;
|
|
1021
|
+
}
|
|
1022
|
+
if (anchor) {
|
|
1023
|
+
setStatus("Text selected");
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
function showSelectionMenu(event) {
|
|
1028
|
+
const anchor = getSelectionAnchor();
|
|
1029
|
+
selectedAnchor = anchor;
|
|
1030
|
+
if (!anchor) {
|
|
1031
|
+
hideSelectionMenu();
|
|
1032
|
+
return;
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
event.preventDefault();
|
|
1036
|
+
event.stopPropagation();
|
|
1037
|
+
setSelectionButtonsEnabled(true);
|
|
1038
|
+
|
|
1039
|
+
const frame = getFrame();
|
|
1040
|
+
const menu = document.getElementById("html-collab-context-menu");
|
|
1041
|
+
if (!(menu instanceof HTMLElement)) {
|
|
1042
|
+
return;
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
const frameRect = frame.getBoundingClientRect();
|
|
1046
|
+
menu.hidden = false;
|
|
1047
|
+
const left = frameRect.left + event.clientX;
|
|
1048
|
+
const top = frameRect.top + event.clientY;
|
|
1049
|
+
const width = menu.offsetWidth || 128;
|
|
1050
|
+
const height = menu.offsetHeight || 82;
|
|
1051
|
+
menu.style.left = Math.min(left, window.innerWidth - width - 8) + "px";
|
|
1052
|
+
menu.style.top = Math.min(top, window.innerHeight - height - 8) + "px";
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
function hideSelectionMenu() {
|
|
1056
|
+
const menu = document.getElementById("html-collab-context-menu");
|
|
1057
|
+
if (menu instanceof HTMLElement) {
|
|
1058
|
+
menu.hidden = true;
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
function toggleHotkeys() {
|
|
1063
|
+
const hotkeys = document.getElementById("html-collab-hotkeys");
|
|
1064
|
+
const button = document.getElementById("html-collab-hotkeys-button");
|
|
1065
|
+
if (!(hotkeys instanceof HTMLElement)) {
|
|
1066
|
+
return;
|
|
1067
|
+
}
|
|
1068
|
+
hotkeys.hidden = !hotkeys.hidden;
|
|
1069
|
+
if (button instanceof HTMLButtonElement) {
|
|
1070
|
+
button.setAttribute("aria-expanded", hotkeys.hidden ? "false" : "true");
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
function hideHotkeys() {
|
|
1075
|
+
const hotkeys = document.getElementById("html-collab-hotkeys");
|
|
1076
|
+
const button = document.getElementById("html-collab-hotkeys-button");
|
|
1077
|
+
if (hotkeys instanceof HTMLElement) {
|
|
1078
|
+
hotkeys.hidden = true;
|
|
1079
|
+
}
|
|
1080
|
+
if (button instanceof HTMLButtonElement) {
|
|
1081
|
+
button.setAttribute("aria-expanded", "false");
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
function handleReviewShortcut(event) {
|
|
1086
|
+
if (event.defaultPrevented || isEditableEventTarget(event.target)) {
|
|
1087
|
+
return;
|
|
1088
|
+
}
|
|
1089
|
+
const key = event.key.toLowerCase();
|
|
1090
|
+
if (!selectedAnchor && (key === "c" || key === "e")) {
|
|
1091
|
+
selectedAnchor = getSelectionAnchor();
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
if (key === "escape") {
|
|
1095
|
+
const briefModal = document.getElementById("html-collab-brief-modal");
|
|
1096
|
+
if (briefModal && !briefModal.hidden) {
|
|
1097
|
+
closeBriefModal();
|
|
1098
|
+
return;
|
|
1099
|
+
}
|
|
1100
|
+
hideSelectionMenu();
|
|
1101
|
+
hideHotkeys();
|
|
1102
|
+
closeCommentComposer();
|
|
1103
|
+
closeEditComposer();
|
|
1104
|
+
return;
|
|
1105
|
+
}
|
|
1106
|
+
if (!selectedAnchor || event.metaKey || event.ctrlKey || event.altKey) {
|
|
1107
|
+
return;
|
|
1108
|
+
}
|
|
1109
|
+
if (key === "c") {
|
|
1110
|
+
event.preventDefault();
|
|
1111
|
+
hideSelectionMenu();
|
|
1112
|
+
openCommentComposer();
|
|
1113
|
+
return;
|
|
1114
|
+
}
|
|
1115
|
+
if (key === "e") {
|
|
1116
|
+
event.preventDefault();
|
|
1117
|
+
hideSelectionMenu();
|
|
1118
|
+
openEditComposer();
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
function handleCommentComposerKeydown(event) {
|
|
1123
|
+
if (event.isComposing) {
|
|
1124
|
+
return;
|
|
1125
|
+
}
|
|
1126
|
+
if (event.key === "Escape") {
|
|
1127
|
+
event.preventDefault();
|
|
1128
|
+
closeCommentComposer();
|
|
1129
|
+
setStatus("Comment canceled");
|
|
1130
|
+
return;
|
|
1131
|
+
}
|
|
1132
|
+
if (event.key === "Enter" && !event.shiftKey) {
|
|
1133
|
+
event.preventDefault();
|
|
1134
|
+
submitComment();
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
function handleEditComposerKeydown(event) {
|
|
1139
|
+
if (event.isComposing) {
|
|
1140
|
+
return;
|
|
1141
|
+
}
|
|
1142
|
+
if (event.key === "Escape") {
|
|
1143
|
+
event.preventDefault();
|
|
1144
|
+
closeEditComposer();
|
|
1145
|
+
setStatus("Edit canceled");
|
|
1146
|
+
return;
|
|
1147
|
+
}
|
|
1148
|
+
if (event.key === "Enter" && !event.shiftKey) {
|
|
1149
|
+
event.preventDefault();
|
|
1150
|
+
submitEditSuggestion();
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
function isInteractiveClickTarget(target) {
|
|
1155
|
+
if (!(target instanceof Element)) {
|
|
1156
|
+
return false;
|
|
1157
|
+
}
|
|
1158
|
+
return Boolean(target.closest("button, textarea, input, select, a, label"));
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
function isEditableEventTarget(target) {
|
|
1162
|
+
if (!target || target.nodeType !== Node.ELEMENT_NODE) {
|
|
1163
|
+
return false;
|
|
1164
|
+
}
|
|
1165
|
+
const tagName = target.tagName.toLowerCase();
|
|
1166
|
+
return tagName === "input" || tagName === "textarea" || tagName === "select" || target.isContentEditable;
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
function setSelectionButtonsEnabled(enabled) {
|
|
1170
|
+
const addButton = document.getElementById("html-collab-add-comment");
|
|
1171
|
+
const editButton = document.getElementById("html-collab-suggest-edit");
|
|
1172
|
+
if (addButton instanceof HTMLButtonElement) {
|
|
1173
|
+
addButton.disabled = !enabled;
|
|
1174
|
+
}
|
|
1175
|
+
if (editButton instanceof HTMLButtonElement) {
|
|
1176
|
+
editButton.disabled = !enabled;
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
function getSelectionAnchor() {
|
|
1181
|
+
const frame = getFrame();
|
|
1182
|
+
const frameDocument = frame.contentDocument;
|
|
1183
|
+
const frameWindow = frame.contentWindow;
|
|
1184
|
+
if (!frameDocument || !frameWindow) {
|
|
1185
|
+
return null;
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
const selection = frameWindow.getSelection();
|
|
1189
|
+
if (!selection || selection.rangeCount === 0 || selection.isCollapsed) {
|
|
1190
|
+
return null;
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
const range = selection.getRangeAt(0);
|
|
1194
|
+
const quote = selection.toString();
|
|
1195
|
+
if (!quote.trim()) {
|
|
1196
|
+
return null;
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
const fullText = frameDocument.body?.textContent || "";
|
|
1200
|
+
const start = textOffsetForRangeBoundary(frameDocument.body, range.startContainer, range.startOffset);
|
|
1201
|
+
const end = textOffsetForRangeBoundary(frameDocument.body, range.endContainer, range.endOffset);
|
|
1202
|
+
const safeStart = Math.max(0, start);
|
|
1203
|
+
const safeEnd = Math.max(safeStart, end);
|
|
1204
|
+
|
|
1205
|
+
return {
|
|
1206
|
+
kind: "text",
|
|
1207
|
+
quote,
|
|
1208
|
+
prefix: fullText.slice(Math.max(0, safeStart - 40), safeStart),
|
|
1209
|
+
suffix: fullText.slice(safeEnd, Math.min(fullText.length, safeEnd + 40)),
|
|
1210
|
+
position: {
|
|
1211
|
+
start: safeStart,
|
|
1212
|
+
end: safeEnd,
|
|
1213
|
+
},
|
|
1214
|
+
elementFingerprint: elementFingerprint(range.commonAncestorContainer),
|
|
1215
|
+
headingPath: headingPathForNode(frameDocument, range.commonAncestorContainer),
|
|
1216
|
+
};
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
function headingPathForNode(frameDocument, node) {
|
|
1220
|
+
if (!frameDocument || !frameDocument.body || !node) {
|
|
1221
|
+
return [];
|
|
1222
|
+
}
|
|
1223
|
+
const target = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement;
|
|
1224
|
+
if (!target) {
|
|
1225
|
+
return [];
|
|
1226
|
+
}
|
|
1227
|
+
const headings = frameDocument.body.querySelectorAll("h1,h2,h3,h4,h5,h6");
|
|
1228
|
+
const stack = [];
|
|
1229
|
+
headings.forEach((heading) => {
|
|
1230
|
+
if (heading === target) {
|
|
1231
|
+
return;
|
|
1232
|
+
}
|
|
1233
|
+
const relation = heading.compareDocumentPosition(target);
|
|
1234
|
+
const headingPrecedesTarget = (relation & Node.DOCUMENT_POSITION_FOLLOWING) !== 0
|
|
1235
|
+
|| (relation & Node.DOCUMENT_POSITION_CONTAINED_BY) !== 0;
|
|
1236
|
+
if (!headingPrecedesTarget) {
|
|
1237
|
+
return;
|
|
1238
|
+
}
|
|
1239
|
+
const level = parseInt(heading.tagName.slice(1), 10) || 1;
|
|
1240
|
+
while (stack.length > 0 && stack[stack.length - 1].level >= level) {
|
|
1241
|
+
stack.pop();
|
|
1242
|
+
}
|
|
1243
|
+
stack.push({ level, heading });
|
|
1244
|
+
});
|
|
1245
|
+
return stack
|
|
1246
|
+
.map((entry) => (entry.heading.textContent || "").replace(/\s+/g, " ").trim())
|
|
1247
|
+
.filter((text) => text.length > 0)
|
|
1248
|
+
.slice(-4)
|
|
1249
|
+
.map((text) => (text.length > 80 ? text.slice(0, 79) + "…" : text));
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
function textOffsetForRangeBoundary(root, targetNode, targetOffset) {
|
|
1253
|
+
if (!root) {
|
|
1254
|
+
return 0;
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
let offset = 0;
|
|
1258
|
+
const walker = root.ownerDocument.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
|
1259
|
+
let node = walker.nextNode();
|
|
1260
|
+
while (node) {
|
|
1261
|
+
if (node === targetNode) {
|
|
1262
|
+
return offset + targetOffset;
|
|
1263
|
+
}
|
|
1264
|
+
offset += node.textContent.length;
|
|
1265
|
+
node = walker.nextNode();
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
return offset;
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
function elementFingerprint(node) {
|
|
1272
|
+
const element = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement;
|
|
1273
|
+
if (!element) {
|
|
1274
|
+
return "";
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
const parts = [];
|
|
1278
|
+
let current = element;
|
|
1279
|
+
while (current && current.tagName && parts.length < 4) {
|
|
1280
|
+
parts.unshift(current.tagName.toLowerCase());
|
|
1281
|
+
current = current.parentElement;
|
|
1282
|
+
}
|
|
1283
|
+
return parts.join("/");
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
function openCommentComposer() {
|
|
1287
|
+
if (!selectedAnchor) {
|
|
1288
|
+
setStatus("Select text in the report first");
|
|
1289
|
+
return;
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
const composer = document.getElementById("html-collab-composer");
|
|
1293
|
+
const quote = document.getElementById("html-collab-selected-quote");
|
|
1294
|
+
const body = document.getElementById("html-collab-comment-body");
|
|
1295
|
+
if (!(composer instanceof HTMLElement) || !(quote instanceof HTMLElement)) {
|
|
1296
|
+
return;
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
closeEditComposer();
|
|
1300
|
+
quote.textContent = selectedAnchor.quote;
|
|
1301
|
+
composer.hidden = false;
|
|
1302
|
+
if (body instanceof HTMLTextAreaElement) {
|
|
1303
|
+
body.value = "";
|
|
1304
|
+
body.focus();
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
function closeCommentComposer() {
|
|
1309
|
+
const composer = document.getElementById("html-collab-composer");
|
|
1310
|
+
if (composer instanceof HTMLElement) {
|
|
1311
|
+
composer.hidden = true;
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
function openEditComposer() {
|
|
1316
|
+
if (!selectedAnchor) {
|
|
1317
|
+
setStatus("Select text in the report first");
|
|
1318
|
+
return;
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
const composer = document.getElementById("html-collab-edit-composer");
|
|
1322
|
+
const quote = document.getElementById("html-collab-edit-selected-quote");
|
|
1323
|
+
const replacement = document.getElementById("html-collab-edit-replacement");
|
|
1324
|
+
const note = document.getElementById("html-collab-edit-note");
|
|
1325
|
+
const kind = document.getElementById("html-collab-edit-kind");
|
|
1326
|
+
if (!(composer instanceof HTMLElement) || !(quote instanceof HTMLElement)) {
|
|
1327
|
+
return;
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
closeCommentComposer();
|
|
1331
|
+
quote.textContent = selectedAnchor.quote;
|
|
1332
|
+
composer.hidden = false;
|
|
1333
|
+
if (kind instanceof HTMLSelectElement) {
|
|
1334
|
+
kind.value = "replace";
|
|
1335
|
+
}
|
|
1336
|
+
if (replacement instanceof HTMLTextAreaElement) {
|
|
1337
|
+
replacement.value = "";
|
|
1338
|
+
replacement.focus();
|
|
1339
|
+
}
|
|
1340
|
+
if (note instanceof HTMLTextAreaElement) {
|
|
1341
|
+
note.value = "";
|
|
1342
|
+
}
|
|
1343
|
+
updateEditComposerKind();
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
function closeEditComposer() {
|
|
1347
|
+
const composer = document.getElementById("html-collab-edit-composer");
|
|
1348
|
+
if (composer instanceof HTMLElement) {
|
|
1349
|
+
composer.hidden = true;
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
function updateEditComposerKind() {
|
|
1354
|
+
const kind = document.getElementById("html-collab-edit-kind");
|
|
1355
|
+
const replacement = document.getElementById("html-collab-edit-replacement");
|
|
1356
|
+
if (!(kind instanceof HTMLSelectElement) || !(replacement instanceof HTMLTextAreaElement)) {
|
|
1357
|
+
return;
|
|
1358
|
+
}
|
|
1359
|
+
const isDelete = kind.value === "delete";
|
|
1360
|
+
replacement.disabled = isDelete;
|
|
1361
|
+
replacement.placeholder = isDelete ? "No replacement for delete suggestions" : "Replacement or inserted text";
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
function submitComment() {
|
|
1365
|
+
const body = document.getElementById("html-collab-comment-body");
|
|
1366
|
+
if (!(body instanceof HTMLTextAreaElement) || !selectedAnchor) {
|
|
1367
|
+
return;
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
const text = body.value.trim();
|
|
1371
|
+
if (!text) {
|
|
1372
|
+
setStatus("Comment body is empty");
|
|
1373
|
+
return;
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
const threadId = createId("thread");
|
|
1377
|
+
const op = addOp("comment.create", selectedAnchor, {
|
|
1378
|
+
threadId,
|
|
1379
|
+
body: text,
|
|
1380
|
+
});
|
|
1381
|
+
if (!op) {
|
|
1382
|
+
return;
|
|
1383
|
+
}
|
|
1384
|
+
closeCommentComposer();
|
|
1385
|
+
selectedAnchor = null;
|
|
1386
|
+
refreshHighlights();
|
|
1387
|
+
renderThreads();
|
|
1388
|
+
setStatus("Comment added");
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
function submitEditSuggestion() {
|
|
1392
|
+
if (!selectedAnchor) {
|
|
1393
|
+
return;
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
const kindInput = document.getElementById("html-collab-edit-kind");
|
|
1397
|
+
const replacementInput = document.getElementById("html-collab-edit-replacement");
|
|
1398
|
+
const noteInput = document.getElementById("html-collab-edit-note");
|
|
1399
|
+
if (!(kindInput instanceof HTMLSelectElement) || !(replacementInput instanceof HTMLTextAreaElement)) {
|
|
1400
|
+
return;
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
const kind = kindInput.value;
|
|
1404
|
+
const replacement = replacementInput.value.trim();
|
|
1405
|
+
const note = noteInput instanceof HTMLTextAreaElement ? noteInput.value.trim() : "";
|
|
1406
|
+
if (kind !== "replace" && kind !== "insert" && kind !== "delete") {
|
|
1407
|
+
setStatus("Choose an edit type");
|
|
1408
|
+
return;
|
|
1409
|
+
}
|
|
1410
|
+
if (kind !== "delete" && !replacement) {
|
|
1411
|
+
setStatus("Suggested text is empty");
|
|
1412
|
+
return;
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
const payload = {
|
|
1416
|
+
editId: createId("edit"),
|
|
1417
|
+
kind,
|
|
1418
|
+
};
|
|
1419
|
+
if (kind !== "delete") {
|
|
1420
|
+
payload.replacement = replacement;
|
|
1421
|
+
}
|
|
1422
|
+
if (note) {
|
|
1423
|
+
payload.note = note;
|
|
1424
|
+
}
|
|
1425
|
+
const op = addOp("edit.suggest", selectedAnchor, payload);
|
|
1426
|
+
if (!op) {
|
|
1427
|
+
return;
|
|
1428
|
+
}
|
|
1429
|
+
closeEditComposer();
|
|
1430
|
+
selectedAnchor = null;
|
|
1431
|
+
refreshHighlights();
|
|
1432
|
+
renderThreads();
|
|
1433
|
+
setStatus("Edit suggested");
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
function renderHighlights() {
|
|
1437
|
+
const frameDocument = getFrame().contentDocument;
|
|
1438
|
+
if (!frameDocument) {
|
|
1439
|
+
return;
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
installHighlightStyles(frameDocument);
|
|
1443
|
+
const reduced = reduceState(state);
|
|
1444
|
+
reduced.threads.forEach((thread, index) => {
|
|
1445
|
+
if (thread.root.deleted) {
|
|
1446
|
+
return;
|
|
1447
|
+
}
|
|
1448
|
+
const className = thread.status === "resolved" ? "html-collab-mark-resolved" : "html-collab-mark-open";
|
|
1449
|
+
highlightAnchor(frameDocument, thread.anchor, thread.threadId, String(index + 1), className);
|
|
1450
|
+
});
|
|
1451
|
+
reduced.edits.forEach((edit, index) => {
|
|
1452
|
+
if (edit.status === "deleted") {
|
|
1453
|
+
return;
|
|
1454
|
+
}
|
|
1455
|
+
const className = "html-collab-edit-" + edit.status + " html-collab-edit-" + edit.kind;
|
|
1456
|
+
highlightEdit(frameDocument, edit, String(index + 1), className);
|
|
1457
|
+
});
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
function installHighlightStyles(frameDocument) {
|
|
1461
|
+
if (frameDocument.getElementById("html-collab-highlight-styles")) {
|
|
1462
|
+
return;
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
const style = frameDocument.createElement("style");
|
|
1466
|
+
style.id = "html-collab-highlight-styles";
|
|
1467
|
+
style.textContent = [
|
|
1468
|
+
"mark[data-html-collab-thread]{padding:0 2px;border-radius:3px;cursor:pointer;}",
|
|
1469
|
+
"mark.html-collab-mark-open{background:#fff08a;box-shadow:0 0 0 1px rgba(184,134,11,.22);}",
|
|
1470
|
+
"mark.html-collab-mark-resolved{background:#dbeafe;box-shadow:0 0 0 1px rgba(37,99,235,.18);}",
|
|
1471
|
+
"mark[data-html-collab-edit]{padding:0 2px;border-radius:3px;cursor:pointer;box-shadow:0 0 0 1px rgba(22,101,52,.2);}",
|
|
1472
|
+
"mark.html-collab-edit-open{background:#dcfce7;}",
|
|
1473
|
+
"mark.html-collab-edit-accepted{background:#bbf7d0;}",
|
|
1474
|
+
"mark.html-collab-edit-rejected{background:#f1f5f9;color:#64748b;text-decoration:line-through;}",
|
|
1475
|
+
"mark.html-collab-edit-delete{text-decoration:line-through;text-decoration-thickness:2px;}",
|
|
1476
|
+
"html{overflow-anchor:none;}",
|
|
1477
|
+
".html-collab-edit-original{text-decoration:line-through;text-decoration-thickness:2px;color:#64748b;}",
|
|
1478
|
+
".html-collab-edit-inline-replacement{background:#bbf7d0;color:#14532d;text-decoration:none;padding:0 2px;border-radius:3px;}",
|
|
1479
|
+
".html-collab-edit-inline-replacement::before{content:' ';}",
|
|
1480
|
+
".html-collab-edit-preview-replacement{background:#bbf7d0;color:#14532d;text-decoration:none;padding:0 2px;border-radius:3px;}",
|
|
1481
|
+
"mark.html-collab-mark-active{outline:2px solid #2563eb;outline-offset:3px;border-radius:3px;animation:html-collab-mark-pulse 1400ms ease-out;}",
|
|
1482
|
+
"@keyframes html-collab-mark-pulse{0%{box-shadow:0 0 0 0 rgba(37,99,235,.55);}40%{box-shadow:0 0 0 8px rgba(37,99,235,.15);}100%{box-shadow:0 0 0 0 rgba(37,99,235,0);}}",
|
|
1483
|
+
].join("");
|
|
1484
|
+
frameDocument.head?.appendChild(style);
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
function highlightEdit(frameDocument, edit, number, className) {
|
|
1488
|
+
if (!edit || !edit.anchor) {
|
|
1489
|
+
return false;
|
|
1490
|
+
}
|
|
1491
|
+
if (edit.anchor.position && wrapEditRangeByOffsets(frameDocument, edit.anchor.position.start, edit.anchor.position.end, edit, number, className)) {
|
|
1492
|
+
return true;
|
|
1493
|
+
}
|
|
1494
|
+
return wrapFirstEditQuote(frameDocument, edit.anchor.quote, edit, number, className);
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
function highlightAnchor(frameDocument, anchor, threadId, number, className) {
|
|
1498
|
+
if (anchor.position && wrapRangeByOffsets(frameDocument, anchor.position.start, anchor.position.end, threadId, number, className)) {
|
|
1499
|
+
return true;
|
|
1500
|
+
}
|
|
1501
|
+
return wrapFirstQuote(frameDocument, anchor.quote, threadId, number, className);
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
function wrapRangeByOffsets(frameDocument, start, end, threadId, number, className) {
|
|
1505
|
+
if (end <= start) {
|
|
1506
|
+
return false;
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
const body = frameDocument.body;
|
|
1510
|
+
const range = frameDocument.createRange();
|
|
1511
|
+
let offset = 0;
|
|
1512
|
+
let foundStart = false;
|
|
1513
|
+
let foundEnd = false;
|
|
1514
|
+
const walker = frameDocument.createTreeWalker(body, NodeFilter.SHOW_TEXT);
|
|
1515
|
+
let node = walker.nextNode();
|
|
1516
|
+
while (node) {
|
|
1517
|
+
const nextOffset = offset + node.textContent.length;
|
|
1518
|
+
if (!foundStart && start >= offset && start <= nextOffset) {
|
|
1519
|
+
range.setStart(node, start - offset);
|
|
1520
|
+
foundStart = true;
|
|
1521
|
+
}
|
|
1522
|
+
if (foundStart && end >= offset && end <= nextOffset) {
|
|
1523
|
+
range.setEnd(node, end - offset);
|
|
1524
|
+
foundEnd = true;
|
|
1525
|
+
break;
|
|
1526
|
+
}
|
|
1527
|
+
offset = nextOffset;
|
|
1528
|
+
node = walker.nextNode();
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
if (!foundStart || !foundEnd || range.collapsed) {
|
|
1532
|
+
return false;
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
return surroundRange(frameDocument, range, threadId, number, className);
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
function wrapFirstQuote(frameDocument, quote, threadId, number, className) {
|
|
1539
|
+
const body = frameDocument.body;
|
|
1540
|
+
const walker = frameDocument.createTreeWalker(body, NodeFilter.SHOW_TEXT);
|
|
1541
|
+
let node = walker.nextNode();
|
|
1542
|
+
while (node) {
|
|
1543
|
+
const index = node.textContent.indexOf(quote);
|
|
1544
|
+
if (index !== -1) {
|
|
1545
|
+
const range = frameDocument.createRange();
|
|
1546
|
+
range.setStart(node, index);
|
|
1547
|
+
range.setEnd(node, index + quote.length);
|
|
1548
|
+
return surroundRange(frameDocument, range, threadId, number, className);
|
|
1549
|
+
}
|
|
1550
|
+
node = walker.nextNode();
|
|
1551
|
+
}
|
|
1552
|
+
return false;
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
function wrapEditRangeByOffsets(frameDocument, start, end, edit, number, className) {
|
|
1556
|
+
if (end <= start) {
|
|
1557
|
+
return false;
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
const body = frameDocument.body;
|
|
1561
|
+
const range = frameDocument.createRange();
|
|
1562
|
+
let offset = 0;
|
|
1563
|
+
let foundStart = false;
|
|
1564
|
+
let foundEnd = false;
|
|
1565
|
+
const walker = frameDocument.createTreeWalker(body, NodeFilter.SHOW_TEXT);
|
|
1566
|
+
let node = walker.nextNode();
|
|
1567
|
+
while (node) {
|
|
1568
|
+
const nextOffset = offset + node.textContent.length;
|
|
1569
|
+
if (!foundStart && start >= offset && start <= nextOffset) {
|
|
1570
|
+
range.setStart(node, start - offset);
|
|
1571
|
+
foundStart = true;
|
|
1572
|
+
}
|
|
1573
|
+
if (foundStart && end >= offset && end <= nextOffset) {
|
|
1574
|
+
range.setEnd(node, end - offset);
|
|
1575
|
+
foundEnd = true;
|
|
1576
|
+
break;
|
|
1577
|
+
}
|
|
1578
|
+
offset = nextOffset;
|
|
1579
|
+
node = walker.nextNode();
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
if (!foundStart || !foundEnd || range.collapsed) {
|
|
1583
|
+
return false;
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
return surroundEditRange(frameDocument, range, edit, number, className);
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
function wrapFirstEditQuote(frameDocument, quote, edit, number, className) {
|
|
1590
|
+
const body = frameDocument.body;
|
|
1591
|
+
const walker = frameDocument.createTreeWalker(body, NodeFilter.SHOW_TEXT);
|
|
1592
|
+
let node = walker.nextNode();
|
|
1593
|
+
while (node) {
|
|
1594
|
+
const index = node.textContent.indexOf(quote);
|
|
1595
|
+
if (index !== -1) {
|
|
1596
|
+
const range = frameDocument.createRange();
|
|
1597
|
+
range.setStart(node, index);
|
|
1598
|
+
range.setEnd(node, index + quote.length);
|
|
1599
|
+
return surroundEditRange(frameDocument, range, edit, number, className);
|
|
1600
|
+
}
|
|
1601
|
+
node = walker.nextNode();
|
|
1602
|
+
}
|
|
1603
|
+
return false;
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
function surroundRange(frameDocument, range, threadId, number, className) {
|
|
1607
|
+
const mark = frameDocument.createElement("mark");
|
|
1608
|
+
mark.dataset.htmlCollabThread = threadId;
|
|
1609
|
+
mark.dataset.htmlCollabNumber = number;
|
|
1610
|
+
mark.className = className;
|
|
1611
|
+
mark.addEventListener("click", () => focusThread(threadId));
|
|
1612
|
+
|
|
1613
|
+
try {
|
|
1614
|
+
range.surroundContents(mark);
|
|
1615
|
+
return true;
|
|
1616
|
+
} catch {
|
|
1617
|
+
const contents = range.extractContents();
|
|
1618
|
+
mark.appendChild(contents);
|
|
1619
|
+
range.insertNode(mark);
|
|
1620
|
+
return true;
|
|
1621
|
+
}
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
function surroundEditRange(frameDocument, range, edit, number, className) {
|
|
1625
|
+
const mark = frameDocument.createElement("mark");
|
|
1626
|
+
mark.dataset.htmlCollabEdit = edit.editId;
|
|
1627
|
+
mark.dataset.htmlCollabNumber = number;
|
|
1628
|
+
mark.dataset.htmlCollabOriginal = range.toString();
|
|
1629
|
+
if (edit.replacement) {
|
|
1630
|
+
mark.dataset.htmlCollabReplacement = edit.replacement;
|
|
1631
|
+
}
|
|
1632
|
+
mark.className = className;
|
|
1633
|
+
mark.addEventListener("click", () => focusEdit(edit.editId));
|
|
1634
|
+
|
|
1635
|
+
try {
|
|
1636
|
+
const contents = range.extractContents();
|
|
1637
|
+
appendTrackedEditContents(frameDocument, mark, contents, edit);
|
|
1638
|
+
range.insertNode(mark);
|
|
1639
|
+
return true;
|
|
1640
|
+
} catch {
|
|
1641
|
+
return false;
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
function appendTrackedEditContents(frameDocument, mark, contents, edit) {
|
|
1646
|
+
if (editViewMode === "preview" && edit.status !== "rejected") {
|
|
1647
|
+
appendPreviewEditContents(frameDocument, mark, contents, edit);
|
|
1648
|
+
return;
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
if (edit.kind === "replace") {
|
|
1652
|
+
const original = frameDocument.createElement("del");
|
|
1653
|
+
original.className = "html-collab-edit-original";
|
|
1654
|
+
original.appendChild(contents);
|
|
1655
|
+
mark.appendChild(original);
|
|
1656
|
+
appendInlineReplacement(frameDocument, mark, edit.replacement || "");
|
|
1657
|
+
return;
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1660
|
+
mark.appendChild(contents);
|
|
1661
|
+
if (edit.kind === "insert") {
|
|
1662
|
+
appendInlineReplacement(frameDocument, mark, edit.replacement || "");
|
|
1663
|
+
}
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
function appendInlineReplacement(frameDocument, mark, text) {
|
|
1667
|
+
const replacement = frameDocument.createElement("ins");
|
|
1668
|
+
replacement.className = "html-collab-edit-inline-replacement";
|
|
1669
|
+
replacement.textContent = text;
|
|
1670
|
+
mark.appendChild(replacement);
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
function appendPreviewEditContents(frameDocument, mark, contents, edit) {
|
|
1674
|
+
if (edit.kind === "replace") {
|
|
1675
|
+
const replacement = frameDocument.createElement("span");
|
|
1676
|
+
replacement.className = "html-collab-edit-preview-replacement";
|
|
1677
|
+
replacement.textContent = edit.replacement || "";
|
|
1678
|
+
mark.appendChild(replacement);
|
|
1679
|
+
return;
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
if (edit.kind === "insert") {
|
|
1683
|
+
mark.appendChild(contents);
|
|
1684
|
+
appendInlineReplacement(frameDocument, mark, edit.replacement || "");
|
|
1685
|
+
return;
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
mark.textContent = "";
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1691
|
+
function renderThreads() {
|
|
1692
|
+
const list = document.getElementById("html-collab-thread-list");
|
|
1693
|
+
const empty = document.getElementById("html-collab-empty");
|
|
1694
|
+
if (!(list instanceof HTMLElement)) {
|
|
1695
|
+
return;
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
const reduced = reduceState(state);
|
|
1699
|
+
list.textContent = "";
|
|
1700
|
+
if (empty instanceof HTMLElement) {
|
|
1701
|
+
empty.hidden = reduced.threads.length + reduced.edits.length > 0;
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1704
|
+
const frame = document.getElementById("html-collab-source-frame");
|
|
1705
|
+
const frameDoc = frame instanceof HTMLIFrameElement ? frame.contentDocument : null;
|
|
1706
|
+
const docText = frameDoc && frameDoc.body ? frameDoc.body.textContent || "" : "";
|
|
1707
|
+
|
|
1708
|
+
const items = [];
|
|
1709
|
+
reduced.threads.forEach((thread) => {
|
|
1710
|
+
items.push({
|
|
1711
|
+
kind: "thread",
|
|
1712
|
+
data: thread,
|
|
1713
|
+
position: anchorPosition(thread.anchor, docText),
|
|
1714
|
+
createdAt: thread.createdAt,
|
|
1715
|
+
});
|
|
1716
|
+
});
|
|
1717
|
+
reduced.edits.forEach((edit) => {
|
|
1718
|
+
items.push({
|
|
1719
|
+
kind: "edit",
|
|
1720
|
+
data: edit,
|
|
1721
|
+
position: anchorPosition(edit.anchor, docText, edit.replacement),
|
|
1722
|
+
createdAt: edit.createdAt,
|
|
1723
|
+
});
|
|
1724
|
+
});
|
|
1725
|
+
|
|
1726
|
+
items.sort((left, right) => {
|
|
1727
|
+
const leftPos = left.position == null ? Infinity : left.position;
|
|
1728
|
+
const rightPos = right.position == null ? Infinity : right.position;
|
|
1729
|
+
if (leftPos !== rightPos) {
|
|
1730
|
+
return leftPos - rightPos;
|
|
1731
|
+
}
|
|
1732
|
+
return left.createdAt.localeCompare(right.createdAt);
|
|
1733
|
+
});
|
|
1734
|
+
|
|
1735
|
+
items.forEach((item, index) => {
|
|
1736
|
+
const number = index + 1;
|
|
1737
|
+
if (item.kind === "thread") {
|
|
1738
|
+
list.appendChild(renderThread(item.data, number));
|
|
1739
|
+
} else {
|
|
1740
|
+
list.appendChild(renderEdit(item.data, number));
|
|
1741
|
+
}
|
|
1742
|
+
});
|
|
1743
|
+
}
|
|
1744
|
+
|
|
1745
|
+
function anchorPosition(anchor, docText, replacement) {
|
|
1746
|
+
if (anchor && anchor.position && typeof anchor.position.start === "number") {
|
|
1747
|
+
return anchor.position.start;
|
|
1748
|
+
}
|
|
1749
|
+
if (anchor && typeof anchor.quote === "string" && docText) {
|
|
1750
|
+
const index = docText.indexOf(anchor.quote);
|
|
1751
|
+
if (index >= 0) {
|
|
1752
|
+
return index;
|
|
1753
|
+
}
|
|
1754
|
+
}
|
|
1755
|
+
if (typeof replacement === "string" && docText) {
|
|
1756
|
+
const index = docText.indexOf(replacement);
|
|
1757
|
+
if (index >= 0) {
|
|
1758
|
+
return index;
|
|
1759
|
+
}
|
|
1760
|
+
}
|
|
1761
|
+
return null;
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1764
|
+
function renderPanelHeading(text) {
|
|
1765
|
+
const heading = document.createElement("h2");
|
|
1766
|
+
heading.className = "html-collab-panel-heading";
|
|
1767
|
+
heading.textContent = text;
|
|
1768
|
+
return heading;
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1771
|
+
function renderThread(thread, number) {
|
|
1772
|
+
const article = document.createElement("article");
|
|
1773
|
+
article.className = "html-collab-thread";
|
|
1774
|
+
article.id = threadElementId(thread.threadId);
|
|
1775
|
+
article.dataset.threadId = thread.threadId;
|
|
1776
|
+
article.addEventListener("click", (event) => {
|
|
1777
|
+
if (isInteractiveClickTarget(event.target)) {
|
|
1778
|
+
return;
|
|
1779
|
+
}
|
|
1780
|
+
setThreadHash(thread.threadId);
|
|
1781
|
+
scrollToAnchor(thread.threadId);
|
|
1782
|
+
});
|
|
1783
|
+
|
|
1784
|
+
const header = document.createElement("div");
|
|
1785
|
+
header.className = "html-collab-thread-header";
|
|
1786
|
+
|
|
1787
|
+
const quote = document.createElement("blockquote");
|
|
1788
|
+
quote.className = "html-collab-thread-quote";
|
|
1789
|
+
quote.textContent = thread.anchor.quote;
|
|
1790
|
+
|
|
1791
|
+
const pin = document.createElement("div");
|
|
1792
|
+
pin.className = "html-collab-thread-pin";
|
|
1793
|
+
|
|
1794
|
+
const button = document.createElement("button");
|
|
1795
|
+
button.type = "button";
|
|
1796
|
+
button.className = "html-collab-thread-number";
|
|
1797
|
+
button.textContent = String(number);
|
|
1798
|
+
button.addEventListener("click", () => {
|
|
1799
|
+
setThreadHash(thread.threadId);
|
|
1800
|
+
scrollToAnchor(thread.threadId);
|
|
1801
|
+
});
|
|
1802
|
+
|
|
1803
|
+
const status = document.createElement("span");
|
|
1804
|
+
status.className = "html-collab-thread-status";
|
|
1805
|
+
status.textContent = thread.status;
|
|
1806
|
+
|
|
1807
|
+
pin.append(button, status);
|
|
1808
|
+
header.append(quote, pin);
|
|
1809
|
+
article.appendChild(header);
|
|
1810
|
+
|
|
1811
|
+
article.appendChild(renderMessage(thread.root, "comment"));
|
|
1812
|
+
thread.replies.forEach((reply) => article.appendChild(renderMessage(reply, "reply")));
|
|
1813
|
+
|
|
1814
|
+
const replyBlock = document.createElement("div");
|
|
1815
|
+
replyBlock.className = "html-collab-thread-reply";
|
|
1816
|
+
|
|
1817
|
+
const reply = document.createElement("textarea");
|
|
1818
|
+
reply.className = "html-collab-reply-body";
|
|
1819
|
+
reply.rows = 1;
|
|
1820
|
+
reply.placeholder = "Reply";
|
|
1821
|
+
reply.addEventListener("input", () => {
|
|
1822
|
+
reply.style.height = "auto";
|
|
1823
|
+
reply.style.height = reply.scrollHeight + "px";
|
|
1824
|
+
});
|
|
1825
|
+
|
|
1826
|
+
const replyActions = document.createElement("div");
|
|
1827
|
+
replyActions.className = "html-collab-thread-actions";
|
|
1828
|
+
|
|
1829
|
+
const replyButton = document.createElement("button");
|
|
1830
|
+
replyButton.type = "button";
|
|
1831
|
+
replyButton.textContent = "Reply";
|
|
1832
|
+
replyButton.addEventListener("click", () => {
|
|
1833
|
+
const body = reply.value.trim();
|
|
1834
|
+
if (!body) {
|
|
1835
|
+
return;
|
|
1836
|
+
}
|
|
1837
|
+
const op = addOp("reply.create", { threadId: thread.threadId, parentId: thread.root.messageId }, { body });
|
|
1838
|
+
if (!op) {
|
|
1839
|
+
return;
|
|
1840
|
+
}
|
|
1841
|
+
reply.value = "";
|
|
1842
|
+
renderThreads();
|
|
1843
|
+
setStatus("Reply added");
|
|
1844
|
+
});
|
|
1845
|
+
|
|
1846
|
+
const resolveButton = document.createElement("button");
|
|
1847
|
+
resolveButton.type = "button";
|
|
1848
|
+
resolveButton.textContent = thread.status === "resolved" ? "Reopen" : "Resolve";
|
|
1849
|
+
resolveButton.addEventListener("click", () => {
|
|
1850
|
+
const op = addOp(thread.status === "resolved" ? "thread.reopen" : "thread.resolve", { threadId: thread.threadId }, {});
|
|
1851
|
+
if (!op) {
|
|
1852
|
+
return;
|
|
1853
|
+
}
|
|
1854
|
+
refreshHighlights();
|
|
1855
|
+
renderThreads();
|
|
1856
|
+
setStatus(thread.status === "resolved" ? "Thread reopened" : "Thread resolved");
|
|
1857
|
+
});
|
|
1858
|
+
|
|
1859
|
+
replyActions.append(replyButton, resolveButton);
|
|
1860
|
+
replyBlock.append(reply, replyActions);
|
|
1861
|
+
article.appendChild(replyBlock);
|
|
1862
|
+
return article;
|
|
1863
|
+
}
|
|
1864
|
+
|
|
1865
|
+
function renderEdit(edit, number) {
|
|
1866
|
+
const article = document.createElement("article");
|
|
1867
|
+
article.className = "html-collab-thread html-collab-edit-suggestion";
|
|
1868
|
+
article.id = editElementId(edit.editId);
|
|
1869
|
+
article.dataset.editId = edit.editId;
|
|
1870
|
+
article.addEventListener("click", (event) => {
|
|
1871
|
+
if (isInteractiveClickTarget(event.target)) {
|
|
1872
|
+
return;
|
|
1873
|
+
}
|
|
1874
|
+
setEditHash(edit.editId);
|
|
1875
|
+
scrollToEdit(edit.editId);
|
|
1876
|
+
});
|
|
1877
|
+
|
|
1878
|
+
const header = document.createElement("div");
|
|
1879
|
+
header.className = "html-collab-thread-header";
|
|
1880
|
+
|
|
1881
|
+
const quote = document.createElement("blockquote");
|
|
1882
|
+
quote.className = "html-collab-thread-quote";
|
|
1883
|
+
quote.textContent = edit.anchor.quote;
|
|
1884
|
+
|
|
1885
|
+
const pin = document.createElement("div");
|
|
1886
|
+
pin.className = "html-collab-thread-pin";
|
|
1887
|
+
|
|
1888
|
+
const button = document.createElement("button");
|
|
1889
|
+
button.type = "button";
|
|
1890
|
+
button.className = "html-collab-thread-number";
|
|
1891
|
+
button.textContent = "E" + number;
|
|
1892
|
+
button.addEventListener("click", () => {
|
|
1893
|
+
setEditHash(edit.editId);
|
|
1894
|
+
scrollToEdit(edit.editId);
|
|
1895
|
+
});
|
|
1896
|
+
|
|
1897
|
+
const status = document.createElement("span");
|
|
1898
|
+
status.className = "html-collab-thread-status";
|
|
1899
|
+
status.textContent = edit.status;
|
|
1900
|
+
|
|
1901
|
+
pin.append(button, status);
|
|
1902
|
+
header.append(quote, pin);
|
|
1903
|
+
article.appendChild(header);
|
|
1904
|
+
|
|
1905
|
+
const meta = document.createElement("div");
|
|
1906
|
+
meta.className = "html-collab-message-meta";
|
|
1907
|
+
const actor = state.actors[edit.actorId];
|
|
1908
|
+
const author = document.createElement("span");
|
|
1909
|
+
author.textContent = (actor?.name || edit.actorId) + " · " + formatTime(edit.createdAt);
|
|
1910
|
+
meta.appendChild(author);
|
|
1911
|
+
article.appendChild(meta);
|
|
1912
|
+
|
|
1913
|
+
const detail = document.createElement("p");
|
|
1914
|
+
detail.className = "html-collab-edit-detail";
|
|
1915
|
+
if (edit.kind === "replace") {
|
|
1916
|
+
detail.textContent = "Replace with: ";
|
|
1917
|
+
const replacement = document.createElement("span");
|
|
1918
|
+
replacement.className = "html-collab-edit-replacement";
|
|
1919
|
+
replacement.textContent = edit.replacement || "";
|
|
1920
|
+
detail.appendChild(replacement);
|
|
1921
|
+
} else if (edit.kind === "insert") {
|
|
1922
|
+
detail.textContent = "Insert after selection: ";
|
|
1923
|
+
const replacement = document.createElement("span");
|
|
1924
|
+
replacement.className = "html-collab-edit-replacement";
|
|
1925
|
+
replacement.textContent = edit.replacement || "";
|
|
1926
|
+
detail.appendChild(replacement);
|
|
1927
|
+
} else {
|
|
1928
|
+
detail.textContent = "Delete selected text.";
|
|
1929
|
+
}
|
|
1930
|
+
article.appendChild(detail);
|
|
1931
|
+
|
|
1932
|
+
if (edit.note) {
|
|
1933
|
+
const note = document.createElement("p");
|
|
1934
|
+
note.className = "html-collab-edit-note";
|
|
1935
|
+
note.textContent = edit.note;
|
|
1936
|
+
article.appendChild(note);
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1939
|
+
if (edit.status === "deleted") {
|
|
1940
|
+
return article;
|
|
1941
|
+
}
|
|
1942
|
+
|
|
1943
|
+
const actions = document.createElement("div");
|
|
1944
|
+
actions.className = "html-collab-thread-actions";
|
|
1945
|
+
|
|
1946
|
+
const deleteButton = document.createElement("button");
|
|
1947
|
+
deleteButton.type = "button";
|
|
1948
|
+
deleteButton.className = "html-collab-action-secondary";
|
|
1949
|
+
deleteButton.textContent = "Delete";
|
|
1950
|
+
deleteButton.addEventListener("click", () => {
|
|
1951
|
+
const op = addOp("edit.delete", { editId: edit.editId }, {});
|
|
1952
|
+
if (!op) {
|
|
1953
|
+
return;
|
|
1954
|
+
}
|
|
1955
|
+
refreshHighlights();
|
|
1956
|
+
renderThreads();
|
|
1957
|
+
setStatus("Edit deleted");
|
|
1958
|
+
});
|
|
1959
|
+
actions.appendChild(deleteButton);
|
|
1960
|
+
|
|
1961
|
+
if (edit.status === "open") {
|
|
1962
|
+
const accept = document.createElement("button");
|
|
1963
|
+
accept.type = "button";
|
|
1964
|
+
accept.textContent = "Accept";
|
|
1965
|
+
accept.addEventListener("click", () => {
|
|
1966
|
+
const op = addOp("edit.accept", { editId: edit.editId }, {});
|
|
1967
|
+
if (!op) {
|
|
1968
|
+
return;
|
|
1969
|
+
}
|
|
1970
|
+
refreshHighlights();
|
|
1971
|
+
renderThreads();
|
|
1972
|
+
setStatus("Edit accepted");
|
|
1973
|
+
});
|
|
1974
|
+
|
|
1975
|
+
const reject = document.createElement("button");
|
|
1976
|
+
reject.type = "button";
|
|
1977
|
+
reject.textContent = "Reject";
|
|
1978
|
+
reject.addEventListener("click", () => {
|
|
1979
|
+
const op = addOp("edit.reject", { editId: edit.editId }, {});
|
|
1980
|
+
if (!op) {
|
|
1981
|
+
return;
|
|
1982
|
+
}
|
|
1983
|
+
refreshHighlights();
|
|
1984
|
+
renderThreads();
|
|
1985
|
+
setStatus("Edit rejected");
|
|
1986
|
+
});
|
|
1987
|
+
|
|
1988
|
+
actions.append(accept, reject);
|
|
1989
|
+
}
|
|
1990
|
+
|
|
1991
|
+
article.appendChild(actions);
|
|
1992
|
+
return article;
|
|
1993
|
+
}
|
|
1994
|
+
|
|
1995
|
+
function renderMessage(message, type) {
|
|
1996
|
+
const wrapper = document.createElement("div");
|
|
1997
|
+
wrapper.className = "html-collab-message";
|
|
1998
|
+
|
|
1999
|
+
const meta = document.createElement("div");
|
|
2000
|
+
meta.className = "html-collab-message-meta";
|
|
2001
|
+
const actor = state.actors[message.actorId];
|
|
2002
|
+
const author = document.createElement("span");
|
|
2003
|
+
author.textContent = (actor?.name || message.actorId) + " · " + formatTime(message.createdAt);
|
|
2004
|
+
meta.appendChild(author);
|
|
2005
|
+
|
|
2006
|
+
if (!message.deleted) {
|
|
2007
|
+
const deleteLink = document.createElement("button");
|
|
2008
|
+
deleteLink.type = "button";
|
|
2009
|
+
deleteLink.className = "html-collab-action-secondary";
|
|
2010
|
+
deleteLink.textContent = "Delete";
|
|
2011
|
+
deleteLink.addEventListener("click", () => {
|
|
2012
|
+
const opType = type === "comment" ? "comment.delete" : "reply.delete";
|
|
2013
|
+
const op = addOp(opType, { messageId: message.messageId }, {});
|
|
2014
|
+
if (!op) {
|
|
2015
|
+
return;
|
|
2016
|
+
}
|
|
2017
|
+
refreshHighlights();
|
|
2018
|
+
renderThreads();
|
|
2019
|
+
setStatus(type === "comment" ? "Comment deleted" : "Reply deleted");
|
|
2020
|
+
});
|
|
2021
|
+
meta.appendChild(deleteLink);
|
|
2022
|
+
}
|
|
2023
|
+
|
|
2024
|
+
const body = document.createElement("p");
|
|
2025
|
+
body.textContent = message.deleted ? "Deleted" : message.body;
|
|
2026
|
+
if (message.deleted) {
|
|
2027
|
+
body.className = "html-collab-message-deleted";
|
|
2028
|
+
}
|
|
2029
|
+
|
|
2030
|
+
wrapper.append(meta, body);
|
|
2031
|
+
return wrapper;
|
|
2032
|
+
}
|
|
2033
|
+
|
|
2034
|
+
function focusThread(threadId) {
|
|
2035
|
+
const thread = document.getElementById(threadElementId(threadId));
|
|
2036
|
+
if (thread instanceof HTMLElement) {
|
|
2037
|
+
thread.scrollIntoView({ block: "nearest" });
|
|
2038
|
+
thread.classList.add("html-collab-thread-active");
|
|
2039
|
+
window.setTimeout(() => thread.classList.remove("html-collab-thread-active"), 1200);
|
|
2040
|
+
}
|
|
2041
|
+
}
|
|
2042
|
+
|
|
2043
|
+
function focusEdit(editId) {
|
|
2044
|
+
const edit = document.getElementById(editElementId(editId));
|
|
2045
|
+
if (edit instanceof HTMLElement) {
|
|
2046
|
+
edit.scrollIntoView({ block: "nearest" });
|
|
2047
|
+
edit.classList.add("html-collab-thread-active");
|
|
2048
|
+
window.setTimeout(() => edit.classList.remove("html-collab-thread-active"), 1200);
|
|
2049
|
+
}
|
|
2050
|
+
}
|
|
2051
|
+
|
|
2052
|
+
function scrollToAnchor(threadId) {
|
|
2053
|
+
const frameDocument = getFrame().contentDocument;
|
|
2054
|
+
const mark = frameDocument?.querySelector('[data-html-collab-thread="' + cssEscape(threadId) + '"]');
|
|
2055
|
+
if (mark && typeof mark.scrollIntoView === "function") {
|
|
2056
|
+
mark.scrollIntoView({ block: "center", behavior: "smooth" });
|
|
2057
|
+
pulseMark(mark);
|
|
2058
|
+
}
|
|
2059
|
+
}
|
|
2060
|
+
|
|
2061
|
+
function scrollToEdit(editId) {
|
|
2062
|
+
const frameDocument = getFrame().contentDocument;
|
|
2063
|
+
const mark = frameDocument?.querySelector('[data-html-collab-edit="' + cssEscape(editId) + '"]');
|
|
2064
|
+
if (mark && typeof mark.scrollIntoView === "function") {
|
|
2065
|
+
mark.scrollIntoView({ block: "center", behavior: "smooth" });
|
|
2066
|
+
pulseMark(mark);
|
|
2067
|
+
}
|
|
2068
|
+
}
|
|
2069
|
+
|
|
2070
|
+
function pulseMark(mark) {
|
|
2071
|
+
if (!mark || !mark.classList) {
|
|
2072
|
+
return;
|
|
2073
|
+
}
|
|
2074
|
+
mark.classList.remove("html-collab-mark-active");
|
|
2075
|
+
void mark.offsetWidth;
|
|
2076
|
+
mark.classList.add("html-collab-mark-active");
|
|
2077
|
+
window.setTimeout(() => mark.classList.remove("html-collab-mark-active"), 1400);
|
|
2078
|
+
}
|
|
2079
|
+
|
|
2080
|
+
function addOp(type, target, payload) {
|
|
2081
|
+
const actor = ensureActor();
|
|
2082
|
+
if (!actor) {
|
|
2083
|
+
return null;
|
|
2084
|
+
}
|
|
2085
|
+
const clock = nextClock();
|
|
2086
|
+
const op = {
|
|
2087
|
+
opId: actor.actorId + ":" + nextActorCounter(actor.actorId),
|
|
2088
|
+
actorId: actor.actorId,
|
|
2089
|
+
time: new Date().toISOString(),
|
|
2090
|
+
clock,
|
|
2091
|
+
type,
|
|
2092
|
+
target,
|
|
2093
|
+
payload,
|
|
2094
|
+
};
|
|
2095
|
+
state.ops.push(op);
|
|
2096
|
+
persistState();
|
|
2097
|
+
void requestAutosave({ automatic: true });
|
|
2098
|
+
return op;
|
|
2099
|
+
}
|
|
2100
|
+
|
|
2101
|
+
function ensureActor() {
|
|
2102
|
+
const name = currentReviewerName() || promptReviewerName();
|
|
2103
|
+
if (!name) {
|
|
2104
|
+
setStatus("Enter your name to continue");
|
|
2105
|
+
return null;
|
|
2106
|
+
}
|
|
2107
|
+
localStorage.setItem("html-collab.reviewerName", name);
|
|
2108
|
+
|
|
2109
|
+
const key = ACTOR_STORAGE_PREFIX + state.docId;
|
|
2110
|
+
let actorId = localStorage.getItem(key);
|
|
2111
|
+
if (!actorId) {
|
|
2112
|
+
actorId = createId("actor");
|
|
2113
|
+
localStorage.setItem(key, actorId);
|
|
2114
|
+
}
|
|
2115
|
+
|
|
2116
|
+
const actor = state.actors[actorId] || {
|
|
2117
|
+
actorId,
|
|
2118
|
+
name,
|
|
2119
|
+
createdAt: new Date().toISOString(),
|
|
2120
|
+
};
|
|
2121
|
+
actor.name = name;
|
|
2122
|
+
state.actors[actorId] = actor;
|
|
2123
|
+
return actor;
|
|
2124
|
+
}
|
|
2125
|
+
|
|
2126
|
+
function currentReviewerName() {
|
|
2127
|
+
const input = document.getElementById("html-collab-reviewer");
|
|
2128
|
+
if (input instanceof HTMLInputElement && input.value.trim()) {
|
|
2129
|
+
return input.value.trim();
|
|
2130
|
+
}
|
|
2131
|
+
return "";
|
|
2132
|
+
}
|
|
2133
|
+
|
|
2134
|
+
function promptReviewerName() {
|
|
2135
|
+
const input = document.getElementById("html-collab-reviewer");
|
|
2136
|
+
const suggested = input instanceof HTMLInputElement ? input.value.trim() : "";
|
|
2137
|
+
const name = window.prompt("Enter your name for this review", suggested);
|
|
2138
|
+
const trimmed = name?.trim() || "";
|
|
2139
|
+
if (!trimmed) {
|
|
2140
|
+
if (input instanceof HTMLInputElement) {
|
|
2141
|
+
input.focus();
|
|
2142
|
+
}
|
|
2143
|
+
return "";
|
|
2144
|
+
}
|
|
2145
|
+
if (input instanceof HTMLInputElement) {
|
|
2146
|
+
input.value = trimmed;
|
|
2147
|
+
}
|
|
2148
|
+
return trimmed;
|
|
2149
|
+
}
|
|
2150
|
+
|
|
2151
|
+
function nextClock() {
|
|
2152
|
+
return state.ops.reduce((max, op) => Math.max(max, Number(op.clock) || 0), 0) + 1;
|
|
2153
|
+
}
|
|
2154
|
+
|
|
2155
|
+
function nextActorCounter(actorId) {
|
|
2156
|
+
let max = 0;
|
|
2157
|
+
for (const op of state.ops) {
|
|
2158
|
+
if (typeof op.opId !== "string" || !op.opId.startsWith(actorId + ":")) {
|
|
2159
|
+
continue;
|
|
2160
|
+
}
|
|
2161
|
+
const value = Number(op.opId.slice(actorId.length + 1));
|
|
2162
|
+
if (Number.isFinite(value)) {
|
|
2163
|
+
max = Math.max(max, value);
|
|
2164
|
+
}
|
|
2165
|
+
}
|
|
2166
|
+
return max + 1;
|
|
2167
|
+
}
|
|
2168
|
+
|
|
2169
|
+
function persistState(options = {}) {
|
|
2170
|
+
writeJsonScript(STATE_SCRIPT_ID, state);
|
|
2171
|
+
if (options.autosave !== false) {
|
|
2172
|
+
scheduleAutosave();
|
|
2173
|
+
}
|
|
2174
|
+
}
|
|
2175
|
+
|
|
2176
|
+
async function requestAutosave(options = {}) {
|
|
2177
|
+
if (autosaveEnabled && autosaveHandle) {
|
|
2178
|
+
await autosaveNow();
|
|
2179
|
+
return;
|
|
2180
|
+
}
|
|
2181
|
+
if (autosavePromptInFlight || (options.automatic && autosavePrompted)) {
|
|
2182
|
+
return;
|
|
2183
|
+
}
|
|
2184
|
+
|
|
2185
|
+
if (typeof window.showSaveFilePicker !== "function") {
|
|
2186
|
+
setStatus("Autosave needs a Chromium browser; changes are only in this tab");
|
|
2187
|
+
return;
|
|
2188
|
+
}
|
|
2189
|
+
|
|
2190
|
+
try {
|
|
2191
|
+
autosavePrompted = true;
|
|
2192
|
+
autosavePromptInFlight = true;
|
|
2193
|
+
setStatus(options.automatic ? "Choose where to save this local review file" : "Choose a local review file to keep changes");
|
|
2194
|
+
const handle = await window.showSaveFilePicker({
|
|
2195
|
+
suggestedName: reviewFilename(),
|
|
2196
|
+
types: [
|
|
2197
|
+
{
|
|
2198
|
+
description: "HTML review file",
|
|
2199
|
+
accept: { "text/html": [".html"] },
|
|
2200
|
+
},
|
|
2201
|
+
],
|
|
2202
|
+
});
|
|
2203
|
+
await assertAutosaveTarget(handle);
|
|
2204
|
+
autosaveHandle = handle;
|
|
2205
|
+
autosaveEnabled = true;
|
|
2206
|
+
updateAutosaveButton();
|
|
2207
|
+
persistState({ autosave: false });
|
|
2208
|
+
await autosaveNow();
|
|
2209
|
+
} catch (error) {
|
|
2210
|
+
autosaveEnabled = false;
|
|
2211
|
+
autosaveHandle = null;
|
|
2212
|
+
updateAutosaveButton();
|
|
2213
|
+
if (error?.name === "AbortError") {
|
|
2214
|
+
setStatus("Autosave not enabled; choose a local review file before closing this tab");
|
|
2215
|
+
} else {
|
|
2216
|
+
setStatus("Autosave failed: " + errorMessage(error));
|
|
2217
|
+
}
|
|
2218
|
+
} finally {
|
|
2219
|
+
autosavePromptInFlight = false;
|
|
2220
|
+
}
|
|
2221
|
+
}
|
|
2222
|
+
|
|
2223
|
+
async function assertAutosaveTarget(handle) {
|
|
2224
|
+
if (!handle || typeof handle.getFile !== "function") {
|
|
2225
|
+
return;
|
|
2226
|
+
}
|
|
2227
|
+
|
|
2228
|
+
const file = await handle.getFile();
|
|
2229
|
+
if (!file || file.size === 0) {
|
|
2230
|
+
return;
|
|
2231
|
+
}
|
|
2232
|
+
|
|
2233
|
+
const existingHtml = await file.text();
|
|
2234
|
+
const existingState = parseJsonScriptFromHtml(existingHtml, STATE_SCRIPT_ID);
|
|
2235
|
+
if (existingState.docId !== state.docId || existingState.sourceFingerprint !== state.sourceFingerprint) {
|
|
2236
|
+
throw new Error("selected file is a different review");
|
|
2237
|
+
}
|
|
2238
|
+
}
|
|
2239
|
+
|
|
2240
|
+
function scheduleAutosave() {
|
|
2241
|
+
if (!autosaveEnabled || !autosaveHandle) {
|
|
2242
|
+
return;
|
|
2243
|
+
}
|
|
2244
|
+
if (autosaveTimer) {
|
|
2245
|
+
window.clearTimeout(autosaveTimer);
|
|
2246
|
+
}
|
|
2247
|
+
autosaveTimer = window.setTimeout(() => {
|
|
2248
|
+
autosaveTimer = 0;
|
|
2249
|
+
autosaveNow();
|
|
2250
|
+
}, AUTOSAVE_DELAY_MS);
|
|
2251
|
+
}
|
|
2252
|
+
|
|
2253
|
+
async function autosaveNow() {
|
|
2254
|
+
if (!autosaveEnabled || !autosaveHandle) {
|
|
2255
|
+
return;
|
|
2256
|
+
}
|
|
2257
|
+
if (autosaveInFlight) {
|
|
2258
|
+
autosaveQueued = true;
|
|
2259
|
+
return;
|
|
2260
|
+
}
|
|
2261
|
+
|
|
2262
|
+
autosaveInFlight = true;
|
|
2263
|
+
updateAutosaveButton();
|
|
2264
|
+
try {
|
|
2265
|
+
const mergeResult = await mergeAutosaveTarget();
|
|
2266
|
+
const writable = await autosaveHandle.createWritable();
|
|
2267
|
+
await writable.write(serializeReviewHtml());
|
|
2268
|
+
await writable.close();
|
|
2269
|
+
if (mergeResult.addedOps > 0 || mergeResult.addedActors > 0) {
|
|
2270
|
+
setStatus(
|
|
2271
|
+
"Merged " +
|
|
2272
|
+
mergeResult.addedOps +
|
|
2273
|
+
" synced ops and autosaved " +
|
|
2274
|
+
shortTime(new Date()),
|
|
2275
|
+
);
|
|
2276
|
+
} else {
|
|
2277
|
+
setStatus("Autosaved " + shortTime(new Date()));
|
|
2278
|
+
}
|
|
2279
|
+
} catch (error) {
|
|
2280
|
+
autosaveEnabled = false;
|
|
2281
|
+
autosaveHandle = null;
|
|
2282
|
+
autosaveQueued = false;
|
|
2283
|
+
setStatus("Autosave failed: " + errorMessage(error));
|
|
2284
|
+
} finally {
|
|
2285
|
+
autosaveInFlight = false;
|
|
2286
|
+
updateAutosaveButton();
|
|
2287
|
+
if (autosaveQueued && autosaveEnabled) {
|
|
2288
|
+
autosaveQueued = false;
|
|
2289
|
+
scheduleAutosave();
|
|
2290
|
+
}
|
|
2291
|
+
}
|
|
2292
|
+
}
|
|
2293
|
+
|
|
2294
|
+
async function mergeAutosaveTarget() {
|
|
2295
|
+
if (!autosaveHandle || typeof autosaveHandle.getFile !== "function") {
|
|
2296
|
+
return { addedOps: 0, addedActors: 0 };
|
|
2297
|
+
}
|
|
2298
|
+
|
|
2299
|
+
const file = await autosaveHandle.getFile();
|
|
2300
|
+
if (!file || file.size === 0) {
|
|
2301
|
+
return { addedOps: 0, addedActors: 0 };
|
|
2302
|
+
}
|
|
2303
|
+
|
|
2304
|
+
const existingHtml = await file.text();
|
|
2305
|
+
const existingState = parseJsonScriptFromHtml(existingHtml, STATE_SCRIPT_ID);
|
|
2306
|
+
const result = mergeImportedState(existingState);
|
|
2307
|
+
if (result.addedOps > 0 || result.addedActors > 0) {
|
|
2308
|
+
writeJsonScript(STATE_SCRIPT_ID, state);
|
|
2309
|
+
refreshHighlights();
|
|
2310
|
+
renderThreads();
|
|
2311
|
+
}
|
|
2312
|
+
return result;
|
|
2313
|
+
}
|
|
2314
|
+
|
|
2315
|
+
function serializeReviewHtml() {
|
|
2316
|
+
return "<!doctype html>\n" + document.documentElement.outerHTML;
|
|
2317
|
+
}
|
|
2318
|
+
|
|
2319
|
+
function updateAutosaveButton() {
|
|
2320
|
+
const button = document.getElementById("html-collab-autosave");
|
|
2321
|
+
if (!(button instanceof HTMLButtonElement)) {
|
|
2322
|
+
return;
|
|
2323
|
+
}
|
|
2324
|
+
button.textContent = autosaveInFlight ? "Autosaving" : autosaveEnabled ? "Autosave On" : "Autosave";
|
|
2325
|
+
button.setAttribute("aria-pressed", autosaveEnabled ? "true" : "false");
|
|
2326
|
+
button.title = autosaveEnabled ? "Autosave is writing feedback to the selected local review file" : "Choose a local review file. Until then changes live only in this tab";
|
|
2327
|
+
}
|
|
2328
|
+
|
|
2329
|
+
async function mergeSelectedFiles(input) {
|
|
2330
|
+
if (!(input instanceof HTMLInputElement) || !input.files || input.files.length === 0) {
|
|
2331
|
+
return;
|
|
2332
|
+
}
|
|
2333
|
+
|
|
2334
|
+
let addedOps = 0;
|
|
2335
|
+
let addedActors = 0;
|
|
2336
|
+
let rejected = 0;
|
|
2337
|
+
|
|
2338
|
+
for (const file of Array.from(input.files)) {
|
|
2339
|
+
try {
|
|
2340
|
+
const importedHtml = await file.text();
|
|
2341
|
+
const importedState = parseJsonScriptFromHtml(importedHtml, STATE_SCRIPT_ID);
|
|
2342
|
+
const result = mergeImportedState(importedState);
|
|
2343
|
+
addedOps += result.addedOps;
|
|
2344
|
+
addedActors += result.addedActors;
|
|
2345
|
+
} catch {
|
|
2346
|
+
rejected += 1;
|
|
2347
|
+
}
|
|
2348
|
+
}
|
|
2349
|
+
|
|
2350
|
+
input.value = "";
|
|
2351
|
+
persistState();
|
|
2352
|
+
refreshHighlights();
|
|
2353
|
+
renderThreads();
|
|
2354
|
+
|
|
2355
|
+
const message = rejected > 0
|
|
2356
|
+
? "Merged " + addedOps + " ops; rejected " + rejected + " file" + (rejected === 1 ? "" : "s")
|
|
2357
|
+
: "Merged " + addedOps + " ops";
|
|
2358
|
+
setStatus(message + (addedActors > 0 ? " from " + addedActors + " reviewer" + (addedActors === 1 ? "" : "s") : ""));
|
|
2359
|
+
}
|
|
2360
|
+
|
|
2361
|
+
function mergeImportedState(importedState) {
|
|
2362
|
+
if (!importedState || importedState.docId !== state.docId) {
|
|
2363
|
+
throw new Error("docId mismatch");
|
|
2364
|
+
}
|
|
2365
|
+
if (importedState.sourceFingerprint !== state.sourceFingerprint) {
|
|
2366
|
+
throw new Error("source fingerprint mismatch");
|
|
2367
|
+
}
|
|
2368
|
+
|
|
2369
|
+
let addedOps = 0;
|
|
2370
|
+
let addedActors = 0;
|
|
2371
|
+
const existingOps = new Set(state.ops.map((op) => op.opId));
|
|
2372
|
+
|
|
2373
|
+
for (const [actorId, actor] of Object.entries(importedState.actors || {})) {
|
|
2374
|
+
if (!state.actors[actorId]) {
|
|
2375
|
+
addedActors += 1;
|
|
2376
|
+
state.actors[actorId] = actor;
|
|
2377
|
+
}
|
|
2378
|
+
}
|
|
2379
|
+
|
|
2380
|
+
for (const op of importedState.ops || []) {
|
|
2381
|
+
if (!op || typeof op.opId !== "string" || existingOps.has(op.opId)) {
|
|
2382
|
+
continue;
|
|
2383
|
+
}
|
|
2384
|
+
state.ops.push(op);
|
|
2385
|
+
existingOps.add(op.opId);
|
|
2386
|
+
addedOps += 1;
|
|
2387
|
+
}
|
|
2388
|
+
|
|
2389
|
+
state.ops.sort(compareOps);
|
|
2390
|
+
return { addedOps, addedActors };
|
|
2391
|
+
}
|
|
2392
|
+
|
|
2393
|
+
function parseJsonScriptFromHtml(html, id) {
|
|
2394
|
+
const pattern = /<script\b[^>]*>/gi;
|
|
2395
|
+
let match = pattern.exec(html);
|
|
2396
|
+
while (match) {
|
|
2397
|
+
const openTag = match[0];
|
|
2398
|
+
if (new RegExp("\\bid\\s*=\\s*([\"'])" + escapeRegExp(id) + "\\1", "i").test(openTag)) {
|
|
2399
|
+
const contentStart = match.index + openTag.length;
|
|
2400
|
+
const closeIndex = html.indexOf("<" + "/script>", contentStart);
|
|
2401
|
+
if (closeIndex === -1) {
|
|
2402
|
+
throw new Error("Missing closing script for " + id);
|
|
2403
|
+
}
|
|
2404
|
+
return JSON.parse(html.slice(contentStart, closeIndex));
|
|
2405
|
+
}
|
|
2406
|
+
match = pattern.exec(html);
|
|
2407
|
+
}
|
|
2408
|
+
throw new Error("Missing " + id);
|
|
2409
|
+
}
|
|
2410
|
+
|
|
2411
|
+
function openBriefModal() {
|
|
2412
|
+
const modal = document.getElementById("html-collab-brief-modal");
|
|
2413
|
+
const body = document.getElementById("html-collab-brief-body");
|
|
2414
|
+
if (!modal || !body) {
|
|
2415
|
+
return;
|
|
2416
|
+
}
|
|
2417
|
+
body.textContent = renderMarkdownBrief();
|
|
2418
|
+
resetBriefCopyButton();
|
|
2419
|
+
modal.hidden = false;
|
|
2420
|
+
setStatus("Brief ready — copy or download");
|
|
2421
|
+
}
|
|
2422
|
+
|
|
2423
|
+
function closeBriefModal() {
|
|
2424
|
+
const modal = document.getElementById("html-collab-brief-modal");
|
|
2425
|
+
if (modal) {
|
|
2426
|
+
modal.hidden = true;
|
|
2427
|
+
}
|
|
2428
|
+
}
|
|
2429
|
+
|
|
2430
|
+
function resetBriefCopyButton() {
|
|
2431
|
+
const button = document.getElementById("html-collab-brief-copy");
|
|
2432
|
+
if (button instanceof HTMLButtonElement) {
|
|
2433
|
+
button.textContent = "Copy";
|
|
2434
|
+
button.classList.remove("is-success");
|
|
2435
|
+
button.disabled = false;
|
|
2436
|
+
}
|
|
2437
|
+
}
|
|
2438
|
+
|
|
2439
|
+
function copyBriefToClipboard() {
|
|
2440
|
+
const brief = renderMarkdownBrief();
|
|
2441
|
+
const button = document.getElementById("html-collab-brief-copy");
|
|
2442
|
+
const markCopied = () => {
|
|
2443
|
+
if (button instanceof HTMLButtonElement) {
|
|
2444
|
+
button.textContent = "Copied";
|
|
2445
|
+
button.classList.add("is-success");
|
|
2446
|
+
}
|
|
2447
|
+
setStatus("Brief copied to clipboard");
|
|
2448
|
+
window.setTimeout(resetBriefCopyButton, 2400);
|
|
2449
|
+
};
|
|
2450
|
+
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
2451
|
+
navigator.clipboard.writeText(brief).then(markCopied).catch(() => copyBriefFallback(brief, markCopied));
|
|
2452
|
+
return;
|
|
2453
|
+
}
|
|
2454
|
+
copyBriefFallback(brief, markCopied);
|
|
2455
|
+
}
|
|
2456
|
+
|
|
2457
|
+
function copyBriefFallback(brief, onSuccess) {
|
|
2458
|
+
const textarea = document.createElement("textarea");
|
|
2459
|
+
textarea.value = brief;
|
|
2460
|
+
textarea.style.position = "fixed";
|
|
2461
|
+
textarea.style.opacity = "0";
|
|
2462
|
+
document.body.appendChild(textarea);
|
|
2463
|
+
textarea.select();
|
|
2464
|
+
try {
|
|
2465
|
+
document.execCommand("copy");
|
|
2466
|
+
onSuccess();
|
|
2467
|
+
} catch (error) {
|
|
2468
|
+
setStatus("Could not copy — try Download .md");
|
|
2469
|
+
} finally {
|
|
2470
|
+
textarea.remove();
|
|
2471
|
+
}
|
|
2472
|
+
}
|
|
2473
|
+
|
|
2474
|
+
function downloadReviewBrief() {
|
|
2475
|
+
const brief = renderMarkdownBrief();
|
|
2476
|
+
const blob = new Blob([brief], { type: "text/markdown;charset=utf-8" });
|
|
2477
|
+
const url = URL.createObjectURL(blob);
|
|
2478
|
+
const anchor = document.createElement("a");
|
|
2479
|
+
anchor.href = url;
|
|
2480
|
+
anchor.download = briefFilename();
|
|
2481
|
+
document.body.appendChild(anchor);
|
|
2482
|
+
anchor.click();
|
|
2483
|
+
anchor.remove();
|
|
2484
|
+
URL.revokeObjectURL(url);
|
|
2485
|
+
setStatus("Brief downloaded");
|
|
2486
|
+
}
|
|
2487
|
+
|
|
2488
|
+
function renderMarkdownBrief() {
|
|
2489
|
+
const reduced = reduceState(state);
|
|
2490
|
+
const reviewers = Object.values(state.actors || {})
|
|
2491
|
+
.map((actor) => actor.name)
|
|
2492
|
+
.filter(Boolean)
|
|
2493
|
+
.sort((left, right) => left.localeCompare(right));
|
|
2494
|
+
const openThreads = reduced.threads.filter((thread) => thread.status === "open").length;
|
|
2495
|
+
const resolvedThreads = reduced.threads.filter((thread) => thread.status === "resolved").length;
|
|
2496
|
+
const openEdits = reduced.edits.filter((edit) => edit.status === "open").length;
|
|
2497
|
+
const acceptedEdits = reduced.edits.filter((edit) => edit.status === "accepted").length;
|
|
2498
|
+
const rejectedEdits = reduced.edits.filter((edit) => edit.status === "rejected").length;
|
|
2499
|
+
const deletedEdits = reduced.edits.filter((edit) => edit.status === "deleted").length;
|
|
2500
|
+
const lines = [
|
|
2501
|
+
"# Review Brief: " + (state.title || state.docId),
|
|
2502
|
+
"",
|
|
2503
|
+
"**Reviewers:** " + (reviewers.length ? reviewers.join(", ") : "None"),
|
|
2504
|
+
"**Comments:** " + openThreads + " open, " + resolvedThreads + " resolved",
|
|
2505
|
+
"**Suggested edits:** " + openEdits + " open, " + acceptedEdits + " accepted, " + rejectedEdits + " rejected, " + deletedEdits + " deleted",
|
|
2506
|
+
"",
|
|
2507
|
+
"## Comments",
|
|
2508
|
+
"",
|
|
2509
|
+
];
|
|
2510
|
+
|
|
2511
|
+
if (reduced.threads.length === 0) {
|
|
2512
|
+
lines.push("No comments.", "");
|
|
2513
|
+
}
|
|
2514
|
+
|
|
2515
|
+
reduced.threads.forEach((thread, index) => {
|
|
2516
|
+
lines.push("### Comment " + (index + 1) + ": " + thread.status);
|
|
2517
|
+
lines.push("");
|
|
2518
|
+
lines.push("- [Open in review](" + reviewThreadHref(thread.threadId) + ")");
|
|
2519
|
+
const location = locationLabel(thread.anchor);
|
|
2520
|
+
if (location) {
|
|
2521
|
+
lines.push("- Location: " + location);
|
|
2522
|
+
}
|
|
2523
|
+
lines.push("- ID: " + thread.threadId);
|
|
2524
|
+
lines.push("");
|
|
2525
|
+
lines.push("Context:");
|
|
2526
|
+
lines.push("");
|
|
2527
|
+
lines.push("> " + renderAnchorContextMarkdown(thread.anchor));
|
|
2528
|
+
lines.push("");
|
|
2529
|
+
lines.push("Messages:");
|
|
2530
|
+
const messages = [thread.root].concat(thread.replies);
|
|
2531
|
+
for (const message of messages) {
|
|
2532
|
+
if (!message.deleted) {
|
|
2533
|
+
const actor = state.actors[message.actorId]?.name || message.actorId;
|
|
2534
|
+
lines.push("- " + actor + ": " + message.body);
|
|
2535
|
+
}
|
|
2536
|
+
}
|
|
2537
|
+
lines.push("");
|
|
2538
|
+
});
|
|
2539
|
+
|
|
2540
|
+
lines.push("## Suggested Edits", "");
|
|
2541
|
+
if (reduced.edits.length === 0) {
|
|
2542
|
+
lines.push("No suggested edits.", "");
|
|
2543
|
+
}
|
|
2544
|
+
|
|
2545
|
+
reduced.edits.forEach((edit, index) => {
|
|
2546
|
+
lines.push("### Edit " + (index + 1) + ": " + edit.status + " " + edit.kind);
|
|
2547
|
+
lines.push("");
|
|
2548
|
+
lines.push("- [Open in review](" + reviewEditHref(edit.editId) + ")");
|
|
2549
|
+
const location = locationLabel(edit.anchor);
|
|
2550
|
+
if (location) {
|
|
2551
|
+
lines.push("- Location: " + location);
|
|
2552
|
+
}
|
|
2553
|
+
const actor = state.actors[edit.actorId]?.name || edit.actorId;
|
|
2554
|
+
lines.push("- ID: " + edit.editId);
|
|
2555
|
+
lines.push("- Reviewer: " + actor);
|
|
2556
|
+
lines.push("");
|
|
2557
|
+
lines.push("Context:");
|
|
2558
|
+
lines.push("");
|
|
2559
|
+
lines.push("> " + renderAnchorContextMarkdown(edit.anchor));
|
|
2560
|
+
lines.push("");
|
|
2561
|
+
if (edit.kind === "replace") {
|
|
2562
|
+
lines.push("Replace with: " + (edit.replacement || ""));
|
|
2563
|
+
} else if (edit.kind === "insert") {
|
|
2564
|
+
lines.push("Insert after selection: " + (edit.replacement || ""));
|
|
2565
|
+
} else {
|
|
2566
|
+
lines.push("Delete selected text.");
|
|
2567
|
+
}
|
|
2568
|
+
if (edit.note) {
|
|
2569
|
+
lines.push("Note: " + edit.note);
|
|
2570
|
+
}
|
|
2571
|
+
lines.push("");
|
|
2572
|
+
});
|
|
2573
|
+
|
|
2574
|
+
return lines.join("\n").trimEnd() + "\n";
|
|
2575
|
+
}
|
|
2576
|
+
|
|
2577
|
+
function locationLabel(anchor) {
|
|
2578
|
+
const path = Array.isArray(anchor.headingPath) ? anchor.headingPath : [];
|
|
2579
|
+
const heading = path[path.length - 1];
|
|
2580
|
+
if (heading && !sameText(heading, anchor.quote) && !containsText(heading, anchor.quote)) {
|
|
2581
|
+
return truncate(heading, 96);
|
|
2582
|
+
}
|
|
2583
|
+
return "";
|
|
2584
|
+
}
|
|
2585
|
+
|
|
2586
|
+
function renderAnchorContextMarkdown(anchor) {
|
|
2587
|
+
const prefix = collapseWhitespace(anchor.prefix || "");
|
|
2588
|
+
const quote = collapseWhitespace(anchor.quote || "");
|
|
2589
|
+
const suffix = collapseWhitespace(anchor.suffix || "");
|
|
2590
|
+
const head = prefix ? "…" + escapeMarkdownInline(prefix) + " " : "";
|
|
2591
|
+
const tail = suffix ? " " + escapeMarkdownInline(suffix) + "…" : "";
|
|
2592
|
+
return head + "**" + escapeMarkdownInline(quote) + "**" + tail;
|
|
2593
|
+
}
|
|
2594
|
+
|
|
2595
|
+
function collapseWhitespace(value) {
|
|
2596
|
+
return String(value).replace(/\s+/g, " ").trim();
|
|
2597
|
+
}
|
|
2598
|
+
|
|
2599
|
+
function truncate(value, max) {
|
|
2600
|
+
const collapsed = collapseWhitespace(value);
|
|
2601
|
+
if (collapsed.length <= max) {
|
|
2602
|
+
return collapsed;
|
|
2603
|
+
}
|
|
2604
|
+
return collapsed.slice(0, max - 1) + "…";
|
|
2605
|
+
}
|
|
2606
|
+
|
|
2607
|
+
function sameText(left, right) {
|
|
2608
|
+
return collapseWhitespace(left).toLowerCase() === collapseWhitespace(right).toLowerCase();
|
|
2609
|
+
}
|
|
2610
|
+
|
|
2611
|
+
function containsText(haystack, needle) {
|
|
2612
|
+
return collapseWhitespace(haystack).toLowerCase().includes(collapseWhitespace(needle).toLowerCase());
|
|
2613
|
+
}
|
|
2614
|
+
|
|
2615
|
+
function escapeMarkdownInline(value) {
|
|
2616
|
+
return String(value).replace(/([\\\`*_{}\[\]()#+\-!|>])/g, "\\$1");
|
|
2617
|
+
}
|
|
2618
|
+
|
|
2619
|
+
function reviewThreadHref(threadId) {
|
|
2620
|
+
return reviewFileHref() + "#" + threadElementId(threadId);
|
|
2621
|
+
}
|
|
2622
|
+
|
|
2623
|
+
function reviewEditHref(editId) {
|
|
2624
|
+
return reviewFileHref() + "#" + editElementId(editId);
|
|
2625
|
+
}
|
|
2626
|
+
|
|
2627
|
+
function reviewFileHref() {
|
|
2628
|
+
if (location.protocol === "file:" && location.href) {
|
|
2629
|
+
return location.href.split("#")[0];
|
|
2630
|
+
}
|
|
2631
|
+
return reviewFilename();
|
|
2632
|
+
}
|
|
2633
|
+
|
|
2634
|
+
function reviewFilename() {
|
|
2635
|
+
const title = state.title || "report.html";
|
|
2636
|
+
if (title.endsWith(".review.html")) {
|
|
2637
|
+
return title;
|
|
2638
|
+
}
|
|
2639
|
+
if (title.endsWith(".html")) {
|
|
2640
|
+
return title.slice(0, -5) + ".review.html";
|
|
2641
|
+
}
|
|
2642
|
+
return title + ".review.html";
|
|
2643
|
+
}
|
|
2644
|
+
|
|
2645
|
+
function briefFilename() {
|
|
2646
|
+
const title = state.title || "report.html";
|
|
2647
|
+
if (title.endsWith(".review.html")) {
|
|
2648
|
+
return title.slice(0, -12) + ".review-brief.md";
|
|
2649
|
+
}
|
|
2650
|
+
if (title.endsWith(".html")) {
|
|
2651
|
+
return title.slice(0, -5) + ".review-brief.md";
|
|
2652
|
+
}
|
|
2653
|
+
return title + ".review-brief.md";
|
|
2654
|
+
}
|
|
2655
|
+
|
|
2656
|
+
function reduceState(reviewState) {
|
|
2657
|
+
const unique = new Map();
|
|
2658
|
+
for (const op of reviewState.ops || []) {
|
|
2659
|
+
if (op && typeof op.opId === "string") {
|
|
2660
|
+
unique.set(op.opId, op);
|
|
2661
|
+
}
|
|
2662
|
+
}
|
|
2663
|
+
const ops = Array.from(unique.values()).sort(compareOps);
|
|
2664
|
+
const threads = new Map();
|
|
2665
|
+
const messages = new Map();
|
|
2666
|
+
const edits = new Map();
|
|
2667
|
+
|
|
2668
|
+
for (const op of ops) {
|
|
2669
|
+
if (op.type === "comment.create") {
|
|
2670
|
+
if (threads.has(op.payload.threadId)) {
|
|
2671
|
+
continue;
|
|
2672
|
+
}
|
|
2673
|
+
const root = {
|
|
2674
|
+
messageId: op.opId,
|
|
2675
|
+
actorId: op.actorId,
|
|
2676
|
+
body: op.payload.body,
|
|
2677
|
+
deleted: false,
|
|
2678
|
+
createdAt: op.time,
|
|
2679
|
+
updatedAt: op.time,
|
|
2680
|
+
updateOp: op,
|
|
2681
|
+
};
|
|
2682
|
+
const thread = {
|
|
2683
|
+
threadId: op.payload.threadId,
|
|
2684
|
+
status: "open",
|
|
2685
|
+
anchor: op.target,
|
|
2686
|
+
root,
|
|
2687
|
+
replies: [],
|
|
2688
|
+
createdAt: op.time,
|
|
2689
|
+
statusOp: null,
|
|
2690
|
+
};
|
|
2691
|
+
threads.set(thread.threadId, thread);
|
|
2692
|
+
messages.set(root.messageId, root);
|
|
2693
|
+
} else if (op.type === "edit.suggest") {
|
|
2694
|
+
const editId = op.payload.editId;
|
|
2695
|
+
if (!editId || edits.has(editId)) {
|
|
2696
|
+
continue;
|
|
2697
|
+
}
|
|
2698
|
+
edits.set(editId, {
|
|
2699
|
+
editId,
|
|
2700
|
+
status: "open",
|
|
2701
|
+
kind: op.payload.kind,
|
|
2702
|
+
anchor: op.target,
|
|
2703
|
+
replacement: op.payload.replacement,
|
|
2704
|
+
note: op.payload.note,
|
|
2705
|
+
actorId: op.actorId,
|
|
2706
|
+
createdAt: op.time,
|
|
2707
|
+
updatedAt: op.time,
|
|
2708
|
+
statusOp: null,
|
|
2709
|
+
});
|
|
2710
|
+
}
|
|
2711
|
+
}
|
|
2712
|
+
|
|
2713
|
+
for (const op of ops) {
|
|
2714
|
+
if (op.type === "reply.create") {
|
|
2715
|
+
const thread = threads.get(op.target.threadId);
|
|
2716
|
+
if (!thread) continue;
|
|
2717
|
+
const reply = {
|
|
2718
|
+
messageId: op.opId,
|
|
2719
|
+
actorId: op.actorId,
|
|
2720
|
+
body: op.payload.body,
|
|
2721
|
+
deleted: false,
|
|
2722
|
+
createdAt: op.time,
|
|
2723
|
+
updatedAt: op.time,
|
|
2724
|
+
updateOp: op,
|
|
2725
|
+
};
|
|
2726
|
+
thread.replies.push(reply);
|
|
2727
|
+
messages.set(reply.messageId, reply);
|
|
2728
|
+
} else if (op.type === "comment.edit" || op.type === "reply.edit") {
|
|
2729
|
+
const message = messages.get(op.target.messageId);
|
|
2730
|
+
if (message && compareOps(message.updateOp, op) <= 0) {
|
|
2731
|
+
message.body = op.payload.body;
|
|
2732
|
+
message.updatedAt = op.time;
|
|
2733
|
+
message.updateOp = op;
|
|
2734
|
+
}
|
|
2735
|
+
} else if (op.type === "comment.delete" || op.type === "reply.delete") {
|
|
2736
|
+
const message = messages.get(op.target.messageId);
|
|
2737
|
+
if (message && compareOps(message.updateOp, op) <= 0) {
|
|
2738
|
+
message.deleted = true;
|
|
2739
|
+
message.updatedAt = op.time;
|
|
2740
|
+
message.updateOp = op;
|
|
2741
|
+
}
|
|
2742
|
+
} else if (op.type === "thread.resolve" || op.type === "thread.reopen") {
|
|
2743
|
+
const thread = threads.get(op.target.threadId);
|
|
2744
|
+
if (thread && (!thread.statusOp || compareOps(thread.statusOp, op) <= 0)) {
|
|
2745
|
+
thread.status = op.type === "thread.resolve" ? "resolved" : "open";
|
|
2746
|
+
thread.statusOp = op;
|
|
2747
|
+
}
|
|
2748
|
+
} else if (op.type === "edit.accept" || op.type === "edit.reject" || op.type === "edit.delete") {
|
|
2749
|
+
const edit = edits.get(op.target.editId);
|
|
2750
|
+
if (edit && (!edit.statusOp || compareOps(edit.statusOp, op) <= 0)) {
|
|
2751
|
+
edit.status = op.type === "edit.accept" ? "accepted" : op.type === "edit.reject" ? "rejected" : "deleted";
|
|
2752
|
+
edit.updatedAt = op.time;
|
|
2753
|
+
edit.statusOp = op;
|
|
2754
|
+
}
|
|
2755
|
+
}
|
|
2756
|
+
}
|
|
2757
|
+
|
|
2758
|
+
return {
|
|
2759
|
+
threads: Array.from(threads.values()).sort((left, right) => {
|
|
2760
|
+
const created = left.createdAt.localeCompare(right.createdAt);
|
|
2761
|
+
return created || left.threadId.localeCompare(right.threadId);
|
|
2762
|
+
}),
|
|
2763
|
+
edits: Array.from(edits.values()).sort((left, right) => {
|
|
2764
|
+
const created = left.createdAt.localeCompare(right.createdAt);
|
|
2765
|
+
return created || left.editId.localeCompare(right.editId);
|
|
2766
|
+
}),
|
|
2767
|
+
};
|
|
2768
|
+
}
|
|
2769
|
+
|
|
2770
|
+
function compareOps(left, right) {
|
|
2771
|
+
if ((left.clock || 0) !== (right.clock || 0)) {
|
|
2772
|
+
return (left.clock || 0) - (right.clock || 0);
|
|
2773
|
+
}
|
|
2774
|
+
const time = String(left.time || "").localeCompare(String(right.time || ""));
|
|
2775
|
+
if (time !== 0) {
|
|
2776
|
+
return time;
|
|
2777
|
+
}
|
|
2778
|
+
return String(left.opId || "").localeCompare(String(right.opId || ""));
|
|
2779
|
+
}
|
|
2780
|
+
|
|
2781
|
+
function createId(prefix) {
|
|
2782
|
+
if (crypto.randomUUID) {
|
|
2783
|
+
return prefix + "-" + crypto.randomUUID();
|
|
2784
|
+
}
|
|
2785
|
+
return prefix + "-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2);
|
|
2786
|
+
}
|
|
2787
|
+
|
|
2788
|
+
function threadElementId(threadId) {
|
|
2789
|
+
return "html-collab-thread-" + encodeURIComponent(threadId);
|
|
2790
|
+
}
|
|
2791
|
+
|
|
2792
|
+
function editElementId(editId) {
|
|
2793
|
+
return "html-collab-edit-" + encodeURIComponent(editId);
|
|
2794
|
+
}
|
|
2795
|
+
|
|
2796
|
+
function setThreadHash(threadId) {
|
|
2797
|
+
const hash = "#" + threadElementId(threadId);
|
|
2798
|
+
if (location.hash !== hash && history.replaceState) {
|
|
2799
|
+
try {
|
|
2800
|
+
history.replaceState(null, "", hash);
|
|
2801
|
+
} catch {
|
|
2802
|
+
location.hash = hash;
|
|
2803
|
+
}
|
|
2804
|
+
}
|
|
2805
|
+
}
|
|
2806
|
+
|
|
2807
|
+
function setEditHash(editId) {
|
|
2808
|
+
const hash = "#" + editElementId(editId);
|
|
2809
|
+
if (location.hash !== hash && history.replaceState) {
|
|
2810
|
+
try {
|
|
2811
|
+
history.replaceState(null, "", hash);
|
|
2812
|
+
} catch {
|
|
2813
|
+
location.hash = hash;
|
|
2814
|
+
}
|
|
2815
|
+
}
|
|
2816
|
+
}
|
|
2817
|
+
|
|
2818
|
+
function focusFromLocationHash() {
|
|
2819
|
+
const editId = editIdFromHash(location.hash);
|
|
2820
|
+
if (editId) {
|
|
2821
|
+
focusEdit(editId);
|
|
2822
|
+
scrollToEdit(editId);
|
|
2823
|
+
return;
|
|
2824
|
+
}
|
|
2825
|
+
|
|
2826
|
+
const threadId = threadIdFromHash(location.hash);
|
|
2827
|
+
if (!threadId) {
|
|
2828
|
+
return;
|
|
2829
|
+
}
|
|
2830
|
+
focusThread(threadId);
|
|
2831
|
+
scrollToAnchor(threadId);
|
|
2832
|
+
}
|
|
2833
|
+
|
|
2834
|
+
function threadIdFromHash(hash) {
|
|
2835
|
+
const prefix = "#html-collab-thread-";
|
|
2836
|
+
if (!hash || !hash.startsWith(prefix)) {
|
|
2837
|
+
return null;
|
|
2838
|
+
}
|
|
2839
|
+
const encoded = hash.slice(prefix.length);
|
|
2840
|
+
try {
|
|
2841
|
+
return decodeURIComponent(encoded);
|
|
2842
|
+
} catch {
|
|
2843
|
+
return encoded;
|
|
2844
|
+
}
|
|
2845
|
+
}
|
|
2846
|
+
|
|
2847
|
+
function editIdFromHash(hash) {
|
|
2848
|
+
const prefix = "#html-collab-edit-";
|
|
2849
|
+
if (!hash || !hash.startsWith(prefix)) {
|
|
2850
|
+
return null;
|
|
2851
|
+
}
|
|
2852
|
+
const encoded = hash.slice(prefix.length);
|
|
2853
|
+
try {
|
|
2854
|
+
return decodeURIComponent(encoded);
|
|
2855
|
+
} catch {
|
|
2856
|
+
return encoded;
|
|
2857
|
+
}
|
|
2858
|
+
}
|
|
2859
|
+
|
|
2860
|
+
function cssEscape(value) {
|
|
2861
|
+
if (window.CSS && CSS.escape) {
|
|
2862
|
+
return CSS.escape(value);
|
|
2863
|
+
}
|
|
2864
|
+
return String(value).replace(/["\\]/g, "\\$&");
|
|
2865
|
+
}
|
|
2866
|
+
|
|
2867
|
+
function escapeRegExp(value) {
|
|
2868
|
+
return String(value).replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&");
|
|
2869
|
+
}
|
|
2870
|
+
|
|
2871
|
+
function formatTime(value) {
|
|
2872
|
+
const date = new Date(value);
|
|
2873
|
+
if (Number.isNaN(date.getTime())) {
|
|
2874
|
+
return value;
|
|
2875
|
+
}
|
|
2876
|
+
return date.toLocaleString([], {
|
|
2877
|
+
month: "short",
|
|
2878
|
+
day: "numeric",
|
|
2879
|
+
hour: "2-digit",
|
|
2880
|
+
minute: "2-digit",
|
|
2881
|
+
});
|
|
2882
|
+
}
|
|
2883
|
+
|
|
2884
|
+
function shortTime(date) {
|
|
2885
|
+
return date.toLocaleTimeString([], {
|
|
2886
|
+
hour: "2-digit",
|
|
2887
|
+
minute: "2-digit",
|
|
2888
|
+
second: "2-digit",
|
|
2889
|
+
});
|
|
2890
|
+
}
|
|
2891
|
+
|
|
2892
|
+
function errorMessage(error) {
|
|
2893
|
+
if (error && typeof error.message === "string" && error.message) {
|
|
2894
|
+
return error.message;
|
|
2895
|
+
}
|
|
2896
|
+
return "unknown error";
|
|
2897
|
+
}
|
|
2898
|
+
|
|
2899
|
+
function setStatus(message) {
|
|
2900
|
+
const status = document.getElementById("html-collab-status");
|
|
2901
|
+
if (status instanceof HTMLElement) {
|
|
2902
|
+
status.textContent = message;
|
|
2903
|
+
}
|
|
2904
|
+
}
|
|
2905
|
+
|
|
2906
|
+
if (document.readyState === "loading") {
|
|
2907
|
+
document.addEventListener("DOMContentLoaded", init);
|
|
2908
|
+
} else {
|
|
2909
|
+
init();
|
|
2910
|
+
}
|
|
2911
|
+
})();
|
|
2912
|
+
`;
|
|
2913
|
+
|
|
2914
|
+
// src/format/state.ts
|
|
2915
|
+
function createInitialState(input) {
|
|
2916
|
+
const state = {
|
|
2917
|
+
schemaVersion: 1,
|
|
2918
|
+
docId: input.docId,
|
|
2919
|
+
sourceFingerprint: input.sourceFingerprint,
|
|
2920
|
+
actors: {},
|
|
2921
|
+
ops: []
|
|
2922
|
+
};
|
|
2923
|
+
if (input.title) {
|
|
2924
|
+
state.title = input.title;
|
|
2925
|
+
}
|
|
2926
|
+
return state;
|
|
2927
|
+
}
|
|
2928
|
+
|
|
2929
|
+
// src/format/html-envelope.ts
|
|
2930
|
+
var SOURCE_SCRIPT_ID = "html-collab-source";
|
|
2931
|
+
var STATE_SCRIPT_ID = "html-collab-state";
|
|
2932
|
+
var PROJECT_URL = "https://github.com/glendigity/html-collab";
|
|
2933
|
+
var BRAND_LOGO_SVG = `<svg class="html-collab-brand-logo" aria-hidden="true" focusable="false" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 200" fill="none" stroke="#1F2042" stroke-width="15" stroke-linejoin="round" stroke-linecap="round">
|
|
2934
|
+
<polyline points="70,26 25,96 70,166"/>
|
|
2935
|
+
<polyline points="250,26 295,96 250,166"/>
|
|
2936
|
+
<path transform="translate(95 40)" d="M10 0 L82 0 L100 18 L100 70 A10 10 0 0 1 90 80 L30 80 L18 92 L14 80 L10 80 A10 10 0 0 1 0 70 L0 10 A10 10 0 0 1 10 0 Z" fill="#F5A524"/>
|
|
2937
|
+
<path transform="translate(125 60)" d="M10 0 L82 0 L100 18 L100 70 A10 10 0 0 1 90 80 L30 80 L18 92 L14 80 L10 80 A10 10 0 0 1 0 70 L0 10 A10 10 0 0 1 10 0 Z" fill="#F5A524"/>
|
|
2938
|
+
</svg>`;
|
|
2939
|
+
var BRAND_WORDMARK_SVG = `<svg class="html-collab-brand-wordmark" aria-hidden="true" focusable="false" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 120">
|
|
2940
|
+
<text x="10" y="92" font-family="'Geist Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-weight="700" font-size="100" fill="#1F2042"><html-collab></text>
|
|
2941
|
+
</svg>`;
|
|
2942
|
+
function createReviewHtml(sourceBytes, options = {}) {
|
|
2943
|
+
const sourceHtml = sourceBytes.toString("utf8");
|
|
2944
|
+
const title = extractSourceTitle(sourceHtml) ?? (options.sourcePath ? basename(options.sourcePath) : undefined);
|
|
2945
|
+
const sourcePayload = {
|
|
2946
|
+
encoding: "base64",
|
|
2947
|
+
html: sourceBytes.toString("base64")
|
|
2948
|
+
};
|
|
2949
|
+
const state = options.state ?? createInitialState({
|
|
2950
|
+
docId: options.docId ?? randomUUID(),
|
|
2951
|
+
sourceFingerprint: fingerprintSource(sourceBytes),
|
|
2952
|
+
title
|
|
2953
|
+
});
|
|
2954
|
+
return renderReviewShell(sourcePayload, state);
|
|
2955
|
+
}
|
|
2956
|
+
function createReviewHtmlFromParts(source, state) {
|
|
2957
|
+
return renderReviewShell(source, state);
|
|
2958
|
+
}
|
|
2959
|
+
function unwrapReviewHtml(reviewHtml) {
|
|
2960
|
+
const source = extractSourcePayload(reviewHtml.toString());
|
|
2961
|
+
if (source.encoding !== "base64") {
|
|
2962
|
+
throw new Error(`Unsupported source encoding: ${source.encoding}`);
|
|
2963
|
+
}
|
|
2964
|
+
return Buffer.from(source.html, "base64");
|
|
2965
|
+
}
|
|
2966
|
+
function extractSourcePayload(reviewHtml) {
|
|
2967
|
+
const payload = parseJsonScript(reviewHtml, SOURCE_SCRIPT_ID);
|
|
2968
|
+
if (!isSourcePayload(payload)) {
|
|
2969
|
+
throw new Error("Invalid html-collab source payload in this review file");
|
|
2970
|
+
}
|
|
2971
|
+
return payload;
|
|
2972
|
+
}
|
|
2973
|
+
function extractReviewState(reviewHtml) {
|
|
2974
|
+
const state = parseJsonScript(reviewHtml, STATE_SCRIPT_ID);
|
|
2975
|
+
if (!isReviewState(state)) {
|
|
2976
|
+
throw new Error("Invalid html-collab review state in this review file");
|
|
2977
|
+
}
|
|
2978
|
+
return state;
|
|
2979
|
+
}
|
|
2980
|
+
function fingerprintSource(sourceBytes) {
|
|
2981
|
+
return `sha256:${createHash("sha256").update(sourceBytes).digest("hex")}`;
|
|
2982
|
+
}
|
|
2983
|
+
function renderReviewShell(source, state) {
|
|
2984
|
+
return `<!doctype html>
|
|
2985
|
+
<html lang="en">
|
|
2986
|
+
<head>
|
|
2987
|
+
<meta charset="utf-8">
|
|
2988
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
2989
|
+
<meta name="generator" content="html-collab">
|
|
2990
|
+
<link rel="author" href="${PROJECT_URL}">
|
|
2991
|
+
<title>${escapeHtml(state.title ?? "Reviewable HTML")}</title>
|
|
2992
|
+
<style>
|
|
2993
|
+
:root {
|
|
2994
|
+
color-scheme: light;
|
|
2995
|
+
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
2996
|
+
}
|
|
2997
|
+
|
|
2998
|
+
* {
|
|
2999
|
+
box-sizing: border-box;
|
|
3000
|
+
}
|
|
3001
|
+
|
|
3002
|
+
html,
|
|
3003
|
+
body {
|
|
3004
|
+
height: 100%;
|
|
3005
|
+
margin: 0;
|
|
3006
|
+
}
|
|
3007
|
+
|
|
3008
|
+
body {
|
|
3009
|
+
background: #f6f7f9;
|
|
3010
|
+
color: #1f2937;
|
|
3011
|
+
}
|
|
3012
|
+
|
|
3013
|
+
button,
|
|
3014
|
+
input,
|
|
3015
|
+
select,
|
|
3016
|
+
textarea {
|
|
3017
|
+
font: inherit;
|
|
3018
|
+
}
|
|
3019
|
+
|
|
3020
|
+
button {
|
|
3021
|
+
min-height: 32px;
|
|
3022
|
+
border: 1px solid #cbd5e1;
|
|
3023
|
+
border-radius: 6px;
|
|
3024
|
+
background: #ffffff;
|
|
3025
|
+
color: #1f2937;
|
|
3026
|
+
cursor: pointer;
|
|
3027
|
+
}
|
|
3028
|
+
|
|
3029
|
+
button:hover:not(:disabled) {
|
|
3030
|
+
background: #f1f5f9;
|
|
3031
|
+
}
|
|
3032
|
+
|
|
3033
|
+
button:disabled {
|
|
3034
|
+
cursor: not-allowed;
|
|
3035
|
+
opacity: 0.45;
|
|
3036
|
+
}
|
|
3037
|
+
|
|
3038
|
+
textarea,
|
|
3039
|
+
select,
|
|
3040
|
+
input {
|
|
3041
|
+
border: 1px solid #cbd5e1;
|
|
3042
|
+
border-radius: 6px;
|
|
3043
|
+
background: #ffffff;
|
|
3044
|
+
color: #1f2937;
|
|
3045
|
+
}
|
|
3046
|
+
|
|
3047
|
+
#html-collab-shell {
|
|
3048
|
+
display: grid;
|
|
3049
|
+
grid-template-rows: auto minmax(0, 1fr);
|
|
3050
|
+
min-height: 100vh;
|
|
3051
|
+
}
|
|
3052
|
+
|
|
3053
|
+
.html-collab-toolbar {
|
|
3054
|
+
display: flex;
|
|
3055
|
+
align-items: center;
|
|
3056
|
+
justify-content: space-between;
|
|
3057
|
+
gap: 16px;
|
|
3058
|
+
min-height: 48px;
|
|
3059
|
+
padding: 8px 14px;
|
|
3060
|
+
border-bottom: 1px solid #d7dce3;
|
|
3061
|
+
background: #ffffff;
|
|
3062
|
+
color: #2f3a4a;
|
|
3063
|
+
font-size: 13px;
|
|
3064
|
+
}
|
|
3065
|
+
|
|
3066
|
+
.html-collab-brand {
|
|
3067
|
+
display: flex;
|
|
3068
|
+
align-items: center;
|
|
3069
|
+
gap: 6px;
|
|
3070
|
+
min-width: 0;
|
|
3071
|
+
border-radius: 6px;
|
|
3072
|
+
color: inherit;
|
|
3073
|
+
text-decoration: none;
|
|
3074
|
+
}
|
|
3075
|
+
|
|
3076
|
+
.html-collab-brand:hover {
|
|
3077
|
+
background: #f8fafc;
|
|
3078
|
+
}
|
|
3079
|
+
|
|
3080
|
+
.html-collab-brand:focus-visible {
|
|
3081
|
+
outline: 2px solid #2563eb;
|
|
3082
|
+
outline-offset: 3px;
|
|
3083
|
+
}
|
|
3084
|
+
|
|
3085
|
+
.html-collab-brand-logo {
|
|
3086
|
+
display: block;
|
|
3087
|
+
flex: 0 0 auto;
|
|
3088
|
+
width: 34px;
|
|
3089
|
+
height: 22px;
|
|
3090
|
+
}
|
|
3091
|
+
|
|
3092
|
+
.html-collab-brand-wordmark {
|
|
3093
|
+
display: block;
|
|
3094
|
+
flex: 0 1 auto;
|
|
3095
|
+
width: 124px;
|
|
3096
|
+
max-width: 32vw;
|
|
3097
|
+
height: auto;
|
|
3098
|
+
}
|
|
3099
|
+
|
|
3100
|
+
.html-collab-toolbar-group {
|
|
3101
|
+
display: flex;
|
|
3102
|
+
align-items: center;
|
|
3103
|
+
gap: 8px;
|
|
3104
|
+
min-width: 0;
|
|
3105
|
+
}
|
|
3106
|
+
|
|
3107
|
+
.html-collab-toolbar input {
|
|
3108
|
+
width: 170px;
|
|
3109
|
+
min-height: 32px;
|
|
3110
|
+
padding: 4px 8px;
|
|
3111
|
+
}
|
|
3112
|
+
|
|
3113
|
+
.html-collab-toolbar button {
|
|
3114
|
+
padding: 4px 10px;
|
|
3115
|
+
}
|
|
3116
|
+
|
|
3117
|
+
.html-collab-toolbar select {
|
|
3118
|
+
min-height: 32px;
|
|
3119
|
+
padding: 4px 8px;
|
|
3120
|
+
}
|
|
3121
|
+
|
|
3122
|
+
.html-collab-status {
|
|
3123
|
+
color: #64748b;
|
|
3124
|
+
overflow: hidden;
|
|
3125
|
+
text-overflow: ellipsis;
|
|
3126
|
+
white-space: nowrap;
|
|
3127
|
+
}
|
|
3128
|
+
|
|
3129
|
+
.html-collab-workspace {
|
|
3130
|
+
display: grid;
|
|
3131
|
+
grid-template-columns: minmax(0, 1fr) 360px;
|
|
3132
|
+
min-height: 0;
|
|
3133
|
+
}
|
|
3134
|
+
|
|
3135
|
+
#html-collab-canvas {
|
|
3136
|
+
min-height: 0;
|
|
3137
|
+
padding: 0;
|
|
3138
|
+
}
|
|
3139
|
+
|
|
3140
|
+
#html-collab-source-frame {
|
|
3141
|
+
display: block;
|
|
3142
|
+
width: 100%;
|
|
3143
|
+
height: calc(100vh - 49px);
|
|
3144
|
+
border: 0;
|
|
3145
|
+
background: #ffffff;
|
|
3146
|
+
}
|
|
3147
|
+
|
|
3148
|
+
.html-collab-panel {
|
|
3149
|
+
display: grid;
|
|
3150
|
+
grid-template-rows: auto minmax(0, 1fr);
|
|
3151
|
+
min-height: 0;
|
|
3152
|
+
border-left: 1px solid #d7dce3;
|
|
3153
|
+
background: #ffffff;
|
|
3154
|
+
}
|
|
3155
|
+
|
|
3156
|
+
.html-collab-composer {
|
|
3157
|
+
padding: 12px;
|
|
3158
|
+
border-bottom: 1px solid #e2e8f0;
|
|
3159
|
+
background: #fbfcfd;
|
|
3160
|
+
}
|
|
3161
|
+
|
|
3162
|
+
.html-collab-composer[hidden] {
|
|
3163
|
+
display: none;
|
|
3164
|
+
}
|
|
3165
|
+
|
|
3166
|
+
.html-collab-selected-quote {
|
|
3167
|
+
max-height: 76px;
|
|
3168
|
+
margin: 0 0 8px;
|
|
3169
|
+
overflow: auto;
|
|
3170
|
+
border-left: 3px solid #eab308;
|
|
3171
|
+
padding-left: 8px;
|
|
3172
|
+
color: #475569;
|
|
3173
|
+
font-size: 12px;
|
|
3174
|
+
line-height: 1.4;
|
|
3175
|
+
}
|
|
3176
|
+
|
|
3177
|
+
.html-collab-composer textarea,
|
|
3178
|
+
.html-collab-reply-body {
|
|
3179
|
+
width: 100%;
|
|
3180
|
+
resize: vertical;
|
|
3181
|
+
padding: 8px;
|
|
3182
|
+
line-height: 1.4;
|
|
3183
|
+
}
|
|
3184
|
+
|
|
3185
|
+
.html-collab-reply-body {
|
|
3186
|
+
resize: none;
|
|
3187
|
+
overflow: hidden;
|
|
3188
|
+
}
|
|
3189
|
+
|
|
3190
|
+
.html-collab-composer-actions {
|
|
3191
|
+
display: flex;
|
|
3192
|
+
align-items: center;
|
|
3193
|
+
gap: 8px;
|
|
3194
|
+
margin-top: 8px;
|
|
3195
|
+
}
|
|
3196
|
+
|
|
3197
|
+
.html-collab-thread-actions {
|
|
3198
|
+
display: flex;
|
|
3199
|
+
flex-wrap: wrap;
|
|
3200
|
+
align-items: center;
|
|
3201
|
+
justify-content: flex-end;
|
|
3202
|
+
gap: 6px;
|
|
3203
|
+
margin-top: 10px;
|
|
3204
|
+
}
|
|
3205
|
+
|
|
3206
|
+
.html-collab-thread-reply .html-collab-thread-actions {
|
|
3207
|
+
margin-top: 0;
|
|
3208
|
+
}
|
|
3209
|
+
|
|
3210
|
+
.html-collab-thread-actions .html-collab-action-secondary {
|
|
3211
|
+
margin-right: auto;
|
|
3212
|
+
}
|
|
3213
|
+
|
|
3214
|
+
.html-collab-action-secondary {
|
|
3215
|
+
min-height: 28px;
|
|
3216
|
+
border: 0;
|
|
3217
|
+
background: transparent;
|
|
3218
|
+
color: #94a3b8;
|
|
3219
|
+
padding: 2px 6px;
|
|
3220
|
+
font-size: 12px;
|
|
3221
|
+
border-radius: 4px;
|
|
3222
|
+
}
|
|
3223
|
+
|
|
3224
|
+
.html-collab-action-secondary:hover:not(:disabled) {
|
|
3225
|
+
background: #f1f5f9;
|
|
3226
|
+
color: #475569;
|
|
3227
|
+
}
|
|
3228
|
+
|
|
3229
|
+
.html-collab-thread-reply {
|
|
3230
|
+
display: grid;
|
|
3231
|
+
gap: 6px;
|
|
3232
|
+
margin-top: 12px;
|
|
3233
|
+
padding-top: 10px;
|
|
3234
|
+
border-top: 1px dashed #e2e8f0;
|
|
3235
|
+
}
|
|
3236
|
+
|
|
3237
|
+
.html-collab-edit-fields {
|
|
3238
|
+
display: grid;
|
|
3239
|
+
gap: 8px;
|
|
3240
|
+
}
|
|
3241
|
+
|
|
3242
|
+
.html-collab-edit-fields select {
|
|
3243
|
+
min-height: 32px;
|
|
3244
|
+
padding: 4px 8px;
|
|
3245
|
+
}
|
|
3246
|
+
|
|
3247
|
+
.html-collab-modal-backdrop {
|
|
3248
|
+
position: fixed;
|
|
3249
|
+
inset: 0;
|
|
3250
|
+
background: rgba(15, 16, 20, 0.55);
|
|
3251
|
+
display: flex;
|
|
3252
|
+
align-items: center;
|
|
3253
|
+
justify-content: center;
|
|
3254
|
+
z-index: 200;
|
|
3255
|
+
}
|
|
3256
|
+
|
|
3257
|
+
.html-collab-modal-backdrop[hidden] {
|
|
3258
|
+
display: none;
|
|
3259
|
+
}
|
|
3260
|
+
|
|
3261
|
+
.html-collab-modal {
|
|
3262
|
+
width: min(720px, 92vw);
|
|
3263
|
+
max-height: min(80vh, 600px);
|
|
3264
|
+
background: #ffffff;
|
|
3265
|
+
border-radius: 12px;
|
|
3266
|
+
box-shadow: 0 30px 80px rgba(0, 0, 0, 0.35);
|
|
3267
|
+
display: flex;
|
|
3268
|
+
flex-direction: column;
|
|
3269
|
+
overflow: hidden;
|
|
3270
|
+
border: 1px solid #e2e8f0;
|
|
3271
|
+
}
|
|
3272
|
+
|
|
3273
|
+
.html-collab-modal-header {
|
|
3274
|
+
display: flex;
|
|
3275
|
+
align-items: center;
|
|
3276
|
+
gap: 12px;
|
|
3277
|
+
padding: 14px 20px;
|
|
3278
|
+
border-bottom: 1px solid #e2e8f0;
|
|
3279
|
+
background: #f8fafc;
|
|
3280
|
+
}
|
|
3281
|
+
|
|
3282
|
+
.html-collab-modal-title {
|
|
3283
|
+
font-weight: 600;
|
|
3284
|
+
color: #1f2937;
|
|
3285
|
+
font-size: 14px;
|
|
3286
|
+
}
|
|
3287
|
+
|
|
3288
|
+
.html-collab-modal-subtitle {
|
|
3289
|
+
font-size: 12px;
|
|
3290
|
+
color: #64748b;
|
|
3291
|
+
}
|
|
3292
|
+
|
|
3293
|
+
.html-collab-modal-actions {
|
|
3294
|
+
margin-left: auto;
|
|
3295
|
+
display: flex;
|
|
3296
|
+
gap: 6px;
|
|
3297
|
+
}
|
|
3298
|
+
|
|
3299
|
+
.html-collab-button-primary {
|
|
3300
|
+
background: #1f2937;
|
|
3301
|
+
color: #ffffff;
|
|
3302
|
+
border: 1px solid #1f2937;
|
|
3303
|
+
font-weight: 500;
|
|
3304
|
+
}
|
|
3305
|
+
|
|
3306
|
+
.html-collab-button-primary:hover:not(:disabled) {
|
|
3307
|
+
background: #111827;
|
|
3308
|
+
}
|
|
3309
|
+
|
|
3310
|
+
.html-collab-button-primary.is-success {
|
|
3311
|
+
background: #16a34a;
|
|
3312
|
+
border-color: #16a34a;
|
|
3313
|
+
}
|
|
3314
|
+
|
|
3315
|
+
.html-collab-modal-body {
|
|
3316
|
+
margin: 0;
|
|
3317
|
+
padding: 20px 24px;
|
|
3318
|
+
overflow: auto;
|
|
3319
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
3320
|
+
font-size: 13px;
|
|
3321
|
+
line-height: 1.55;
|
|
3322
|
+
white-space: pre-wrap;
|
|
3323
|
+
word-break: break-word;
|
|
3324
|
+
color: #1f2937;
|
|
3325
|
+
flex: 1;
|
|
3326
|
+
background: #ffffff;
|
|
3327
|
+
}
|
|
3328
|
+
|
|
3329
|
+
.html-collab-thread-scroll {
|
|
3330
|
+
min-height: 0;
|
|
3331
|
+
overflow: auto;
|
|
3332
|
+
padding: 12px;
|
|
3333
|
+
}
|
|
3334
|
+
|
|
3335
|
+
.html-collab-panel-heading {
|
|
3336
|
+
margin: 18px 4px 8px;
|
|
3337
|
+
color: #334155;
|
|
3338
|
+
font-size: 11px;
|
|
3339
|
+
font-weight: 700;
|
|
3340
|
+
letter-spacing: 0.06em;
|
|
3341
|
+
text-transform: uppercase;
|
|
3342
|
+
}
|
|
3343
|
+
|
|
3344
|
+
.html-collab-panel-heading:first-child {
|
|
3345
|
+
margin-top: 4px;
|
|
3346
|
+
}
|
|
3347
|
+
|
|
3348
|
+
.html-collab-empty {
|
|
3349
|
+
margin: 16px 4px;
|
|
3350
|
+
color: #64748b;
|
|
3351
|
+
font-size: 13px;
|
|
3352
|
+
line-height: 1.45;
|
|
3353
|
+
}
|
|
3354
|
+
|
|
3355
|
+
.html-collab-thread {
|
|
3356
|
+
border: 1px solid #e5e9f0;
|
|
3357
|
+
border-radius: 10px;
|
|
3358
|
+
padding: 12px;
|
|
3359
|
+
margin-bottom: 10px;
|
|
3360
|
+
background: #ffffff;
|
|
3361
|
+
cursor: pointer;
|
|
3362
|
+
transition: border-color 120ms ease, box-shadow 120ms ease;
|
|
3363
|
+
}
|
|
3364
|
+
|
|
3365
|
+
.html-collab-thread:hover {
|
|
3366
|
+
border-color: #cbd5e1;
|
|
3367
|
+
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
|
|
3368
|
+
}
|
|
3369
|
+
|
|
3370
|
+
.html-collab-thread textarea,
|
|
3371
|
+
.html-collab-thread input {
|
|
3372
|
+
cursor: text;
|
|
3373
|
+
}
|
|
3374
|
+
|
|
3375
|
+
.html-collab-thread-active {
|
|
3376
|
+
outline: 2px solid #2563eb;
|
|
3377
|
+
outline-offset: 2px;
|
|
3378
|
+
}
|
|
3379
|
+
|
|
3380
|
+
.html-collab-thread-header {
|
|
3381
|
+
display: flex;
|
|
3382
|
+
align-items: flex-start;
|
|
3383
|
+
gap: 12px;
|
|
3384
|
+
margin-bottom: 10px;
|
|
3385
|
+
}
|
|
3386
|
+
|
|
3387
|
+
.html-collab-thread-header .html-collab-thread-quote {
|
|
3388
|
+
flex: 1 1 auto;
|
|
3389
|
+
min-width: 0;
|
|
3390
|
+
margin: 4px 0 0;
|
|
3391
|
+
}
|
|
3392
|
+
|
|
3393
|
+
.html-collab-thread-pin {
|
|
3394
|
+
display: flex;
|
|
3395
|
+
flex-direction: column;
|
|
3396
|
+
align-items: flex-end;
|
|
3397
|
+
gap: 6px;
|
|
3398
|
+
flex: 0 0 auto;
|
|
3399
|
+
}
|
|
3400
|
+
|
|
3401
|
+
.html-collab-thread-number {
|
|
3402
|
+
width: 32px;
|
|
3403
|
+
min-height: 32px;
|
|
3404
|
+
padding: 0;
|
|
3405
|
+
border-color: #eab308;
|
|
3406
|
+
background: #fffbeb;
|
|
3407
|
+
font-weight: 700;
|
|
3408
|
+
}
|
|
3409
|
+
|
|
3410
|
+
.html-collab-thread-status {
|
|
3411
|
+
border-radius: 999px;
|
|
3412
|
+
background: #eef2f7;
|
|
3413
|
+
padding: 2px 10px;
|
|
3414
|
+
color: #475569;
|
|
3415
|
+
font-size: 11px;
|
|
3416
|
+
font-weight: 500;
|
|
3417
|
+
letter-spacing: 0.02em;
|
|
3418
|
+
text-transform: capitalize;
|
|
3419
|
+
}
|
|
3420
|
+
|
|
3421
|
+
.html-collab-edit-suggestion .html-collab-thread-number {
|
|
3422
|
+
border-color: #22c55e;
|
|
3423
|
+
background: #f0fdf4;
|
|
3424
|
+
}
|
|
3425
|
+
|
|
3426
|
+
.html-collab-edit-suggestion .html-collab-thread-quote {
|
|
3427
|
+
border-left-color: #22c55e;
|
|
3428
|
+
}
|
|
3429
|
+
|
|
3430
|
+
.html-collab-edit-detail {
|
|
3431
|
+
margin: 6px 0 0;
|
|
3432
|
+
color: #1f2937;
|
|
3433
|
+
font-size: 13px;
|
|
3434
|
+
line-height: 1.45;
|
|
3435
|
+
white-space: pre-wrap;
|
|
3436
|
+
}
|
|
3437
|
+
|
|
3438
|
+
.html-collab-edit-note {
|
|
3439
|
+
margin: 4px 0 0;
|
|
3440
|
+
color: #64748b;
|
|
3441
|
+
font-size: 12px;
|
|
3442
|
+
font-style: italic;
|
|
3443
|
+
line-height: 1.45;
|
|
3444
|
+
white-space: pre-wrap;
|
|
3445
|
+
}
|
|
3446
|
+
|
|
3447
|
+
.html-collab-edit-replacement {
|
|
3448
|
+
background: #ecfdf5;
|
|
3449
|
+
color: #166534;
|
|
3450
|
+
border-radius: 4px;
|
|
3451
|
+
padding: 1px 6px;
|
|
3452
|
+
font-size: 12px;
|
|
3453
|
+
}
|
|
3454
|
+
|
|
3455
|
+
.html-collab-context-menu {
|
|
3456
|
+
position: fixed;
|
|
3457
|
+
z-index: 20;
|
|
3458
|
+
display: grid;
|
|
3459
|
+
gap: 4px;
|
|
3460
|
+
min-width: 128px;
|
|
3461
|
+
padding: 6px;
|
|
3462
|
+
border: 1px solid #cbd5e1;
|
|
3463
|
+
border-radius: 8px;
|
|
3464
|
+
background: #ffffff;
|
|
3465
|
+
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.16);
|
|
3466
|
+
}
|
|
3467
|
+
|
|
3468
|
+
.html-collab-context-menu[hidden] {
|
|
3469
|
+
display: none;
|
|
3470
|
+
}
|
|
3471
|
+
|
|
3472
|
+
.html-collab-context-menu button {
|
|
3473
|
+
width: 100%;
|
|
3474
|
+
border: 0;
|
|
3475
|
+
padding: 6px 8px;
|
|
3476
|
+
text-align: left;
|
|
3477
|
+
}
|
|
3478
|
+
|
|
3479
|
+
.html-collab-hotkeys {
|
|
3480
|
+
position: fixed;
|
|
3481
|
+
top: 54px;
|
|
3482
|
+
right: 14px;
|
|
3483
|
+
z-index: 21;
|
|
3484
|
+
min-width: 220px;
|
|
3485
|
+
padding: 10px 12px;
|
|
3486
|
+
border: 1px solid #cbd5e1;
|
|
3487
|
+
border-radius: 8px;
|
|
3488
|
+
background: #ffffff;
|
|
3489
|
+
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.16);
|
|
3490
|
+
color: #334155;
|
|
3491
|
+
font-size: 12px;
|
|
3492
|
+
line-height: 1.5;
|
|
3493
|
+
}
|
|
3494
|
+
|
|
3495
|
+
.html-collab-hotkeys[hidden] {
|
|
3496
|
+
display: none;
|
|
3497
|
+
}
|
|
3498
|
+
|
|
3499
|
+
.html-collab-hotkeys kbd {
|
|
3500
|
+
display: inline-block;
|
|
3501
|
+
min-width: 22px;
|
|
3502
|
+
margin-right: 6px;
|
|
3503
|
+
border: 1px solid #cbd5e1;
|
|
3504
|
+
border-radius: 4px;
|
|
3505
|
+
background: #f8fafc;
|
|
3506
|
+
padding: 1px 5px;
|
|
3507
|
+
color: #0f172a;
|
|
3508
|
+
font: inherit;
|
|
3509
|
+
text-align: center;
|
|
3510
|
+
}
|
|
3511
|
+
|
|
3512
|
+
.html-collab-thread-quote {
|
|
3513
|
+
margin: 0 0 8px;
|
|
3514
|
+
border-left: 3px solid #eab308;
|
|
3515
|
+
padding-left: 8px;
|
|
3516
|
+
color: #475569;
|
|
3517
|
+
font-size: 12px;
|
|
3518
|
+
line-height: 1.4;
|
|
3519
|
+
}
|
|
3520
|
+
|
|
3521
|
+
.html-collab-message {
|
|
3522
|
+
margin: 10px 0 0;
|
|
3523
|
+
}
|
|
3524
|
+
|
|
3525
|
+
.html-collab-message-meta {
|
|
3526
|
+
display: flex;
|
|
3527
|
+
align-items: center;
|
|
3528
|
+
gap: 8px;
|
|
3529
|
+
color: #64748b;
|
|
3530
|
+
font-size: 12px;
|
|
3531
|
+
}
|
|
3532
|
+
|
|
3533
|
+
.html-collab-message-meta > :first-child {
|
|
3534
|
+
flex: 1 1 auto;
|
|
3535
|
+
min-width: 0;
|
|
3536
|
+
overflow: hidden;
|
|
3537
|
+
text-overflow: ellipsis;
|
|
3538
|
+
white-space: nowrap;
|
|
3539
|
+
}
|
|
3540
|
+
|
|
3541
|
+
.html-collab-message p {
|
|
3542
|
+
margin: 3px 0 0;
|
|
3543
|
+
color: #1f2937;
|
|
3544
|
+
font-size: 13px;
|
|
3545
|
+
line-height: 1.45;
|
|
3546
|
+
white-space: pre-wrap;
|
|
3547
|
+
}
|
|
3548
|
+
|
|
3549
|
+
.html-collab-message-deleted {
|
|
3550
|
+
color: #94a3b8 !important;
|
|
3551
|
+
font-style: italic;
|
|
3552
|
+
}
|
|
3553
|
+
|
|
3554
|
+
@media (max-width: 860px) {
|
|
3555
|
+
.html-collab-workspace {
|
|
3556
|
+
grid-template-columns: 1fr;
|
|
3557
|
+
}
|
|
3558
|
+
|
|
3559
|
+
.html-collab-panel {
|
|
3560
|
+
border-left: 0;
|
|
3561
|
+
border-top: 1px solid #d7dce3;
|
|
3562
|
+
}
|
|
3563
|
+
|
|
3564
|
+
#html-collab-source-frame {
|
|
3565
|
+
height: 68vh;
|
|
3566
|
+
}
|
|
3567
|
+
|
|
3568
|
+
.html-collab-brand-wordmark {
|
|
3569
|
+
display: none;
|
|
3570
|
+
}
|
|
3571
|
+
}
|
|
3572
|
+
</style>
|
|
3573
|
+
</head>
|
|
3574
|
+
<body>
|
|
3575
|
+
<div id="html-collab-shell">
|
|
3576
|
+
<header class="html-collab-toolbar" aria-label="HTML Collab review shell">
|
|
3577
|
+
<div class="html-collab-toolbar-group">
|
|
3578
|
+
<a class="html-collab-brand" href="${PROJECT_URL}" target="_blank" rel="noopener noreferrer" aria-label="Made with html-collab. Open the GitHub repository" title="Made with html-collab">${BRAND_LOGO_SVG}${BRAND_WORDMARK_SVG}</a>
|
|
3579
|
+
<input id="html-collab-reviewer" type="text" autocomplete="name" aria-label="Reviewer name" placeholder="Your name">
|
|
3580
|
+
</div>
|
|
3581
|
+
<div class="html-collab-toolbar-group">
|
|
3582
|
+
<button id="html-collab-add-comment" type="button" disabled>Comment</button>
|
|
3583
|
+
<button id="html-collab-suggest-edit" type="button" disabled>Edit</button>
|
|
3584
|
+
<select id="html-collab-edit-view" aria-label="Edit preview mode">
|
|
3585
|
+
<option value="markup">Markup</option>
|
|
3586
|
+
<option value="preview">Preview</option>
|
|
3587
|
+
</select>
|
|
3588
|
+
<button id="html-collab-merge" type="button">Merge</button>
|
|
3589
|
+
<button id="html-collab-brief" type="button">Brief</button>
|
|
3590
|
+
<button id="html-collab-autosave" type="button" aria-pressed="false">Autosave</button>
|
|
3591
|
+
<button id="html-collab-hotkeys-button" type="button" aria-expanded="false" title="Keyboard shortcuts">?</button>
|
|
3592
|
+
<input id="html-collab-merge-files" type="file" accept=".html,text/html" multiple hidden>
|
|
3593
|
+
<div class="html-collab-status" id="html-collab-status">Review envelope ready</div>
|
|
3594
|
+
</div>
|
|
3595
|
+
</header>
|
|
3596
|
+
<div class="html-collab-workspace">
|
|
3597
|
+
<main id="html-collab-canvas">
|
|
3598
|
+
<iframe
|
|
3599
|
+
id="html-collab-source-frame"
|
|
3600
|
+
title="Reviewed HTML source"
|
|
3601
|
+
></iframe>
|
|
3602
|
+
</main>
|
|
3603
|
+
<div class="html-collab-context-menu" id="html-collab-context-menu" hidden>
|
|
3604
|
+
<button id="html-collab-context-comment" type="button">Comment (C)</button>
|
|
3605
|
+
<button id="html-collab-context-edit" type="button">Edit (E)</button>
|
|
3606
|
+
</div>
|
|
3607
|
+
<div class="html-collab-hotkeys" id="html-collab-hotkeys" hidden>
|
|
3608
|
+
<div><kbd>C</kbd>Comment on selected text</div>
|
|
3609
|
+
<div><kbd>E</kbd>Suggest edit on selected text</div>
|
|
3610
|
+
<div><kbd>Enter</kbd>Add or suggest</div>
|
|
3611
|
+
<div><kbd>Esc</kbd>Cancel or close</div>
|
|
3612
|
+
<div><kbd>Shift Enter</kbd>Line break</div>
|
|
3613
|
+
</div>
|
|
3614
|
+
<div class="html-collab-modal-backdrop" id="html-collab-brief-modal" hidden>
|
|
3615
|
+
<div class="html-collab-modal" role="dialog" aria-modal="true" aria-labelledby="html-collab-brief-modal-title">
|
|
3616
|
+
<header class="html-collab-modal-header">
|
|
3617
|
+
<span class="html-collab-modal-title" id="html-collab-brief-modal-title">Review brief</span>
|
|
3618
|
+
<span class="html-collab-modal-subtitle">Markdown · paste into your AI</span>
|
|
3619
|
+
<div class="html-collab-modal-actions">
|
|
3620
|
+
<button id="html-collab-brief-copy" type="button" class="html-collab-button-primary">Copy</button>
|
|
3621
|
+
<button id="html-collab-brief-download" type="button">Download .md</button>
|
|
3622
|
+
<button id="html-collab-brief-close" type="button" aria-label="Close">✕</button>
|
|
3623
|
+
</div>
|
|
3624
|
+
</header>
|
|
3625
|
+
<pre class="html-collab-modal-body" id="html-collab-brief-body"></pre>
|
|
3626
|
+
</div>
|
|
3627
|
+
</div>
|
|
3628
|
+
<aside class="html-collab-panel" aria-label="Review comments">
|
|
3629
|
+
<section class="html-collab-composer" id="html-collab-composer" hidden>
|
|
3630
|
+
<blockquote class="html-collab-selected-quote" id="html-collab-selected-quote"></blockquote>
|
|
3631
|
+
<textarea id="html-collab-comment-body" rows="4" placeholder="Comment"></textarea>
|
|
3632
|
+
<div class="html-collab-composer-actions">
|
|
3633
|
+
<button id="html-collab-submit-comment" type="button">Add (Enter)</button>
|
|
3634
|
+
<button id="html-collab-cancel-comment" type="button">Cancel (Esc)</button>
|
|
3635
|
+
</div>
|
|
3636
|
+
</section>
|
|
3637
|
+
<section class="html-collab-composer" id="html-collab-edit-composer" hidden>
|
|
3638
|
+
<blockquote class="html-collab-selected-quote" id="html-collab-edit-selected-quote"></blockquote>
|
|
3639
|
+
<div class="html-collab-edit-fields">
|
|
3640
|
+
<select id="html-collab-edit-kind" aria-label="Suggested edit type">
|
|
3641
|
+
<option value="replace">Replace selection</option>
|
|
3642
|
+
<option value="insert">Insert after selection</option>
|
|
3643
|
+
<option value="delete">Delete selection</option>
|
|
3644
|
+
</select>
|
|
3645
|
+
<textarea id="html-collab-edit-replacement" rows="3" placeholder="Replacement or inserted text"></textarea>
|
|
3646
|
+
<textarea id="html-collab-edit-note" rows="2" placeholder="Optional note"></textarea>
|
|
3647
|
+
</div>
|
|
3648
|
+
<div class="html-collab-composer-actions">
|
|
3649
|
+
<button id="html-collab-submit-edit" type="button">Suggest (Enter)</button>
|
|
3650
|
+
<button id="html-collab-cancel-edit" type="button">Cancel (Esc)</button>
|
|
3651
|
+
</div>
|
|
3652
|
+
</section>
|
|
3653
|
+
<section class="html-collab-thread-scroll">
|
|
3654
|
+
<p class="html-collab-empty" id="html-collab-empty">No comments yet.</p>
|
|
3655
|
+
<div id="html-collab-thread-list"></div>
|
|
3656
|
+
</section>
|
|
3657
|
+
</aside>
|
|
3658
|
+
</div>
|
|
3659
|
+
</div>
|
|
3660
|
+
|
|
3661
|
+
<script type="application/json" id="${SOURCE_SCRIPT_ID}">${jsonForScript(source)}</script>
|
|
3662
|
+
<script type="application/json" id="${STATE_SCRIPT_ID}">${jsonForScript(state)}</script>
|
|
3663
|
+
<script>
|
|
3664
|
+
${iframeLoaderRuntime}
|
|
3665
|
+
</script>
|
|
3666
|
+
</body>
|
|
3667
|
+
</html>
|
|
3668
|
+
`;
|
|
3669
|
+
}
|
|
3670
|
+
function parseJsonScript(html, id) {
|
|
3671
|
+
const openTagPattern = /<script\b[^>]*>/gi;
|
|
3672
|
+
let match;
|
|
3673
|
+
while ((match = openTagPattern.exec(html)) !== null) {
|
|
3674
|
+
const openTag = match[0];
|
|
3675
|
+
if (!scriptTagHasId(openTag, id)) {
|
|
3676
|
+
continue;
|
|
3677
|
+
}
|
|
3678
|
+
const contentStart = match.index + openTag.length;
|
|
3679
|
+
const closeIndex = html.indexOf("</script>", contentStart);
|
|
3680
|
+
if (closeIndex === -1) {
|
|
3681
|
+
throw new Error(`Missing closing script tag for ${id}`);
|
|
3682
|
+
}
|
|
3683
|
+
return JSON.parse(html.slice(contentStart, closeIndex));
|
|
3684
|
+
}
|
|
3685
|
+
throw new Error(missingScriptMessage(id));
|
|
3686
|
+
}
|
|
3687
|
+
function extractSourceTitle(sourceHtml) {
|
|
3688
|
+
const match = sourceHtml.match(/<title\b[^>]*>([\s\S]*?)<\/title>/i);
|
|
3689
|
+
const title = match?.[1]?.replace(/\s+/g, " ").trim();
|
|
3690
|
+
return title ? decodeHtmlText(title) : undefined;
|
|
3691
|
+
}
|
|
3692
|
+
function decodeHtmlText(value) {
|
|
3693
|
+
return value.replace(/</gi, "<").replace(/>/gi, ">").replace(/&/gi, "&").replace(/"/gi, '"').replace(/'|'/gi, "'");
|
|
3694
|
+
}
|
|
3695
|
+
function missingScriptMessage(id) {
|
|
3696
|
+
if (id === STATE_SCRIPT_ID) {
|
|
3697
|
+
return "This does not look like an html-collab review file: missing review state. Run html-collab wrap first.";
|
|
3698
|
+
}
|
|
3699
|
+
if (id === SOURCE_SCRIPT_ID) {
|
|
3700
|
+
return "This does not look like an html-collab review file: missing embedded source. Run html-collab wrap first.";
|
|
3701
|
+
}
|
|
3702
|
+
return `Missing ${id} script`;
|
|
3703
|
+
}
|
|
3704
|
+
function scriptTagHasId(openTag, id) {
|
|
3705
|
+
const escapedId = escapeRegExp(id);
|
|
3706
|
+
return new RegExp(`\\bid\\s*=\\s*(["'])${escapedId}\\1`, "i").test(openTag);
|
|
3707
|
+
}
|
|
3708
|
+
function isSourcePayload(value) {
|
|
3709
|
+
if (!value || typeof value !== "object") {
|
|
3710
|
+
return false;
|
|
3711
|
+
}
|
|
3712
|
+
const payload = value;
|
|
3713
|
+
return payload.encoding === "base64" && typeof payload.html === "string";
|
|
3714
|
+
}
|
|
3715
|
+
function isReviewState(value) {
|
|
3716
|
+
if (!value || typeof value !== "object") {
|
|
3717
|
+
return false;
|
|
3718
|
+
}
|
|
3719
|
+
const state = value;
|
|
3720
|
+
return state.schemaVersion === 1 && typeof state.docId === "string" && typeof state.sourceFingerprint === "string" && Boolean(state.actors) && typeof state.actors === "object" && Array.isArray(state.ops);
|
|
3721
|
+
}
|
|
3722
|
+
function jsonForScript(value) {
|
|
3723
|
+
return JSON.stringify(value, null, 2).replace(/[<>&]/g, (character) => {
|
|
3724
|
+
switch (character) {
|
|
3725
|
+
case "<":
|
|
3726
|
+
return "\\u003c";
|
|
3727
|
+
case ">":
|
|
3728
|
+
return "\\u003e";
|
|
3729
|
+
case "&":
|
|
3730
|
+
return "\\u0026";
|
|
3731
|
+
default:
|
|
3732
|
+
return character;
|
|
3733
|
+
}
|
|
3734
|
+
});
|
|
3735
|
+
}
|
|
3736
|
+
function escapeHtml(value) {
|
|
3737
|
+
return value.replace(/[&<>"']/g, (character) => {
|
|
3738
|
+
switch (character) {
|
|
3739
|
+
case "&":
|
|
3740
|
+
return "&";
|
|
3741
|
+
case "<":
|
|
3742
|
+
return "<";
|
|
3743
|
+
case ">":
|
|
3744
|
+
return ">";
|
|
3745
|
+
case '"':
|
|
3746
|
+
return """;
|
|
3747
|
+
case "'":
|
|
3748
|
+
return "'";
|
|
3749
|
+
default:
|
|
3750
|
+
return character;
|
|
3751
|
+
}
|
|
3752
|
+
});
|
|
3753
|
+
}
|
|
3754
|
+
function escapeRegExp(value) {
|
|
3755
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
3756
|
+
}
|
|
3757
|
+
|
|
3758
|
+
// src/commands/extract.ts
|
|
3759
|
+
async function extractFile(inputPath, options) {
|
|
3760
|
+
const reviewHtml = await readFile(inputPath, "utf8");
|
|
3761
|
+
const state = extractReviewState(reviewHtml);
|
|
3762
|
+
const output = extractReview(state, options.format, {
|
|
3763
|
+
reviewHref: reviewHref(inputPath, options.outputPath)
|
|
3764
|
+
});
|
|
3765
|
+
if (options.outputPath) {
|
|
3766
|
+
await writeFile(options.outputPath, output, "utf8");
|
|
3767
|
+
}
|
|
3768
|
+
return output;
|
|
3769
|
+
}
|
|
3770
|
+
function reviewHref(inputPath, outputPath) {
|
|
3771
|
+
if (!outputPath) {
|
|
3772
|
+
return encodeURI(inputPath.split(sep).join("/"));
|
|
3773
|
+
}
|
|
3774
|
+
const inputAbsolute = resolve(inputPath);
|
|
3775
|
+
const outputDirAbsolute = resolve(dirname(outputPath));
|
|
3776
|
+
const href = relative(outputDirAbsolute, inputAbsolute) || inputPath;
|
|
3777
|
+
return encodeURI(href.split(sep).join("/"));
|
|
3778
|
+
}
|
|
3779
|
+
|
|
3780
|
+
// src/commands/merge.ts
|
|
3781
|
+
import { readFile as readFile2, writeFile as writeFile2 } from "node:fs/promises";
|
|
3782
|
+
|
|
3783
|
+
// src/format/merge.ts
|
|
3784
|
+
function mergeReviewStates(states) {
|
|
3785
|
+
if (states.length === 0) {
|
|
3786
|
+
throw new Error("Expected at least one review state to merge");
|
|
3787
|
+
}
|
|
3788
|
+
const [base] = states;
|
|
3789
|
+
const actors = { ...base.actors };
|
|
3790
|
+
const opsById = new Map;
|
|
3791
|
+
let addedOps = 0;
|
|
3792
|
+
let addedActors = 0;
|
|
3793
|
+
for (const op of base.ops) {
|
|
3794
|
+
opsById.set(op.opId, op);
|
|
3795
|
+
}
|
|
3796
|
+
for (const state of states.slice(1)) {
|
|
3797
|
+
if (state.docId !== base.docId) {
|
|
3798
|
+
throw new Error(`Cannot merge ${state.docId}: expected docId ${base.docId}`);
|
|
3799
|
+
}
|
|
3800
|
+
if (state.sourceFingerprint !== base.sourceFingerprint) {
|
|
3801
|
+
throw new Error(`Cannot merge ${state.docId}: source fingerprint mismatch`);
|
|
3802
|
+
}
|
|
3803
|
+
for (const [actorId, actor] of Object.entries(state.actors)) {
|
|
3804
|
+
if (!actors[actorId]) {
|
|
3805
|
+
addedActors += 1;
|
|
3806
|
+
}
|
|
3807
|
+
actors[actorId] = actors[actorId] ?? actor;
|
|
3808
|
+
}
|
|
3809
|
+
for (const op of state.ops) {
|
|
3810
|
+
if (!opsById.has(op.opId)) {
|
|
3811
|
+
addedOps += 1;
|
|
3812
|
+
}
|
|
3813
|
+
opsById.set(op.opId, opsById.get(op.opId) ?? op);
|
|
3814
|
+
}
|
|
3815
|
+
}
|
|
3816
|
+
return {
|
|
3817
|
+
state: {
|
|
3818
|
+
...base,
|
|
3819
|
+
actors,
|
|
3820
|
+
ops: Array.from(opsById.values()).sort(compareOpsForMerge)
|
|
3821
|
+
},
|
|
3822
|
+
addedOps,
|
|
3823
|
+
addedActors
|
|
3824
|
+
};
|
|
3825
|
+
}
|
|
3826
|
+
function compareOpsForMerge(left, right) {
|
|
3827
|
+
if (left.clock !== right.clock) {
|
|
3828
|
+
return left.clock - right.clock;
|
|
3829
|
+
}
|
|
3830
|
+
const time = left.time.localeCompare(right.time);
|
|
3831
|
+
if (time !== 0) {
|
|
3832
|
+
return time;
|
|
3833
|
+
}
|
|
3834
|
+
return left.opId.localeCompare(right.opId);
|
|
3835
|
+
}
|
|
3836
|
+
|
|
3837
|
+
// src/commands/merge.ts
|
|
3838
|
+
async function mergeFiles(inputPaths, outputPath) {
|
|
3839
|
+
if (inputPaths.length < 2) {
|
|
3840
|
+
throw new Error("Expected at least two reviewed HTML files to merge");
|
|
3841
|
+
}
|
|
3842
|
+
const reviewHtmlFiles = await Promise.all(inputPaths.map((path) => readFile2(path, "utf8")));
|
|
3843
|
+
const source = extractSourcePayload(reviewHtmlFiles[0]);
|
|
3844
|
+
const states = reviewHtmlFiles.map(extractReviewState);
|
|
3845
|
+
const result = mergeReviewStates(states);
|
|
3846
|
+
const mergedHtml = createReviewHtmlFromParts(source, result.state);
|
|
3847
|
+
await writeFile2(outputPath, mergedHtml, "utf8");
|
|
3848
|
+
}
|
|
3849
|
+
|
|
3850
|
+
// src/commands/skill.ts
|
|
3851
|
+
import { access, readFile as readFile3, writeFile as writeFile3 } from "node:fs/promises";
|
|
3852
|
+
import { dirname as dirname2, join, resolve as resolve2 } from "node:path";
|
|
3853
|
+
import { fileURLToPath } from "node:url";
|
|
3854
|
+
async function skillFile(outputPath) {
|
|
3855
|
+
const skill = await readPackagedSkill();
|
|
3856
|
+
if (outputPath) {
|
|
3857
|
+
await writeFile3(outputPath, skill);
|
|
3858
|
+
}
|
|
3859
|
+
return skill;
|
|
3860
|
+
}
|
|
3861
|
+
async function readPackagedSkill() {
|
|
3862
|
+
const path = await findPackagedSkill(dirname2(fileURLToPath(import.meta.url)));
|
|
3863
|
+
return readFile3(path, "utf8");
|
|
3864
|
+
}
|
|
3865
|
+
async function findPackagedSkill(startDir) {
|
|
3866
|
+
let current = resolve2(startDir);
|
|
3867
|
+
while (true) {
|
|
3868
|
+
const candidate = join(current, "skill", "SKILL.md");
|
|
3869
|
+
if (await pathExists(candidate)) {
|
|
3870
|
+
return candidate;
|
|
3871
|
+
}
|
|
3872
|
+
const parent = dirname2(current);
|
|
3873
|
+
if (parent === current) {
|
|
3874
|
+
throw new Error("Could not find packaged html-collab skill");
|
|
3875
|
+
}
|
|
3876
|
+
current = parent;
|
|
3877
|
+
}
|
|
3878
|
+
}
|
|
3879
|
+
async function pathExists(path) {
|
|
3880
|
+
try {
|
|
3881
|
+
await access(path);
|
|
3882
|
+
return true;
|
|
3883
|
+
} catch {
|
|
3884
|
+
return false;
|
|
3885
|
+
}
|
|
3886
|
+
}
|
|
3887
|
+
|
|
3888
|
+
// src/commands/unwrap.ts
|
|
3889
|
+
import { readFile as readFile4, writeFile as writeFile4 } from "node:fs/promises";
|
|
3890
|
+
|
|
3891
|
+
// src/format/apply-edits.ts
|
|
3892
|
+
function applyAcceptedEdits(sourceHtml, state) {
|
|
3893
|
+
const accepted = reduceReviewState(state).edits.filter((edit) => edit.status === "accepted");
|
|
3894
|
+
const planned = accepted.map((edit) => planEdit(sourceHtml, edit));
|
|
3895
|
+
planned.sort((left, right) => right.start - left.start);
|
|
3896
|
+
let html = sourceHtml;
|
|
3897
|
+
for (const edit of planned) {
|
|
3898
|
+
html = html.slice(0, edit.start) + edit.replacement + html.slice(edit.end);
|
|
3899
|
+
}
|
|
3900
|
+
return {
|
|
3901
|
+
html,
|
|
3902
|
+
appliedEdits: planned.length
|
|
3903
|
+
};
|
|
3904
|
+
}
|
|
3905
|
+
function planEdit(sourceHtml, edit) {
|
|
3906
|
+
const quote = edit.anchor.quote;
|
|
3907
|
+
const start = findUniqueQuote(sourceHtml, quote, edit.editId);
|
|
3908
|
+
const end = start + quote.length;
|
|
3909
|
+
if (edit.kind === "replace") {
|
|
3910
|
+
return {
|
|
3911
|
+
start,
|
|
3912
|
+
end,
|
|
3913
|
+
replacement: edit.replacement ?? ""
|
|
3914
|
+
};
|
|
3915
|
+
}
|
|
3916
|
+
if (edit.kind === "insert") {
|
|
3917
|
+
return {
|
|
3918
|
+
start: end,
|
|
3919
|
+
end,
|
|
3920
|
+
replacement: edit.replacement ?? ""
|
|
3921
|
+
};
|
|
3922
|
+
}
|
|
3923
|
+
return {
|
|
3924
|
+
start,
|
|
3925
|
+
end,
|
|
3926
|
+
replacement: ""
|
|
3927
|
+
};
|
|
3928
|
+
}
|
|
3929
|
+
function findUniqueQuote(sourceHtml, quote, editId) {
|
|
3930
|
+
if (!quote) {
|
|
3931
|
+
throw new Error(`Cannot apply edit ${editId}: empty selected quote`);
|
|
3932
|
+
}
|
|
3933
|
+
const first = sourceHtml.indexOf(quote);
|
|
3934
|
+
if (first === -1) {
|
|
3935
|
+
throw new Error(`Cannot apply edit ${editId}: selected quote not found in source HTML`);
|
|
3936
|
+
}
|
|
3937
|
+
const second = sourceHtml.indexOf(quote, first + quote.length);
|
|
3938
|
+
if (second !== -1) {
|
|
3939
|
+
throw new Error(`Cannot apply edit ${editId}: selected quote is ambiguous in source HTML`);
|
|
3940
|
+
}
|
|
3941
|
+
return first;
|
|
3942
|
+
}
|
|
3943
|
+
|
|
3944
|
+
// src/commands/unwrap.ts
|
|
3945
|
+
async function unwrapFile(inputPath, outputPath, options = {}) {
|
|
3946
|
+
const reviewHtml = await readFile4(inputPath, "utf8");
|
|
3947
|
+
const sourceBytes = unwrapReviewHtml(reviewHtml);
|
|
3948
|
+
if (!options.applyAcceptedEdits) {
|
|
3949
|
+
await writeFile4(outputPath, sourceBytes);
|
|
3950
|
+
return {};
|
|
3951
|
+
}
|
|
3952
|
+
const state = extractReviewState(reviewHtml);
|
|
3953
|
+
const result = applyAcceptedEdits(sourceBytes.toString("utf8"), state);
|
|
3954
|
+
await writeFile4(outputPath, result.html, "utf8");
|
|
3955
|
+
return { appliedEdits: result.appliedEdits };
|
|
3956
|
+
}
|
|
3957
|
+
|
|
3958
|
+
// src/commands/wrap.ts
|
|
3959
|
+
import { readFile as readFile5, writeFile as writeFile5 } from "node:fs/promises";
|
|
3960
|
+
async function wrapFile(inputPath, outputPath) {
|
|
3961
|
+
const sourceBytes = await readFile5(inputPath);
|
|
3962
|
+
const reviewHtml = createReviewHtml(sourceBytes, { sourcePath: inputPath });
|
|
3963
|
+
await writeFile5(outputPath, reviewHtml, "utf8");
|
|
3964
|
+
}
|
|
3965
|
+
|
|
3966
|
+
// src/cli.ts
|
|
3967
|
+
async function main(argv = process.argv) {
|
|
3968
|
+
try {
|
|
3969
|
+
const command = parseArgs(argv.slice(2));
|
|
3970
|
+
if (command.command === "wrap") {
|
|
3971
|
+
await wrapFile(command.inputPath, command.outputPath);
|
|
3972
|
+
return;
|
|
3973
|
+
}
|
|
3974
|
+
if (command.command === "merge") {
|
|
3975
|
+
await mergeFiles(command.inputPaths, command.outputPath);
|
|
3976
|
+
return;
|
|
3977
|
+
}
|
|
3978
|
+
if (command.command === "extract") {
|
|
3979
|
+
const output = await extractFile(command.inputPath, {
|
|
3980
|
+
format: command.format,
|
|
3981
|
+
outputPath: command.outputPath
|
|
3982
|
+
});
|
|
3983
|
+
if (!command.outputPath) {
|
|
3984
|
+
process.stdout.write(output);
|
|
3985
|
+
}
|
|
3986
|
+
return;
|
|
3987
|
+
}
|
|
3988
|
+
if (command.command === "skill") {
|
|
3989
|
+
const output = await skillFile(command.outputPath);
|
|
3990
|
+
if (!command.outputPath) {
|
|
3991
|
+
process.stdout.write(output);
|
|
3992
|
+
}
|
|
3993
|
+
return;
|
|
3994
|
+
}
|
|
3995
|
+
const result = await unwrapFile(command.inputPath, command.outputPath, {
|
|
3996
|
+
applyAcceptedEdits: command.applyAcceptedEdits
|
|
3997
|
+
});
|
|
3998
|
+
if (command.applyAcceptedEdits && result.appliedEdits === 0) {
|
|
3999
|
+
console.warn("No accepted edits to apply; wrote the original source HTML.");
|
|
4000
|
+
}
|
|
4001
|
+
} catch (error) {
|
|
4002
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
4003
|
+
console.error(message);
|
|
4004
|
+
console.error("");
|
|
4005
|
+
console.error(usage(basename2(argv[1] ?? "html-collab")));
|
|
4006
|
+
process.exitCode = 1;
|
|
4007
|
+
}
|
|
4008
|
+
}
|
|
4009
|
+
function parseArgs(args) {
|
|
4010
|
+
const [command, inputPath, ...rest] = args;
|
|
4011
|
+
if (command !== "wrap" && command !== "unwrap" && command !== "merge" && command !== "extract" && command !== "skill") {
|
|
4012
|
+
throw new Error("Expected command: wrap, merge, extract, skill, or unwrap");
|
|
4013
|
+
}
|
|
4014
|
+
if (command === "skill") {
|
|
4015
|
+
const outputPath2 = parseSkillOptions(args.slice(1));
|
|
4016
|
+
return { command, outputPath: outputPath2 };
|
|
4017
|
+
}
|
|
4018
|
+
if (command === "merge") {
|
|
4019
|
+
const outIndex = args.indexOf("--out");
|
|
4020
|
+
if (outIndex === -1) {
|
|
4021
|
+
throw new Error("Missing required option --out");
|
|
4022
|
+
}
|
|
4023
|
+
const outputPath2 = args[outIndex + 1];
|
|
4024
|
+
if (!outputPath2 || outputPath2.startsWith("-")) {
|
|
4025
|
+
throw new Error("Expected a value after --out");
|
|
4026
|
+
}
|
|
4027
|
+
const inputPaths = args.slice(1, outIndex);
|
|
4028
|
+
const unknownArgs = args.slice(outIndex + 2);
|
|
4029
|
+
if (unknownArgs.length > 0) {
|
|
4030
|
+
throw new Error(`Unknown option: ${unknownArgs[0]}`);
|
|
4031
|
+
}
|
|
4032
|
+
if (inputPaths.length < 2) {
|
|
4033
|
+
throw new Error("Expected at least two reviewed HTML files for merge");
|
|
4034
|
+
}
|
|
4035
|
+
return { command, inputPaths, outputPath: outputPath2 };
|
|
4036
|
+
}
|
|
4037
|
+
if (command === "extract") {
|
|
4038
|
+
if (!inputPath || inputPath.startsWith("-")) {
|
|
4039
|
+
throw new Error("Expected input reviewed HTML file for extract");
|
|
4040
|
+
}
|
|
4041
|
+
const { format, outputPath: outputPath2 } = parseExtractOptions(rest);
|
|
4042
|
+
return { command, inputPath, format, outputPath: outputPath2 };
|
|
4043
|
+
}
|
|
4044
|
+
if (!inputPath || inputPath.startsWith("-")) {
|
|
4045
|
+
throw new Error(`Expected input HTML file for ${command}`);
|
|
4046
|
+
}
|
|
4047
|
+
if (command === "unwrap") {
|
|
4048
|
+
const { outputPath: outputPath2, applyAcceptedEdits: applyAcceptedEdits2 } = parseUnwrapOptions(rest);
|
|
4049
|
+
return { command, inputPath, outputPath: outputPath2, applyAcceptedEdits: applyAcceptedEdits2 };
|
|
4050
|
+
}
|
|
4051
|
+
const outputPath = readRequiredOption(rest, "--out");
|
|
4052
|
+
return { command, inputPath, outputPath };
|
|
4053
|
+
}
|
|
4054
|
+
function readRequiredOption(args, name) {
|
|
4055
|
+
const index = args.indexOf(name);
|
|
4056
|
+
if (index === -1) {
|
|
4057
|
+
throw new Error(`Missing required option ${name}`);
|
|
4058
|
+
}
|
|
4059
|
+
const value = args[index + 1];
|
|
4060
|
+
if (!value || value.startsWith("-")) {
|
|
4061
|
+
throw new Error(`Expected a value after ${name}`);
|
|
4062
|
+
}
|
|
4063
|
+
const unknownArgs = args.filter((arg, argIndex) => argIndex !== index && argIndex !== index + 1);
|
|
4064
|
+
if (unknownArgs.length > 0) {
|
|
4065
|
+
throw new Error(`Unknown option: ${unknownArgs[0]}`);
|
|
4066
|
+
}
|
|
4067
|
+
return value;
|
|
4068
|
+
}
|
|
4069
|
+
function parseExtractOptions(args) {
|
|
4070
|
+
let format = "markdown";
|
|
4071
|
+
let outputPath;
|
|
4072
|
+
let index = 0;
|
|
4073
|
+
while (index < args.length) {
|
|
4074
|
+
const arg = args[index];
|
|
4075
|
+
const value = args[index + 1];
|
|
4076
|
+
if (arg === "--format") {
|
|
4077
|
+
if (!isExtractFormat(value)) {
|
|
4078
|
+
throw new Error("Expected --format to be markdown, json, text, or agent");
|
|
4079
|
+
}
|
|
4080
|
+
format = value;
|
|
4081
|
+
index += 2;
|
|
4082
|
+
continue;
|
|
4083
|
+
}
|
|
4084
|
+
if (arg === "--out") {
|
|
4085
|
+
if (!value || value.startsWith("-")) {
|
|
4086
|
+
throw new Error("Expected a value after --out");
|
|
4087
|
+
}
|
|
4088
|
+
outputPath = value;
|
|
4089
|
+
index += 2;
|
|
4090
|
+
continue;
|
|
4091
|
+
}
|
|
4092
|
+
throw new Error(`Unknown option: ${arg}`);
|
|
4093
|
+
}
|
|
4094
|
+
return { format, outputPath };
|
|
4095
|
+
}
|
|
4096
|
+
function parseUnwrapOptions(args) {
|
|
4097
|
+
let outputPath;
|
|
4098
|
+
let applyAcceptedEdits2 = false;
|
|
4099
|
+
let index = 0;
|
|
4100
|
+
while (index < args.length) {
|
|
4101
|
+
const arg = args[index];
|
|
4102
|
+
const value = args[index + 1];
|
|
4103
|
+
if (arg === "--out") {
|
|
4104
|
+
if (!value || value.startsWith("-")) {
|
|
4105
|
+
throw new Error("Expected a value after --out");
|
|
4106
|
+
}
|
|
4107
|
+
outputPath = value;
|
|
4108
|
+
index += 2;
|
|
4109
|
+
continue;
|
|
4110
|
+
}
|
|
4111
|
+
if (arg === "--apply-edits") {
|
|
4112
|
+
applyAcceptedEdits2 = true;
|
|
4113
|
+
index += 1;
|
|
4114
|
+
continue;
|
|
4115
|
+
}
|
|
4116
|
+
throw new Error(`Unknown option: ${arg}`);
|
|
4117
|
+
}
|
|
4118
|
+
if (!outputPath) {
|
|
4119
|
+
throw new Error("Missing required option --out");
|
|
4120
|
+
}
|
|
4121
|
+
return { outputPath, applyAcceptedEdits: applyAcceptedEdits2 };
|
|
4122
|
+
}
|
|
4123
|
+
function parseSkillOptions(args) {
|
|
4124
|
+
if (args.length === 0) {
|
|
4125
|
+
return;
|
|
4126
|
+
}
|
|
4127
|
+
const outputPath = readRequiredOption(args, "--out");
|
|
4128
|
+
return outputPath;
|
|
4129
|
+
}
|
|
4130
|
+
function isExtractFormat(value) {
|
|
4131
|
+
return value === "markdown" || value === "json" || value === "text" || value === "agent";
|
|
4132
|
+
}
|
|
4133
|
+
function usage(commandName) {
|
|
4134
|
+
return `Usage:
|
|
4135
|
+
${commandName} wrap report.html --out report.review.html
|
|
4136
|
+
${commandName} merge glen.review.html maya.review.html --out merged.review.html
|
|
4137
|
+
${commandName} extract merged.review.html --format markdown --out review-brief.md
|
|
4138
|
+
${commandName} extract merged.review.html --format agent --out agent-plan.md
|
|
4139
|
+
${commandName} extract merged.review.html --format json --out review-bundle.json
|
|
4140
|
+
${commandName} skill --out html-collab.SKILL.md
|
|
4141
|
+
${commandName} unwrap report.review.html --out report.final.html
|
|
4142
|
+
${commandName} unwrap report.review.html --apply-edits --out report.final.html`;
|
|
4143
|
+
}
|
|
4144
|
+
if (isDirectRun()) {
|
|
4145
|
+
await main(process.argv);
|
|
4146
|
+
}
|
|
4147
|
+
function isDirectRun() {
|
|
4148
|
+
if (!process.argv[1]) {
|
|
4149
|
+
return false;
|
|
4150
|
+
}
|
|
4151
|
+
try {
|
|
4152
|
+
return realpathSync(fileURLToPath2(import.meta.url)) === realpathSync(process.argv[1]);
|
|
4153
|
+
} catch {
|
|
4154
|
+
return fileURLToPath2(import.meta.url) === process.argv[1];
|
|
4155
|
+
}
|
|
4156
|
+
}
|
|
4157
|
+
export {
|
|
4158
|
+
main
|
|
4159
|
+
};
|