@erdemtuna/doc-review 0.7.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 +117 -0
- package/package.json +57 -0
- package/src/SKILL.md +143 -0
- package/src/anchor-text.js +115 -0
- package/src/chrome-client.js +986 -0
- package/src/chrome-session.js +15 -0
- package/src/chrome.css +458 -0
- package/src/chrome.html +83 -0
- package/src/cli.js +341 -0
- package/src/click-target.js +26 -0
- package/src/editing.js +99 -0
- package/src/frame-channel.js +44 -0
- package/src/frame-policy.js +16 -0
- package/src/html-transform.js +62 -0
- package/src/markdown.js +87 -0
- package/src/paths.js +87 -0
- package/src/sdk.js +1520 -0
- package/src/serialize.js +46 -0
- package/src/server-entry.js +26 -0
- package/src/server-lock.js +101 -0
- package/src/server.js +1268 -0
- package/src/setup.js +117 -0
- package/src/state.js +491 -0
package/src/sdk.js
ADDED
|
@@ -0,0 +1,1520 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* doc-review artifact SDK.
|
|
3
|
+
*
|
|
4
|
+
* Runs inside the sandboxed iframe alongside the artifact. It owns the
|
|
5
|
+
* document: editing, highlights, target resolution and serialization. It never
|
|
6
|
+
* talks to the server — everything crosses to the chrome page by postMessage.
|
|
7
|
+
*/
|
|
8
|
+
import { buildContext, findQuote } from "./anchor-text.js";
|
|
9
|
+
import { hashClickAction, navigationHref } from "./click-target.js";
|
|
10
|
+
import { classifyHref, externalHref, linkStyleFixup, listCommandFor, listStyleFixup, normalizeHref } from "./editing.js";
|
|
11
|
+
import { frameMessage, initializeChannelFromDocument, matchesFrameMessage } from "./frame-channel.js";
|
|
12
|
+
import { keepBodyEditable, serializeDocument, UI_ATTR, MARK_ATTR } from "./serialize.js";
|
|
13
|
+
|
|
14
|
+
initializeChannelFromDocument();
|
|
15
|
+
|
|
16
|
+
const SAVE_DEBOUNCE_MS = 700;
|
|
17
|
+
const EDIT_FLUSH_MS = 500;
|
|
18
|
+
const MEDIA = /^(img|svg|canvas|video|picture|iframe|hr|figure)$/i;
|
|
19
|
+
|
|
20
|
+
// The chrome page lives on the other loopback hostname (a separate origin, so
|
|
21
|
+
// the reviewed document can never touch it directly). Address it explicitly so
|
|
22
|
+
// nothing we post can be read by any other embedder.
|
|
23
|
+
const CHROME_ORIGIN = `${location.protocol}//${location.hostname === "127.0.0.1" ? "localhost" : "127.0.0.1"}:${location.port}`;
|
|
24
|
+
|
|
25
|
+
const post = (type, payload) => parent.postMessage(frameMessage(type, payload), CHROME_ORIGIN);
|
|
26
|
+
|
|
27
|
+
let pending = null; // { id, marks } for an uncommitted highlight
|
|
28
|
+
let hoverTarget = null;
|
|
29
|
+
let hoverMove = null; // the innermost block under the cursor, movable via the handle
|
|
30
|
+
let hoverMedia = null; // the img/video under the cursor, resizable via the grip
|
|
31
|
+
let resizing = null; // live drag state while the grip is held
|
|
32
|
+
let suppressUntil = 0; // ignore the mouseup/click that ends a resize drag
|
|
33
|
+
let saveTimer = null;
|
|
34
|
+
let composeOpen = false;
|
|
35
|
+
/** True when the page's own scripts rewrote the DOM before any user edit. */
|
|
36
|
+
let dynamic = false;
|
|
37
|
+
|
|
38
|
+
// ------------------------------------------------------------------ overlay
|
|
39
|
+
|
|
40
|
+
const host = document.createElement("div");
|
|
41
|
+
host.setAttribute(UI_ATTR, "");
|
|
42
|
+
const shadow = host.attachShadow({ mode: "open" });
|
|
43
|
+
shadow.innerHTML = `
|
|
44
|
+
<style>
|
|
45
|
+
:host { all: initial; }
|
|
46
|
+
.box { position: fixed; pointer-events: none; z-index: 2147483646; border-radius: 3px; display: none; }
|
|
47
|
+
.outline { border: 1px solid #c2beb4; }
|
|
48
|
+
.active { border: 1px solid #1b1a16; }
|
|
49
|
+
.chips {
|
|
50
|
+
position: fixed; z-index: 2147483647; display: none; gap: 4px;
|
|
51
|
+
pointer-events: auto;
|
|
52
|
+
}
|
|
53
|
+
.chip {
|
|
54
|
+
width: 23px; height: 23px; display: flex;
|
|
55
|
+
align-items: center; justify-content: center; padding: 0;
|
|
56
|
+
border: 1px solid #e4e2db; border-radius: 50%; background: #fff; color: #6b6862;
|
|
57
|
+
font: 11px/1 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
58
|
+
cursor: pointer; box-shadow: 0 2px 8px rgba(27,26,22,.14);
|
|
59
|
+
}
|
|
60
|
+
.chip:hover { background: #1b1a16; color: #fff; border-color: #1b1a16; }
|
|
61
|
+
.chip.danger { color: #b23b2e; }
|
|
62
|
+
.chip.danger:hover { background: #b23b2e; color: #fff; border-color: #b23b2e; }
|
|
63
|
+
.chips .chip, .mover { opacity: .6; transition: opacity 100ms ease; }
|
|
64
|
+
.chips .chip:hover, .chips .chip:focus-visible, .mover:hover, .mover:focus-visible { opacity: 1; }
|
|
65
|
+
.grip {
|
|
66
|
+
position: fixed; z-index: 2147483647; width: 13px; height: 13px;
|
|
67
|
+
display: none; border: 1px solid #1b1a16; border-radius: 3px;
|
|
68
|
+
background: #fff; cursor: nwse-resize; pointer-events: auto;
|
|
69
|
+
box-shadow: 0 1px 4px rgba(27,26,22,.2);
|
|
70
|
+
}
|
|
71
|
+
.hint {
|
|
72
|
+
position: fixed; z-index: 2147483647; display: none; padding: 3px 7px;
|
|
73
|
+
border-radius: 5px; background: #1b1a16; color: #fbfaf7; white-space: nowrap;
|
|
74
|
+
font: 11px/1.3 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
75
|
+
pointer-events: none;
|
|
76
|
+
}
|
|
77
|
+
.linkbox {
|
|
78
|
+
position: fixed; z-index: 2147483647; display: none; gap: 4px; align-items: center;
|
|
79
|
+
padding: 5px; border: 1px solid #e4e2db; border-radius: 9px; background: #fff;
|
|
80
|
+
box-shadow: 0 4px 16px rgba(27,26,22,.16); pointer-events: auto;
|
|
81
|
+
}
|
|
82
|
+
.linkbox input {
|
|
83
|
+
width: 224px; padding: 4px 7px; border: none; outline: none; background: none;
|
|
84
|
+
font: 12px/1.4 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: #1b1a16;
|
|
85
|
+
}
|
|
86
|
+
.linkbox input::placeholder { color: #a29f95; }
|
|
87
|
+
.mover {
|
|
88
|
+
position: fixed; z-index: 2147483647; width: 18px; height: 24px;
|
|
89
|
+
display: none; align-items: center; justify-content: center;
|
|
90
|
+
border: 1px solid #e4e2db; border-radius: 6px; background: #fff; color: #a29f95;
|
|
91
|
+
font: 12px/1 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
92
|
+
cursor: grab; pointer-events: auto; user-select: none;
|
|
93
|
+
box-shadow: 0 2px 8px rgba(27,26,22,.14);
|
|
94
|
+
}
|
|
95
|
+
.mover:hover { color: #1b1a16; border-color: #c2beb4; }
|
|
96
|
+
.mover:active { cursor: grabbing; }
|
|
97
|
+
.dropline {
|
|
98
|
+
position: fixed; z-index: 2147483646; height: 0; display: none;
|
|
99
|
+
border-top: 2px solid #1b1a16; border-radius: 1px; pointer-events: none;
|
|
100
|
+
}
|
|
101
|
+
</style>
|
|
102
|
+
<div class="box outline" id="outline"></div>
|
|
103
|
+
<div class="box active" id="activeBox"></div>
|
|
104
|
+
<div class="chips" id="chips">
|
|
105
|
+
<button class="chip danger" id="chipDelete" title="Delete this block" aria-label="Delete this block">✕</button>
|
|
106
|
+
</div>
|
|
107
|
+
<div class="grip" id="grip" title="Drag to resize"></div>
|
|
108
|
+
<div class="hint" id="hint"></div>
|
|
109
|
+
<div class="linkbox" id="linkbox">
|
|
110
|
+
<input id="linkInput" type="text" placeholder="Link to…" spellcheck="false">
|
|
111
|
+
<button class="chip" id="linkApply" title="Apply link (⏎)" aria-label="Apply link">↵</button>
|
|
112
|
+
<button class="chip danger" id="linkRemove" title="Remove link" aria-label="Remove link">✕</button>
|
|
113
|
+
</div>
|
|
114
|
+
<div class="mover" id="mover" title="Drag to move this block">⠿</div>
|
|
115
|
+
<div class="dropline" id="dropline"></div>
|
|
116
|
+
`;
|
|
117
|
+
|
|
118
|
+
const els = {};
|
|
119
|
+
const mountOverlay = () => {
|
|
120
|
+
if (!host.isConnected) document.documentElement.appendChild(host);
|
|
121
|
+
els.outline = shadow.getElementById("outline");
|
|
122
|
+
els.activeBox = shadow.getElementById("activeBox");
|
|
123
|
+
els.chips = shadow.getElementById("chips");
|
|
124
|
+
els.chipDelete = shadow.getElementById("chipDelete");
|
|
125
|
+
els.grip = shadow.getElementById("grip");
|
|
126
|
+
els.hint = shadow.getElementById("hint");
|
|
127
|
+
els.linkbox = shadow.getElementById("linkbox");
|
|
128
|
+
els.linkInput = shadow.getElementById("linkInput");
|
|
129
|
+
els.linkApply = shadow.getElementById("linkApply");
|
|
130
|
+
els.linkRemove = shadow.getElementById("linkRemove");
|
|
131
|
+
els.mover = shadow.getElementById("mover");
|
|
132
|
+
els.dropline = shadow.getElementById("dropline");
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
function showMover(el) {
|
|
136
|
+
if (!el || !el.isConnected) {
|
|
137
|
+
els.mover.style.display = "none";
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
const r = el.getBoundingClientRect();
|
|
141
|
+
if (!r.width && !r.height) {
|
|
142
|
+
els.mover.style.display = "none";
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
els.mover.style.display = "flex";
|
|
146
|
+
// Half-overlap the block's edge: the pointer can travel from text to handle
|
|
147
|
+
// without ever leaving the block, so the hover state never drops.
|
|
148
|
+
els.mover.style.left = `${Math.max(4, r.left - 9)}px`;
|
|
149
|
+
els.mover.style.top = `${Math.max(4, r.top + 1)}px`;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function showDropline(drop) {
|
|
153
|
+
if (!drop || !drop.ref.isConnected) {
|
|
154
|
+
els.dropline.style.display = "none";
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
const r = drop.ref.getBoundingClientRect();
|
|
158
|
+
els.dropline.style.display = "block";
|
|
159
|
+
els.dropline.style.left = `${r.left}px`;
|
|
160
|
+
els.dropline.style.width = `${r.width}px`;
|
|
161
|
+
els.dropline.style.top = `${(drop.before ? r.top : r.bottom) - 1}px`;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function place(box, el, pad = 2) {
|
|
165
|
+
if (!el || !el.isConnected) {
|
|
166
|
+
box.style.display = "none";
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
const r = el.getBoundingClientRect();
|
|
170
|
+
if (!r.width && !r.height) {
|
|
171
|
+
box.style.display = "none";
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
box.style.display = "block";
|
|
175
|
+
box.style.left = `${r.left - pad}px`;
|
|
176
|
+
box.style.top = `${r.top - pad}px`;
|
|
177
|
+
box.style.width = `${r.width + pad * 2}px`;
|
|
178
|
+
box.style.height = `${r.height + pad * 2}px`;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function showChip(el) {
|
|
182
|
+
if (!el || !el.isConnected) {
|
|
183
|
+
els.chips.style.display = "none";
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
const r = el.getBoundingClientRect();
|
|
187
|
+
// A target in a collapsed tab has no box; don't strand the chips in a corner.
|
|
188
|
+
if (!r.width && !r.height) {
|
|
189
|
+
els.chips.style.display = "none";
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
els.chips.style.display = "flex";
|
|
193
|
+
els.chips.style.left = `${Math.max(4, r.right - 11)}px`;
|
|
194
|
+
els.chips.style.top = `${Math.max(4, r.top - 11)}px`;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function showGrip(el) {
|
|
198
|
+
if (!el || !el.isConnected) {
|
|
199
|
+
els.grip.style.display = "none";
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
const r = el.getBoundingClientRect();
|
|
203
|
+
if (!r.width && !r.height) {
|
|
204
|
+
els.grip.style.display = "none";
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
els.grip.style.display = "block";
|
|
208
|
+
els.grip.style.left = `${r.right - 7}px`;
|
|
209
|
+
els.grip.style.top = `${r.bottom - 7}px`;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function showHint(text, x, y) {
|
|
213
|
+
if (!text) {
|
|
214
|
+
els.hint.style.display = "none";
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
els.hint.textContent = text;
|
|
218
|
+
els.hint.style.display = "block";
|
|
219
|
+
els.hint.style.left = `${x + 12}px`;
|
|
220
|
+
els.hint.style.top = `${y + 16}px`;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ------------------------------------------------------------ text plumbing
|
|
224
|
+
|
|
225
|
+
const isOurs = (node) => {
|
|
226
|
+
const el = node && node.nodeType === 1 ? node : node && node.parentElement;
|
|
227
|
+
return !!(el && el.closest && el.closest(`[${UI_ATTR}]`));
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
/** Flatten the body's text nodes into one string plus an offset map. */
|
|
231
|
+
function flatten() {
|
|
232
|
+
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, {
|
|
233
|
+
acceptNode(node) {
|
|
234
|
+
if (!node.nodeValue) return NodeFilter.FILTER_REJECT;
|
|
235
|
+
const parent = node.parentElement;
|
|
236
|
+
if (!parent) return NodeFilter.FILTER_REJECT;
|
|
237
|
+
if (isOurs(parent)) return NodeFilter.FILTER_REJECT;
|
|
238
|
+
if (/^(script|style|noscript|template)$/i.test(parent.tagName)) return NodeFilter.FILTER_REJECT;
|
|
239
|
+
return NodeFilter.FILTER_ACCEPT;
|
|
240
|
+
},
|
|
241
|
+
});
|
|
242
|
+
let text = "";
|
|
243
|
+
const map = [];
|
|
244
|
+
let node = walker.nextNode();
|
|
245
|
+
while (node) {
|
|
246
|
+
const start = text.length;
|
|
247
|
+
text += node.nodeValue;
|
|
248
|
+
map.push({ node, start, end: text.length });
|
|
249
|
+
node = walker.nextNode();
|
|
250
|
+
}
|
|
251
|
+
return { text, map };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function offsetsFromRange(map, range) {
|
|
255
|
+
let start = null;
|
|
256
|
+
let end = null;
|
|
257
|
+
for (const entry of map) {
|
|
258
|
+
if (entry.node === range.startContainer) start = entry.start + range.startOffset;
|
|
259
|
+
if (entry.node === range.endContainer) end = entry.start + range.endOffset;
|
|
260
|
+
}
|
|
261
|
+
if (start === null || end === null || end <= start) return null;
|
|
262
|
+
return { start, end };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Wrap a global offset span in <mark> elements, one per text node touched. */
|
|
266
|
+
function wrapOffsets(map, start, end, id) {
|
|
267
|
+
const marks = [];
|
|
268
|
+
for (const entry of map) {
|
|
269
|
+
if (entry.end <= start || entry.start >= end) continue;
|
|
270
|
+
const from = Math.max(0, start - entry.start);
|
|
271
|
+
const to = Math.min(entry.node.nodeValue.length, end - entry.start);
|
|
272
|
+
if (to <= from) continue;
|
|
273
|
+
const range = document.createRange();
|
|
274
|
+
try {
|
|
275
|
+
range.setStart(entry.node, from);
|
|
276
|
+
range.setEnd(entry.node, to);
|
|
277
|
+
const mark = document.createElement("mark");
|
|
278
|
+
mark.setAttribute(MARK_ATTR, id);
|
|
279
|
+
range.surroundContents(mark);
|
|
280
|
+
marks.push(mark);
|
|
281
|
+
} catch {
|
|
282
|
+
// A node that shifted under us is skipped rather than corrupting the DOM.
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
return marks;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function marksFor(id) {
|
|
289
|
+
return [...document.querySelectorAll(`mark[${MARK_ATTR}="${CSS.escape(id)}"]`)];
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function unwrap(id) {
|
|
293
|
+
for (const mark of marksFor(id)) {
|
|
294
|
+
const parent = mark.parentNode;
|
|
295
|
+
if (!parent) continue;
|
|
296
|
+
while (mark.firstChild) parent.insertBefore(mark.firstChild, mark);
|
|
297
|
+
parent.removeChild(mark);
|
|
298
|
+
parent.normalize();
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ------------------------------------------------------- target resolution
|
|
303
|
+
|
|
304
|
+
function isBlock(el) {
|
|
305
|
+
if (!el || el.nodeType !== 1) return false;
|
|
306
|
+
const display = getComputedStyle(el).display;
|
|
307
|
+
return /^(block|flex|grid|list-item|table|flow-root)$/.test(display);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function hasOwnText(el) {
|
|
311
|
+
return [...el.childNodes].some((n) => n.nodeType === 3 && n.nodeValue.trim());
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** A body-anchored, fully indexed path, so it resolves to one element only. */
|
|
315
|
+
function cssPath(el) {
|
|
316
|
+
const parts = [];
|
|
317
|
+
let node = el;
|
|
318
|
+
while (node && node.nodeType === 1 && node !== document.body && parts.length < 8) {
|
|
319
|
+
if (node.id) {
|
|
320
|
+
parts.unshift(`#${CSS.escape(node.id)}`);
|
|
321
|
+
return parts.join(" > ");
|
|
322
|
+
}
|
|
323
|
+
const tag = node.tagName.toLowerCase();
|
|
324
|
+
const twins = node.parentElement ? [...node.parentElement.children].filter((c) => c.tagName === node.tagName) : [];
|
|
325
|
+
parts.unshift(twins.length > 1 ? `${tag}:nth-of-type(${twins.indexOf(node) + 1})` : tag);
|
|
326
|
+
node = node.parentElement;
|
|
327
|
+
}
|
|
328
|
+
return ["body", ...parts].join(" > ");
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** The heading a block sits under, used to name edits in arbitrary HTML. */
|
|
332
|
+
function precedingHeading(el) {
|
|
333
|
+
let node = el;
|
|
334
|
+
while (node && node !== document.body) {
|
|
335
|
+
let sib = node.previousElementSibling;
|
|
336
|
+
while (sib) {
|
|
337
|
+
if (/^h[1-6]$/i.test(sib.tagName) && sib.textContent.trim()) return sib.textContent.trim();
|
|
338
|
+
const nested = sib.querySelectorAll ? sib.querySelectorAll("h1,h2,h3,h4,h5,h6") : [];
|
|
339
|
+
for (let i = nested.length - 1; i >= 0; i -= 1) {
|
|
340
|
+
if (nested[i].textContent.trim()) return nested[i].textContent.trim();
|
|
341
|
+
}
|
|
342
|
+
sib = sib.previousElementSibling;
|
|
343
|
+
}
|
|
344
|
+
node = node.parentElement;
|
|
345
|
+
}
|
|
346
|
+
return "";
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const clip = (text, limit = 40) => {
|
|
350
|
+
const flat = String(text || "").replace(/\s+/g, " ").trim();
|
|
351
|
+
return flat.length > limit ? `${flat.slice(0, limit - 1).trimEnd()}…` : flat;
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* A block's label is pinned the first time it is computed. Labels derived
|
|
356
|
+
* from the block's own text or position would drift as the user types or
|
|
357
|
+
* deletes siblings, splitting one block's edit into several contradictory
|
|
358
|
+
* rows keyed on each intermediate wording.
|
|
359
|
+
*/
|
|
360
|
+
const pinnedLabels = new WeakMap();
|
|
361
|
+
|
|
362
|
+
/** The block a hover/edit belongs to, plus a human label for the edit list. */
|
|
363
|
+
function targetFor(node) {
|
|
364
|
+
const el = node && node.nodeType === 1 ? node : node && node.parentElement;
|
|
365
|
+
if (!el || isOurs(el)) return null;
|
|
366
|
+
|
|
367
|
+
const authored = el.closest("[data-container],[data-block]");
|
|
368
|
+
if (authored) {
|
|
369
|
+
const label = authored.getAttribute("data-container") || authored.getAttribute("data-block");
|
|
370
|
+
return { el: authored, label: clip(label), authored: true };
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
let block = el;
|
|
374
|
+
while (block && block !== document.body && !isBlock(block)) block = block.parentElement;
|
|
375
|
+
if (!block || block === document.body || !block.textContent.trim()) {
|
|
376
|
+
if (el !== document.body && MEDIA.test(el.tagName)) block = el;
|
|
377
|
+
else return null;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
if (!pinnedLabels.has(block)) {
|
|
381
|
+
const heading = precedingHeading(block);
|
|
382
|
+
const tag = block.tagName.toLowerCase();
|
|
383
|
+
// Siblings of the same tag would otherwise share a label and collapse into
|
|
384
|
+
// one edit row, so number them.
|
|
385
|
+
const twins = block.parentElement ? [...block.parentElement.children].filter((c) => c.tagName === block.tagName) : [];
|
|
386
|
+
const ordinal = twins.length > 1 ? ` ${twins.indexOf(block) + 1}` : "";
|
|
387
|
+
pinnedLabels.set(block, heading ? `${clip(heading, 26)} · ${tag}${ordinal}` : clip(block.textContent, 40) || tag);
|
|
388
|
+
}
|
|
389
|
+
return { el: block, label: pinnedLabels.get(block), authored: false };
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* The nearest rendered block around a node, ignoring authored data-block
|
|
394
|
+
* containers. List conversion and drop targeting need the line the caret is
|
|
395
|
+
* actually on, not the labeled region it reports edits under.
|
|
396
|
+
*/
|
|
397
|
+
function innermostBlock(node) {
|
|
398
|
+
let el = node && node.nodeType === 1 ? node : node && node.parentElement;
|
|
399
|
+
while (el && el !== document.body && !isBlock(el)) el = el.parentElement;
|
|
400
|
+
return el && el !== document.body ? el : null;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* After a list command: hoist the fresh list out of the paragraph Chrome
|
|
405
|
+
* sometimes nests it in (invalid HTML that a reparse would restructure), and
|
|
406
|
+
* make sure the page's CSS reset can't hide it.
|
|
407
|
+
*/
|
|
408
|
+
function polishNewList(sel) {
|
|
409
|
+
const node = sel && sel.anchorNode;
|
|
410
|
+
const el = node && (node.nodeType === 1 ? node : node.parentElement);
|
|
411
|
+
const item = el && el.closest ? el.closest("li") : null;
|
|
412
|
+
const list = item ? item.closest("ul, ol") : null;
|
|
413
|
+
if (!list) return;
|
|
414
|
+
const wrap = list.parentElement;
|
|
415
|
+
if (wrap && /^(p|h[1-6])$/i.test(wrap.tagName) && wrap.childNodes.length === 1) {
|
|
416
|
+
wrap.replaceWith(list);
|
|
417
|
+
}
|
|
418
|
+
const patch = listStyleFixup(list.tagName, getComputedStyle(list));
|
|
419
|
+
if (patch.listStyleType) list.style.listStyleType = patch.listStyleType;
|
|
420
|
+
if (patch.paddingLeft) list.style.paddingLeft = patch.paddingLeft;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/** The caret position a drag at (x, y) would drop into. */
|
|
424
|
+
function caretRangeAt(x, y) {
|
|
425
|
+
if (document.caretRangeFromPoint) return document.caretRangeFromPoint(x, y);
|
|
426
|
+
if (document.caretPositionFromPoint) {
|
|
427
|
+
const pos = document.caretPositionFromPoint(x, y);
|
|
428
|
+
if (!pos) return null;
|
|
429
|
+
const range = document.createRange();
|
|
430
|
+
range.setStart(pos.offsetNode, pos.offset);
|
|
431
|
+
return range;
|
|
432
|
+
}
|
|
433
|
+
return null;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// ------------------------------------------------------------ serialization
|
|
437
|
+
|
|
438
|
+
const serialize = () => serializeDocument(document);
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* A single block's HTML with every review artifact removed. outerHTML, not
|
|
442
|
+
* innerHTML: changes to the block's own attributes (an image's new width, for
|
|
443
|
+
* one) live on the element itself, and a void element has no inner markup.
|
|
444
|
+
*/
|
|
445
|
+
function blockHtml(el) {
|
|
446
|
+
const clone = el.cloneNode(true);
|
|
447
|
+
clone.querySelectorAll(`[${UI_ATTR}]`).forEach((n) => n.remove());
|
|
448
|
+
clone.querySelectorAll(`mark[${MARK_ATTR}]`).forEach((mark) => {
|
|
449
|
+
const parent = mark.parentNode;
|
|
450
|
+
if (!parent) return;
|
|
451
|
+
while (mark.firstChild) parent.insertBefore(mark.firstChild, mark);
|
|
452
|
+
parent.removeChild(mark);
|
|
453
|
+
});
|
|
454
|
+
clone.querySelectorAll("[data-eh-el]").forEach((n) => n.removeAttribute("data-eh-el"));
|
|
455
|
+
clone.removeAttribute("data-eh-el");
|
|
456
|
+
clone.normalize();
|
|
457
|
+
return clone.outerHTML;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Serialization is lossy about formatting, so a save that would not change the
|
|
462
|
+
* document is skipped outright. Opening a file must never rewrite it.
|
|
463
|
+
*/
|
|
464
|
+
let baseline = null;
|
|
465
|
+
/** What the document looked like at boot, before any user edit. */
|
|
466
|
+
let bootSnapshot = null;
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Compare the live document against the file on disk (parsed the same way,
|
|
470
|
+
* without running its scripts). A mismatch means the page renders itself —
|
|
471
|
+
* writing the live DOM back would bake that output into the file, so saves
|
|
472
|
+
* are disabled and edits travel to the agent as feedback only.
|
|
473
|
+
*/
|
|
474
|
+
function checkDynamic(diskHtml) {
|
|
475
|
+
try {
|
|
476
|
+
const parsed = new DOMParser().parseFromString(diskHtml, "text/html");
|
|
477
|
+
if (serializeDocument(parsed) !== bootSnapshot) markDynamic();
|
|
478
|
+
} catch {
|
|
479
|
+
// If the comparison itself fails, keep saving as normal.
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function markDynamic() {
|
|
484
|
+
if (dynamic) return;
|
|
485
|
+
dynamic = true;
|
|
486
|
+
clearTimeout(saveTimer);
|
|
487
|
+
post("eh:dynamic", {});
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/** True once the user has actually edited, as opposed to the page's own scripts. */
|
|
491
|
+
let userEdited = false;
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* The boot comparison only catches scripts that rewrote the DOM before this
|
|
495
|
+
* module ran. Pages that render on load or after a fetch (charts, mermaid,
|
|
496
|
+
* client-rendered apps) mutate later — watch for any serialization drift that
|
|
497
|
+
* happens before the first real user edit and treat it the same way.
|
|
498
|
+
*/
|
|
499
|
+
function watchSelfRendering() {
|
|
500
|
+
const observer = new MutationObserver(() => {
|
|
501
|
+
if (dynamic || userEdited) {
|
|
502
|
+
observer.disconnect();
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
if (serialize() !== bootSnapshot) {
|
|
506
|
+
observer.disconnect();
|
|
507
|
+
markDynamic();
|
|
508
|
+
}
|
|
509
|
+
});
|
|
510
|
+
observer.observe(document.documentElement, { childList: true, subtree: true, characterData: true, attributes: true });
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function emitSave() {
|
|
514
|
+
if (dynamic) {
|
|
515
|
+
post("eh:dynamic", {});
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
const html = serialize();
|
|
519
|
+
if (html === baseline) {
|
|
520
|
+
post("eh:clean", {});
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
baseline = html;
|
|
524
|
+
post("eh:html", { html });
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function scheduleSave() {
|
|
528
|
+
clearTimeout(saveTimer);
|
|
529
|
+
if (!dynamic) post("eh:saving", {});
|
|
530
|
+
saveTimer = setTimeout(emitSave, SAVE_DEBOUNCE_MS);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function flushSave() {
|
|
534
|
+
clearTimeout(saveTimer);
|
|
535
|
+
flushEdits();
|
|
536
|
+
emitSave();
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// ------------------------------------------------------------- edit tracking
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* One POST per keystroke would hammer the server (each save rewrites the whole
|
|
543
|
+
* state file), so edits queue up and flush together. The store dedupes on
|
|
544
|
+
* label+kind and only the latest `after` matters, so coalescing loses nothing.
|
|
545
|
+
*/
|
|
546
|
+
const editQueue = new Map();
|
|
547
|
+
let editTimer = null;
|
|
548
|
+
|
|
549
|
+
function flushEdits() {
|
|
550
|
+
clearTimeout(editTimer);
|
|
551
|
+
editTimer = null;
|
|
552
|
+
for (const payload of editQueue.values()) post("eh:edit", payload);
|
|
553
|
+
editQueue.clear();
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function queueEdit(payload) {
|
|
557
|
+
const key = `${payload.label}\u0000${payload.kind}`;
|
|
558
|
+
const queued = editQueue.get(key);
|
|
559
|
+
if (queued && (queued.staged_assets || payload.staged_assets)) {
|
|
560
|
+
const assets = [...(queued.staged_assets || []), ...(payload.staged_assets || [])];
|
|
561
|
+
payload.staged_assets = [...new Map(assets.map((asset) => [asset.path || asset.id, asset])).values()];
|
|
562
|
+
}
|
|
563
|
+
editQueue.set(key, payload);
|
|
564
|
+
clearTimeout(editTimer);
|
|
565
|
+
editTimer = setTimeout(flushEdits, EDIT_FLUSH_MS);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// ------------------------------------------------------------- interactions
|
|
569
|
+
|
|
570
|
+
function clearPending() {
|
|
571
|
+
pending = null;
|
|
572
|
+
place(els.activeBox, null);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* A settled selection opens the compose card but is otherwise left completely
|
|
577
|
+
* alone: still selected, still editable. Nothing is written into the document
|
|
578
|
+
* until the comment is actually committed, so typing or Backspace behaves
|
|
579
|
+
* exactly as it would in any editable page.
|
|
580
|
+
*/
|
|
581
|
+
function settleSelection() {
|
|
582
|
+
const sel = document.getSelection();
|
|
583
|
+
if (!sel || sel.isCollapsed || !sel.rangeCount) return false;
|
|
584
|
+
const range = sel.getRangeAt(0);
|
|
585
|
+
if (!document.body.contains(range.commonAncestorContainer)) return false;
|
|
586
|
+
if (isOurs(range.commonAncestorContainer)) return false;
|
|
587
|
+
const quote = sel.toString();
|
|
588
|
+
if (!quote.trim()) return false;
|
|
589
|
+
|
|
590
|
+
const { text, map } = flatten();
|
|
591
|
+
const offsets = offsetsFromRange(map, range);
|
|
592
|
+
if (!offsets) return false;
|
|
593
|
+
|
|
594
|
+
const context = buildContext(text, offsets.start, offsets.end);
|
|
595
|
+
pending = { kind: "selection", context };
|
|
596
|
+
|
|
597
|
+
post("eh:compose", {
|
|
598
|
+
kind: "selection",
|
|
599
|
+
quote,
|
|
600
|
+
anchor: { ...context, selector: cssPath(range.commonAncestorContainer.parentElement || document.body) },
|
|
601
|
+
});
|
|
602
|
+
return true;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function openElementCompose(container) {
|
|
606
|
+
if (pending && pending.element === container.el) return;
|
|
607
|
+
pending = { kind: "element", element: container.el };
|
|
608
|
+
place(els.activeBox, container.el);
|
|
609
|
+
post("eh:compose", {
|
|
610
|
+
kind: "element",
|
|
611
|
+
quote: container.label,
|
|
612
|
+
anchor: { selector: cssPath(container.el), label: container.label },
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/** Commit turns the remembered range into real <mark> wrappers. */
|
|
617
|
+
function commitPending(id) {
|
|
618
|
+
if (!pending) return;
|
|
619
|
+
if (pending.kind === "element") {
|
|
620
|
+
if (pending.element && pending.element.isConnected) pending.element.setAttribute("data-eh-el", id);
|
|
621
|
+
} else {
|
|
622
|
+
const { text, map } = flatten();
|
|
623
|
+
const hit = findQuote(text, pending.context);
|
|
624
|
+
if (hit) wrapOffsets(map, hit.start, hit.end, id);
|
|
625
|
+
}
|
|
626
|
+
const sel = document.getSelection();
|
|
627
|
+
if (sel) sel.removeAllRanges();
|
|
628
|
+
clearPending();
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
function reanchor(comments) {
|
|
632
|
+
const resolved = [];
|
|
633
|
+
const orphaned = [];
|
|
634
|
+
for (const comment of comments) {
|
|
635
|
+
if (marksFor(comment.id).length) {
|
|
636
|
+
resolved.push(comment.id);
|
|
637
|
+
continue;
|
|
638
|
+
}
|
|
639
|
+
if (comment.kind === "element") {
|
|
640
|
+
const el = comment.anchor && comment.anchor.selector ? document.querySelector(comment.anchor.selector) : null;
|
|
641
|
+
// Re-stamp the marker, or "Jump to" has nothing to find after a reload.
|
|
642
|
+
if (el) el.setAttribute("data-eh-el", comment.id);
|
|
643
|
+
(el ? resolved : orphaned).push(comment.id);
|
|
644
|
+
continue;
|
|
645
|
+
}
|
|
646
|
+
const { text, map } = flatten();
|
|
647
|
+
const hit = comment.anchor ? findQuote(text, comment.anchor) : null;
|
|
648
|
+
if (!hit) {
|
|
649
|
+
orphaned.push(comment.id);
|
|
650
|
+
continue;
|
|
651
|
+
}
|
|
652
|
+
const marks = wrapOffsets(map, hit.start, hit.end, comment.id);
|
|
653
|
+
(marks.length ? resolved : orphaned).push(comment.id);
|
|
654
|
+
}
|
|
655
|
+
post("eh:anchorStatus", { resolved, orphaned });
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
function activate(id, scroll) {
|
|
659
|
+
const marks = marksFor(id);
|
|
660
|
+
let target = marks[0] || null;
|
|
661
|
+
if (!target) {
|
|
662
|
+
const el = document.querySelector(`[data-eh-el="${CSS.escape(id)}"]`);
|
|
663
|
+
if (el) target = el;
|
|
664
|
+
}
|
|
665
|
+
for (const mark of document.querySelectorAll(`mark[${MARK_ATTR}]`)) mark.classList.remove("eh-active");
|
|
666
|
+
for (const mark of marks) mark.classList.add("eh-active");
|
|
667
|
+
place(els.activeBox, target);
|
|
668
|
+
if (target && scroll) {
|
|
669
|
+
// Reveal a target hidden inside a collapsed tab or accordion.
|
|
670
|
+
let hidden = target.closest("[hidden]") || null;
|
|
671
|
+
if (!hidden) {
|
|
672
|
+
let probe = target.parentElement;
|
|
673
|
+
while (probe && probe !== document.body) {
|
|
674
|
+
if (getComputedStyle(probe).display === "none") {
|
|
675
|
+
hidden = probe;
|
|
676
|
+
break;
|
|
677
|
+
}
|
|
678
|
+
probe = probe.parentElement;
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
if (hidden) {
|
|
682
|
+
const control = hidden.id ? document.querySelector(`[aria-controls="${CSS.escape(hidden.id)}"]`) : null;
|
|
683
|
+
if (control) control.click();
|
|
684
|
+
}
|
|
685
|
+
const rect = target.getBoundingClientRect();
|
|
686
|
+
if (!rect.width && !rect.height) {
|
|
687
|
+
post("eh:notInView", { id });
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
// scrollIntoView walks every scrollable ancestor, so targets inside an
|
|
691
|
+
// app's inner scroll container are reached too, not just window-scrolled ones.
|
|
692
|
+
target.scrollIntoView({ block: "center", behavior: "smooth" });
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// -------------------------------------------------------------------- boot
|
|
697
|
+
|
|
698
|
+
function boot() {
|
|
699
|
+
mountOverlay();
|
|
700
|
+
|
|
701
|
+
const style = document.createElement("style");
|
|
702
|
+
style.setAttribute("data-eh-sdk", "");
|
|
703
|
+
style.textContent = `
|
|
704
|
+
mark[${MARK_ATTR}] { background: rgba(245,196,0,.32); border-radius: 2px; color: inherit; cursor: pointer; }
|
|
705
|
+
mark[${MARK_ATTR}]:hover { background: rgba(243,176,0,.5); }
|
|
706
|
+
mark[${MARK_ATTR}].eh-active { background: rgba(255,180,0,.55); }
|
|
707
|
+
body[contenteditable]:focus { outline: none; }
|
|
708
|
+
::selection { background: rgba(245,196,0,.42); }
|
|
709
|
+
`;
|
|
710
|
+
document.head.appendChild(style);
|
|
711
|
+
|
|
712
|
+
// React/Next.js hydration may remove an attribute that was added after the
|
|
713
|
+
// server rendered the page. Keep edit mode on after hydration settles so a
|
|
714
|
+
// real app stays directly editable, not just its initial HTML response.
|
|
715
|
+
keepBodyEditable(document.body);
|
|
716
|
+
document.body.spellcheck = false;
|
|
717
|
+
baseline = serialize();
|
|
718
|
+
bootSnapshot = baseline;
|
|
719
|
+
watchSelfRendering();
|
|
720
|
+
|
|
721
|
+
document.addEventListener("mouseup", (event) => {
|
|
722
|
+
if (isOurs(event.target) || resizing || Date.now() < suppressUntil) return;
|
|
723
|
+
setTimeout(() => {
|
|
724
|
+
if (settleSelection()) return;
|
|
725
|
+
const target = targetFor(event.target);
|
|
726
|
+
if (target) {
|
|
727
|
+
openElementCompose(target);
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
post("eh:dismiss", {});
|
|
731
|
+
}, 0);
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
document.addEventListener(
|
|
735
|
+
"click",
|
|
736
|
+
(event) => {
|
|
737
|
+
if (isOurs(event.target)) return;
|
|
738
|
+
const modified = event.metaKey || event.ctrlKey;
|
|
739
|
+
const href = navigationHref(event.target);
|
|
740
|
+
|
|
741
|
+
if (modified) {
|
|
742
|
+
if (href) {
|
|
743
|
+
event.preventDefault();
|
|
744
|
+
event.stopPropagation();
|
|
745
|
+
const classification = classifyHref(href);
|
|
746
|
+
if (classification === "external") {
|
|
747
|
+
const external = externalHref(href, document.baseURI);
|
|
748
|
+
if (external) post("eh:external", { href: external });
|
|
749
|
+
}
|
|
750
|
+
else if (classification === "hash") {
|
|
751
|
+
const action = hashClickAction(href, location.hash);
|
|
752
|
+
if (action.kind === "scroll") {
|
|
753
|
+
const el = document.getElementById(action.id) || document.getElementsByName(action.id)[0] || document.body;
|
|
754
|
+
el.scrollIntoView({ behavior: "smooth" });
|
|
755
|
+
} else {
|
|
756
|
+
location.hash = action.hash;
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
else if (classification === "navigate") {
|
|
760
|
+
// This document is about to be torn down: anything still sitting in
|
|
761
|
+
// the debounce windows must ship first or it is lost. Message order
|
|
762
|
+
// is guaranteed, so the edits land before the navigation does.
|
|
763
|
+
flushSave();
|
|
764
|
+
post("eh:navigate", { href });
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
return; // let the artifact's own handlers run
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
// Plain clicks belong to editing: never navigate, never fire artifact JS.
|
|
771
|
+
if (href || (event.target.closest && event.target.closest("button, [role='button'], summary"))) {
|
|
772
|
+
event.preventDefault();
|
|
773
|
+
event.stopPropagation();
|
|
774
|
+
}
|
|
775
|
+
const mark = event.target.closest && event.target.closest(`mark[${MARK_ATTR}]`);
|
|
776
|
+
if (mark) post("eh:activate", { id: mark.getAttribute(MARK_ATTR) });
|
|
777
|
+
},
|
|
778
|
+
true
|
|
779
|
+
);
|
|
780
|
+
|
|
781
|
+
document.addEventListener("submit", (event) => {
|
|
782
|
+
if (!event.metaKey && !event.ctrlKey) event.preventDefault();
|
|
783
|
+
}, true);
|
|
784
|
+
|
|
785
|
+
document.addEventListener("mouseover", (event) => {
|
|
786
|
+
if (isOurs(event.target) || resizing || moving) return;
|
|
787
|
+
const target = targetFor(event.target);
|
|
788
|
+
hoverTarget = target ? target.el : null;
|
|
789
|
+
// The move handle works per element, not per labeled container, so each
|
|
790
|
+
// paragraph inside a card can travel on its own.
|
|
791
|
+
hoverMove = hoverTarget ? innermostBlock(event.target) || hoverTarget : null;
|
|
792
|
+
hoverMedia = event.target.closest ? event.target.closest("img, video") : null;
|
|
793
|
+
place(els.outline, hoverTarget);
|
|
794
|
+
if (controlsAreSuppressed()) hideActionControls();
|
|
795
|
+
else {
|
|
796
|
+
showChip(hoverTarget);
|
|
797
|
+
showMover(hoverMove);
|
|
798
|
+
}
|
|
799
|
+
showGrip(hoverMedia);
|
|
800
|
+
const interactive = event.target.closest && event.target.closest("a[href], [data-href], button, [role='button']");
|
|
801
|
+
const draggable = hoverMedia && hoverMedia.tagName === "IMG";
|
|
802
|
+
showHint(interactive ? "⌘-click to open" : draggable ? "Drag to move" : "", event.clientX, event.clientY);
|
|
803
|
+
});
|
|
804
|
+
|
|
805
|
+
document.addEventListener("mouseleave", () => {
|
|
806
|
+
if (resizing || moving) return;
|
|
807
|
+
hoverTarget = null;
|
|
808
|
+
hoverMove = null;
|
|
809
|
+
hoverMedia = null;
|
|
810
|
+
place(els.outline, null);
|
|
811
|
+
showChip(null);
|
|
812
|
+
showMover(null);
|
|
813
|
+
showGrip(null);
|
|
814
|
+
showHint("");
|
|
815
|
+
});
|
|
816
|
+
|
|
817
|
+
// ------------------------------------------------------------ image resize
|
|
818
|
+
|
|
819
|
+
els.grip.addEventListener("pointerdown", (event) => {
|
|
820
|
+
if (!hoverMedia || !hoverMedia.isConnected) return;
|
|
821
|
+
event.preventDefault();
|
|
822
|
+
event.stopPropagation();
|
|
823
|
+
// From here on, DOM changes are the human resizing, not the page rendering.
|
|
824
|
+
userEdited = true;
|
|
825
|
+
const target = targetFor(hoverMedia);
|
|
826
|
+
const blockEl = target ? target.el : hoverMedia;
|
|
827
|
+
resizing = {
|
|
828
|
+
el: hoverMedia,
|
|
829
|
+
startX: event.clientX,
|
|
830
|
+
startWidth: hoverMedia.getBoundingClientRect().width,
|
|
831
|
+
label: target ? target.label : "Image",
|
|
832
|
+
blockEl,
|
|
833
|
+
beforeText: blockEl.textContent,
|
|
834
|
+
beforeHtml: blockHtml(blockEl),
|
|
835
|
+
};
|
|
836
|
+
if (els.grip.setPointerCapture) els.grip.setPointerCapture(event.pointerId);
|
|
837
|
+
});
|
|
838
|
+
|
|
839
|
+
window.addEventListener("pointermove", (event) => {
|
|
840
|
+
if (!resizing) return;
|
|
841
|
+
event.preventDefault();
|
|
842
|
+
const width = Math.max(24, Math.round(resizing.startWidth + (event.clientX - resizing.startX)));
|
|
843
|
+
resizing.el.style.width = `${width}px`;
|
|
844
|
+
resizing.el.style.height = "auto";
|
|
845
|
+
place(els.outline, hoverTarget);
|
|
846
|
+
showGrip(resizing.el);
|
|
847
|
+
});
|
|
848
|
+
|
|
849
|
+
window.addEventListener("pointerup", () => {
|
|
850
|
+
if (!resizing) return;
|
|
851
|
+
const { blockEl, label, beforeText, beforeHtml } = resizing;
|
|
852
|
+
resizing = null;
|
|
853
|
+
suppressUntil = Date.now() + 250;
|
|
854
|
+
queueEdit({
|
|
855
|
+
label,
|
|
856
|
+
kind: "edited",
|
|
857
|
+
before: beforeText,
|
|
858
|
+
after: blockEl.textContent,
|
|
859
|
+
before_html: beforeHtml,
|
|
860
|
+
after_html: blockHtml(blockEl),
|
|
861
|
+
});
|
|
862
|
+
scheduleSave();
|
|
863
|
+
});
|
|
864
|
+
|
|
865
|
+
els.chipDelete.addEventListener("click", (event) => {
|
|
866
|
+
event.preventDefault();
|
|
867
|
+
event.stopPropagation();
|
|
868
|
+
if (!hoverTarget) return;
|
|
869
|
+
userEdited = true;
|
|
870
|
+
const target = targetFor(hoverTarget);
|
|
871
|
+
const label = target ? target.label : "Element";
|
|
872
|
+
const before = hoverTarget.textContent;
|
|
873
|
+
hoverTarget.remove();
|
|
874
|
+
hoverTarget = null;
|
|
875
|
+
place(els.outline, null);
|
|
876
|
+
showChip(null);
|
|
877
|
+
queueEdit({ label, kind: "deleted", before, after: "" });
|
|
878
|
+
flushSave();
|
|
879
|
+
});
|
|
880
|
+
|
|
881
|
+
// beforeinput still sees the untouched wording, so capture it once per block.
|
|
882
|
+
const originalText = new WeakMap();
|
|
883
|
+
const originalHtml = new WeakMap();
|
|
884
|
+
const captureOriginal = (el) => {
|
|
885
|
+
if (!originalText.has(el)) {
|
|
886
|
+
originalText.set(el, el.textContent);
|
|
887
|
+
originalHtml.set(el, blockHtml(el));
|
|
888
|
+
}
|
|
889
|
+
};
|
|
890
|
+
|
|
891
|
+
/** An edit row for a block changed outside the input-event flow (attribute
|
|
892
|
+
* set, link removal, drag move) — the same shape the input listener emits. */
|
|
893
|
+
const emitBlockEdit = (blockEl, fallbackLabel, extra = {}) => {
|
|
894
|
+
const connected = blockEl.isConnected;
|
|
895
|
+
const target = connected ? targetFor(blockEl) : null;
|
|
896
|
+
queueEdit({
|
|
897
|
+
label: (target && target.label) || fallbackLabel || "Document body",
|
|
898
|
+
kind: "edited",
|
|
899
|
+
before: originalText.get(blockEl),
|
|
900
|
+
after: connected ? blockEl.textContent : "",
|
|
901
|
+
before_html: originalHtml.get(blockEl),
|
|
902
|
+
after_html: connected ? blockHtml(blockEl) : "",
|
|
903
|
+
...extra,
|
|
904
|
+
});
|
|
905
|
+
};
|
|
906
|
+
|
|
907
|
+
let typingUntil = 0;
|
|
908
|
+
let controlRestoreTimer = null;
|
|
909
|
+
|
|
910
|
+
const selectionIsActive = () => {
|
|
911
|
+
const sel = document.getSelection();
|
|
912
|
+
return !!(sel && !sel.isCollapsed && sel.rangeCount && document.body.contains(sel.getRangeAt(0).commonAncestorContainer));
|
|
913
|
+
};
|
|
914
|
+
|
|
915
|
+
const controlsAreSuppressed = () => Date.now() < typingUntil || selectionIsActive();
|
|
916
|
+
|
|
917
|
+
const hideActionControls = () => {
|
|
918
|
+
showChip(null);
|
|
919
|
+
showMover(null);
|
|
920
|
+
};
|
|
921
|
+
|
|
922
|
+
const restoreActionControls = () => {
|
|
923
|
+
if (controlsAreSuppressed()) return;
|
|
924
|
+
showChip(hoverTarget);
|
|
925
|
+
showMover(hoverMove);
|
|
926
|
+
};
|
|
927
|
+
|
|
928
|
+
const suppressActionControlsWhileTyping = () => {
|
|
929
|
+
typingUntil = Date.now() + 800;
|
|
930
|
+
hideActionControls();
|
|
931
|
+
clearTimeout(controlRestoreTimer);
|
|
932
|
+
controlRestoreTimer = setTimeout(restoreActionControls, 810);
|
|
933
|
+
};
|
|
934
|
+
document.addEventListener(
|
|
935
|
+
"beforeinput",
|
|
936
|
+
(event) => {
|
|
937
|
+
if (isOurs(event.target)) return;
|
|
938
|
+
suppressActionControlsWhileTyping();
|
|
939
|
+
// From here on, DOM drift is the human typing, not the page rendering.
|
|
940
|
+
userEdited = true;
|
|
941
|
+
const sel = document.getSelection();
|
|
942
|
+
const target = targetFor(sel && sel.anchorNode ? sel.anchorNode : event.target);
|
|
943
|
+
if (target) captureOriginal(target.el);
|
|
944
|
+
},
|
|
945
|
+
true
|
|
946
|
+
);
|
|
947
|
+
|
|
948
|
+
document.addEventListener("selectionchange", () => {
|
|
949
|
+
if (selectionIsActive()) hideActionControls();
|
|
950
|
+
else restoreActionControls();
|
|
951
|
+
});
|
|
952
|
+
|
|
953
|
+
// ------------------------------------------------------------------ links
|
|
954
|
+
|
|
955
|
+
let linkState = null; // { range, anchor, blockEl, label } while the ⌘K popup is open
|
|
956
|
+
|
|
957
|
+
function closeLinkbox(restoreSelection) {
|
|
958
|
+
if (!linkState) return;
|
|
959
|
+
const { range } = linkState;
|
|
960
|
+
linkState = null;
|
|
961
|
+
els.linkbox.style.display = "none";
|
|
962
|
+
if (restoreSelection && range) {
|
|
963
|
+
const sel = document.getSelection();
|
|
964
|
+
sel.removeAllRanges();
|
|
965
|
+
sel.addRange(range);
|
|
966
|
+
document.body.focus({ preventScroll: true });
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
function openLinkbox() {
|
|
971
|
+
const sel = document.getSelection();
|
|
972
|
+
if (!sel || !sel.rangeCount) return false;
|
|
973
|
+
let range = sel.getRangeAt(0).cloneRange();
|
|
974
|
+
const container = range.commonAncestorContainer;
|
|
975
|
+
if (!document.body.contains(container) || isOurs(container)) return false;
|
|
976
|
+
|
|
977
|
+
const caretEl = container.nodeType === 1 ? container : container.parentElement;
|
|
978
|
+
const anchor = caretEl && caretEl.closest ? caretEl.closest("a") : null;
|
|
979
|
+
if (sel.isCollapsed) {
|
|
980
|
+
// A bare caret can only mean "edit the link I'm inside".
|
|
981
|
+
if (!anchor) return false;
|
|
982
|
+
range = document.createRange();
|
|
983
|
+
range.selectNodeContents(anchor);
|
|
984
|
+
}
|
|
985
|
+
const target = targetFor(anchor || container);
|
|
986
|
+
linkState = {
|
|
987
|
+
range,
|
|
988
|
+
anchor,
|
|
989
|
+
blockEl: target ? target.el : null,
|
|
990
|
+
label: target ? target.label : "Document body",
|
|
991
|
+
};
|
|
992
|
+
if (linkState.blockEl) captureOriginal(linkState.blockEl);
|
|
993
|
+
|
|
994
|
+
// The compose card and the link popup fight over the same selection.
|
|
995
|
+
clearPending();
|
|
996
|
+
post("eh:dismiss", {});
|
|
997
|
+
|
|
998
|
+
const r = range.getBoundingClientRect();
|
|
999
|
+
const below = r.bottom + 44 < window.innerHeight;
|
|
1000
|
+
els.linkbox.style.display = "flex";
|
|
1001
|
+
els.linkbox.style.left = `${Math.max(8, Math.min(r.left, window.innerWidth - 320))}px`;
|
|
1002
|
+
els.linkbox.style.top = `${below ? r.bottom + 8 : Math.max(8, r.top - 44)}px`;
|
|
1003
|
+
els.linkRemove.style.display = anchor ? "" : "none";
|
|
1004
|
+
els.linkInput.value = anchor ? anchor.getAttribute("href") || "" : "";
|
|
1005
|
+
els.linkInput.focus();
|
|
1006
|
+
els.linkInput.select();
|
|
1007
|
+
return true;
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
function applyLink() {
|
|
1011
|
+
if (!linkState) return;
|
|
1012
|
+
const href = normalizeHref(els.linkInput.value);
|
|
1013
|
+
const { range, anchor, blockEl, label } = linkState;
|
|
1014
|
+
if (!href) {
|
|
1015
|
+
// Nothing typed → dismiss. Something unusable typed → stay open so the
|
|
1016
|
+
// input isn't silently thrown away.
|
|
1017
|
+
if (!els.linkInput.value.trim()) closeLinkbox(true);
|
|
1018
|
+
else els.linkInput.select();
|
|
1019
|
+
return;
|
|
1020
|
+
}
|
|
1021
|
+
userEdited = true;
|
|
1022
|
+
linkState = null;
|
|
1023
|
+
els.linkbox.style.display = "none";
|
|
1024
|
+
if (anchor && anchor.isConnected) {
|
|
1025
|
+
// Editing an existing link never rewraps — just retarget it.
|
|
1026
|
+
anchor.setAttribute("href", href);
|
|
1027
|
+
if (blockEl) emitBlockEdit(blockEl, label);
|
|
1028
|
+
} else {
|
|
1029
|
+
const sel = document.getSelection();
|
|
1030
|
+
sel.removeAllRanges();
|
|
1031
|
+
sel.addRange(range);
|
|
1032
|
+
document.body.focus({ preventScroll: true });
|
|
1033
|
+
document.execCommand("createLink", false, href);
|
|
1034
|
+
// On pages that reset anchor styling the new link looks like plain
|
|
1035
|
+
// prose — underline it so the user sees that it took.
|
|
1036
|
+
const node = sel.anchorNode;
|
|
1037
|
+
const el = node && (node.nodeType === 1 ? node : node.parentElement);
|
|
1038
|
+
const created = el && el.closest ? el.closest("a") : null;
|
|
1039
|
+
if (created) {
|
|
1040
|
+
const patch = linkStyleFixup(
|
|
1041
|
+
getComputedStyle(created),
|
|
1042
|
+
created.parentElement ? getComputedStyle(created.parentElement) : null
|
|
1043
|
+
);
|
|
1044
|
+
if (patch.textDecoration) created.style.textDecoration = patch.textDecoration;
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
scheduleSave();
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
function removeLink() {
|
|
1051
|
+
if (!linkState) return;
|
|
1052
|
+
const { anchor, blockEl, label } = linkState;
|
|
1053
|
+
linkState = null;
|
|
1054
|
+
els.linkbox.style.display = "none";
|
|
1055
|
+
if (!anchor || !anchor.isConnected) return;
|
|
1056
|
+
userEdited = true;
|
|
1057
|
+
const parent = anchor.parentNode;
|
|
1058
|
+
while (anchor.firstChild) parent.insertBefore(anchor.firstChild, anchor);
|
|
1059
|
+
parent.removeChild(anchor);
|
|
1060
|
+
parent.normalize();
|
|
1061
|
+
if (blockEl) emitBlockEdit(blockEl, label);
|
|
1062
|
+
scheduleSave();
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
els.linkInput.addEventListener("keydown", (event) => {
|
|
1066
|
+
event.stopPropagation();
|
|
1067
|
+
if (event.key === "Enter") {
|
|
1068
|
+
event.preventDefault();
|
|
1069
|
+
applyLink();
|
|
1070
|
+
} else if (event.key === "Escape") {
|
|
1071
|
+
event.preventDefault();
|
|
1072
|
+
closeLinkbox(true);
|
|
1073
|
+
}
|
|
1074
|
+
});
|
|
1075
|
+
els.linkApply.addEventListener("click", applyLink);
|
|
1076
|
+
els.linkRemove.addEventListener("click", removeLink);
|
|
1077
|
+
|
|
1078
|
+
// A click anywhere in the page means the popup lost the argument.
|
|
1079
|
+
document.addEventListener("pointerdown", (event) => {
|
|
1080
|
+
if (linkState && !isOurs(event.target)) closeLinkbox(false);
|
|
1081
|
+
});
|
|
1082
|
+
|
|
1083
|
+
// ------------------------------------------------------------------ lists
|
|
1084
|
+
|
|
1085
|
+
// execCommand mutations fire `input` (so edit rows and saves flow as usual)
|
|
1086
|
+
// but not `beforeinput`, so originals are captured here by hand.
|
|
1087
|
+
document.addEventListener(
|
|
1088
|
+
"keydown",
|
|
1089
|
+
(event) => {
|
|
1090
|
+
if (isOurs(event.target)) return;
|
|
1091
|
+
const meta = event.metaKey || event.ctrlKey;
|
|
1092
|
+
const sel = document.getSelection();
|
|
1093
|
+
const anchor = sel ? sel.anchorNode : null;
|
|
1094
|
+
|
|
1095
|
+
// ⌘K links the selection, like Docs, Word, and Notion.
|
|
1096
|
+
if (meta && !event.shiftKey && !event.altKey && (event.key === "k" || event.key === "K")) {
|
|
1097
|
+
if (openLinkbox()) {
|
|
1098
|
+
event.preventDefault();
|
|
1099
|
+
event.stopPropagation();
|
|
1100
|
+
}
|
|
1101
|
+
return;
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
if (event.key === "Escape" && linkState) {
|
|
1105
|
+
closeLinkbox(true);
|
|
1106
|
+
return;
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
// ⌘⇧8 bulleted, ⌘⇧7 numbered — the shortcuts people know from Docs.
|
|
1110
|
+
if (meta && event.shiftKey && (event.code === "Digit7" || event.code === "Digit8")) {
|
|
1111
|
+
const target = targetFor(anchor || event.target);
|
|
1112
|
+
if (!target) return;
|
|
1113
|
+
event.preventDefault();
|
|
1114
|
+
event.stopPropagation();
|
|
1115
|
+
userEdited = true;
|
|
1116
|
+
captureOriginal(target.el);
|
|
1117
|
+
document.execCommand(event.code === "Digit7" ? "insertOrderedList" : "insertUnorderedList");
|
|
1118
|
+
polishNewList(document.getSelection());
|
|
1119
|
+
scheduleSave();
|
|
1120
|
+
return;
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
// Tab indents and Shift+Tab outdents inside a list, instead of leaving the page.
|
|
1124
|
+
if (event.key === "Tab" && anchor) {
|
|
1125
|
+
const el = anchor.nodeType === 1 ? anchor : anchor.parentElement;
|
|
1126
|
+
const item = el && el.closest ? el.closest("li") : null;
|
|
1127
|
+
if (!item || isOurs(item)) return;
|
|
1128
|
+
event.preventDefault();
|
|
1129
|
+
event.stopPropagation();
|
|
1130
|
+
userEdited = true;
|
|
1131
|
+
const target = targetFor(item);
|
|
1132
|
+
if (target) captureOriginal(target.el);
|
|
1133
|
+
document.execCommand(event.shiftKey ? "outdent" : "indent");
|
|
1134
|
+
polishNewList(document.getSelection());
|
|
1135
|
+
scheduleSave();
|
|
1136
|
+
return;
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
// "- ", "* ", "1. ", or "1) " at the start of a line becomes a list.
|
|
1140
|
+
// Only the text between the block's start and the caret matters, so this
|
|
1141
|
+
// works inside authored data-block containers and in front of existing
|
|
1142
|
+
// text — not just in blocks that hold nothing but the marker.
|
|
1143
|
+
if (event.key === " " && sel && sel.isCollapsed && anchor) {
|
|
1144
|
+
const host = anchor.nodeType === 1 ? anchor : anchor.parentElement;
|
|
1145
|
+
if (!host || host.closest("li, ul, ol")) return;
|
|
1146
|
+
const block = innermostBlock(anchor);
|
|
1147
|
+
// isBlock matches display:table but not table-cell, so a caret in a
|
|
1148
|
+
// table's first cell resolves the whole table — never convert that.
|
|
1149
|
+
if (block && /^(table|thead|tbody|tfoot|tr)$/i.test(block.tagName)) return;
|
|
1150
|
+
const lead = document.createRange();
|
|
1151
|
+
// Inline content directly under <body> (a bare text node beside an
|
|
1152
|
+
// image, say) has no block; the caret's own node is the line then.
|
|
1153
|
+
if (block) lead.selectNodeContents(block);
|
|
1154
|
+
else lead.setStart(anchor, 0);
|
|
1155
|
+
try {
|
|
1156
|
+
lead.setEnd(sel.anchorNode, sel.anchorOffset);
|
|
1157
|
+
} catch {
|
|
1158
|
+
return;
|
|
1159
|
+
}
|
|
1160
|
+
const command = listCommandFor(lead.toString());
|
|
1161
|
+
if (!command) return;
|
|
1162
|
+
const target = targetFor(anchor);
|
|
1163
|
+
event.preventDefault();
|
|
1164
|
+
event.stopPropagation();
|
|
1165
|
+
userEdited = true;
|
|
1166
|
+
if (target) captureOriginal(target.el);
|
|
1167
|
+
sel.removeAllRanges();
|
|
1168
|
+
sel.addRange(lead);
|
|
1169
|
+
document.execCommand("delete");
|
|
1170
|
+
document.execCommand(command);
|
|
1171
|
+
polishNewList(document.getSelection());
|
|
1172
|
+
scheduleSave();
|
|
1173
|
+
}
|
|
1174
|
+
},
|
|
1175
|
+
true
|
|
1176
|
+
);
|
|
1177
|
+
|
|
1178
|
+
// ------------------------------------------------------------- image drag
|
|
1179
|
+
|
|
1180
|
+
// Chromium moves an image dropped inside contenteditable on its own; this
|
|
1181
|
+
// code only aims the drop (indicator outline), blocks payloads that have
|
|
1182
|
+
// nowhere to go (files from the desktop), and writes the edit rows for the
|
|
1183
|
+
// block the image left and the block it landed in.
|
|
1184
|
+
let dragging = null; // { el, fromBlock, fromLabel, overBlock, dropped } during an image drag
|
|
1185
|
+
|
|
1186
|
+
document.addEventListener("dragstart", (event) => {
|
|
1187
|
+
const img = event.target && event.target.nodeType === 1 && event.target.tagName === "IMG" ? event.target : null;
|
|
1188
|
+
if (!img || isOurs(img)) return;
|
|
1189
|
+
const target = targetFor(img);
|
|
1190
|
+
if (target) captureOriginal(target.el);
|
|
1191
|
+
dragging = {
|
|
1192
|
+
el: img,
|
|
1193
|
+
src: img.src,
|
|
1194
|
+
// The rendered size often comes from the container being left behind
|
|
1195
|
+
// (figure/card CSS); remember it so the move can't balloon the image.
|
|
1196
|
+
width: img.getBoundingClientRect().width,
|
|
1197
|
+
fromBlock: target ? target.el : null,
|
|
1198
|
+
fromLabel: target ? target.label : "Image",
|
|
1199
|
+
overBlock: null,
|
|
1200
|
+
dropped: false,
|
|
1201
|
+
};
|
|
1202
|
+
});
|
|
1203
|
+
|
|
1204
|
+
document.addEventListener("dragover", (event) => {
|
|
1205
|
+
if (!dragging) {
|
|
1206
|
+
// External payloads would navigate the frame or paste file:// markup.
|
|
1207
|
+
if (event.dataTransfer && [...(event.dataTransfer.types || [])].includes("Files")) {
|
|
1208
|
+
event.preventDefault();
|
|
1209
|
+
event.dataTransfer.dropEffect = "none";
|
|
1210
|
+
}
|
|
1211
|
+
return;
|
|
1212
|
+
}
|
|
1213
|
+
const range = caretRangeAt(event.clientX, event.clientY);
|
|
1214
|
+
const block = range ? innermostBlock(range.startContainer) : null;
|
|
1215
|
+
if (block && !isOurs(block)) {
|
|
1216
|
+
captureOriginal(block);
|
|
1217
|
+
dragging.overBlock = block;
|
|
1218
|
+
}
|
|
1219
|
+
place(els.outline, block && !isOurs(block) ? block : null);
|
|
1220
|
+
});
|
|
1221
|
+
|
|
1222
|
+
const finalizeImageDrag = () => {
|
|
1223
|
+
const drag = dragging;
|
|
1224
|
+
if (!drag || !drag.dropped) return;
|
|
1225
|
+
dragging = null;
|
|
1226
|
+
place(els.outline, null);
|
|
1227
|
+
// Chrome re-inserts a copy on drop, so find the landed image by source,
|
|
1228
|
+
// then pin it to the size it had before the move — an image dragged out
|
|
1229
|
+
// of the container that was constraining it would otherwise render at
|
|
1230
|
+
// its natural size.
|
|
1231
|
+
let landed = drag.el.isConnected ? drag.el : null;
|
|
1232
|
+
if (!landed && drag.overBlock && drag.overBlock.isConnected) {
|
|
1233
|
+
landed = [...drag.overBlock.querySelectorAll("img")].find((i) => i.src === drag.src) || null;
|
|
1234
|
+
}
|
|
1235
|
+
if (landed && drag.width && Math.abs(landed.getBoundingClientRect().width - drag.width) > 1) {
|
|
1236
|
+
landed.style.width = `${Math.round(drag.width)}px`;
|
|
1237
|
+
landed.style.height = "auto";
|
|
1238
|
+
landed.style.maxWidth = "100%";
|
|
1239
|
+
}
|
|
1240
|
+
const rows = new Map();
|
|
1241
|
+
if (drag.fromBlock) rows.set(drag.fromBlock, drag.fromLabel);
|
|
1242
|
+
if (drag.overBlock && !rows.has(drag.overBlock)) rows.set(drag.overBlock, null);
|
|
1243
|
+
for (const [block, label] of rows) emitBlockEdit(block, label);
|
|
1244
|
+
scheduleSave();
|
|
1245
|
+
};
|
|
1246
|
+
|
|
1247
|
+
document.addEventListener("drop", (event) => {
|
|
1248
|
+
if (dragging) {
|
|
1249
|
+
// The move itself is the drop's default action, so the edit rows can
|
|
1250
|
+
// only be read after it has run.
|
|
1251
|
+
userEdited = true;
|
|
1252
|
+
dragging.dropped = true;
|
|
1253
|
+
setTimeout(finalizeImageDrag, 0);
|
|
1254
|
+
return;
|
|
1255
|
+
}
|
|
1256
|
+
if (event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files.length) event.preventDefault();
|
|
1257
|
+
});
|
|
1258
|
+
|
|
1259
|
+
document.addEventListener("dragend", () => {
|
|
1260
|
+
// A cancelled drag (Esc, or dropped outside the page) still needs cleanup.
|
|
1261
|
+
if (dragging && !dragging.dropped) {
|
|
1262
|
+
dragging = null;
|
|
1263
|
+
place(els.outline, null);
|
|
1264
|
+
}
|
|
1265
|
+
});
|
|
1266
|
+
|
|
1267
|
+
// ------------------------------------------------------------- block moves
|
|
1268
|
+
|
|
1269
|
+
// The handle on a block's left edge moves the whole block. Pointer-based,
|
|
1270
|
+
// not HTML5 drag: the element is relocated on drop, never cloned, so
|
|
1271
|
+
// identity (labels, captured originals, comment anchors) survives the move.
|
|
1272
|
+
let moving = null; // { el, label, drop: { ref, before } | null } while the handle is held
|
|
1273
|
+
|
|
1274
|
+
const dropPointFor = (x, y) => {
|
|
1275
|
+
const under = document.elementFromPoint(x, y);
|
|
1276
|
+
if (!under || isOurs(under)) return null;
|
|
1277
|
+
const block = innermostBlock(under);
|
|
1278
|
+
if (!block || block === moving.el || moving.el.contains(block)) return null;
|
|
1279
|
+
const r = block.getBoundingClientRect();
|
|
1280
|
+
return { ref: block, before: y < r.top + r.height / 2 };
|
|
1281
|
+
};
|
|
1282
|
+
|
|
1283
|
+
els.mover.addEventListener("pointerdown", (event) => {
|
|
1284
|
+
const el = hoverMove && hoverMove.isConnected ? hoverMove : hoverTarget;
|
|
1285
|
+
if (!el || !el.isConnected) return;
|
|
1286
|
+
event.preventDefault();
|
|
1287
|
+
event.stopPropagation();
|
|
1288
|
+
const target = targetFor(el);
|
|
1289
|
+
moving = { el, label: target ? target.label : "Block", drop: null };
|
|
1290
|
+
captureOriginal(moving.el);
|
|
1291
|
+
moving.el.style.opacity = "0.4";
|
|
1292
|
+
place(els.outline, null);
|
|
1293
|
+
showChip(null);
|
|
1294
|
+
try {
|
|
1295
|
+
els.mover.setPointerCapture(event.pointerId);
|
|
1296
|
+
} catch {
|
|
1297
|
+
// Window-level listeners track the move even without capture.
|
|
1298
|
+
}
|
|
1299
|
+
});
|
|
1300
|
+
|
|
1301
|
+
window.addEventListener("pointermove", (event) => {
|
|
1302
|
+
if (!moving) return;
|
|
1303
|
+
event.preventDefault();
|
|
1304
|
+
moving.drop = dropPointFor(event.clientX, event.clientY);
|
|
1305
|
+
showDropline(moving.drop);
|
|
1306
|
+
});
|
|
1307
|
+
|
|
1308
|
+
window.addEventListener("pointerup", () => {
|
|
1309
|
+
if (!moving) return;
|
|
1310
|
+
const { el, label, drop } = moving;
|
|
1311
|
+
moving = null;
|
|
1312
|
+
showDropline(null);
|
|
1313
|
+
showMover(null);
|
|
1314
|
+
el.style.opacity = "";
|
|
1315
|
+
suppressUntil = Date.now() + 250;
|
|
1316
|
+
if (!drop || !drop.ref.isConnected || !el.isConnected) return;
|
|
1317
|
+
// Dropping right back where it came from is a no-op, not an edit.
|
|
1318
|
+
const next = drop.before ? drop.ref : drop.ref.nextElementSibling;
|
|
1319
|
+
if (next === el || (drop.before ? drop.ref.previousElementSibling : drop.ref) === el) return;
|
|
1320
|
+
userEdited = true;
|
|
1321
|
+
drop.ref.parentNode.insertBefore(el, next);
|
|
1322
|
+
const prev = el.previousElementSibling;
|
|
1323
|
+
const following = el.nextElementSibling;
|
|
1324
|
+
queueEdit({
|
|
1325
|
+
label,
|
|
1326
|
+
kind: "moved",
|
|
1327
|
+
before: originalText.get(el),
|
|
1328
|
+
after: el.textContent,
|
|
1329
|
+
before_html: originalHtml.get(el),
|
|
1330
|
+
after_html: blockHtml(el),
|
|
1331
|
+
moved_after: prev ? clip(prev.textContent, 90) : "",
|
|
1332
|
+
moved_before: following ? clip(following.textContent, 90) : "",
|
|
1333
|
+
});
|
|
1334
|
+
flushSave();
|
|
1335
|
+
});
|
|
1336
|
+
|
|
1337
|
+
// ------------------------------------------------------------- image paste
|
|
1338
|
+
|
|
1339
|
+
// Pasted images cross to the chrome page as bytes; the server writes them
|
|
1340
|
+
// into assets/ next to the reviewed file and the confirmed relative path
|
|
1341
|
+
// comes back to be inserted where the caret was.
|
|
1342
|
+
let pasteSeq = 0;
|
|
1343
|
+
const pendingPastes = new Map(); // id → collapsed caret range to insert at
|
|
1344
|
+
|
|
1345
|
+
document.addEventListener(
|
|
1346
|
+
"paste",
|
|
1347
|
+
(event) => {
|
|
1348
|
+
if (isOurs(event.target)) return;
|
|
1349
|
+
const items = event.clipboardData ? [...event.clipboardData.items] : [];
|
|
1350
|
+
const images = items.filter((item) => item.kind === "file" && /^image\//.test(item.type));
|
|
1351
|
+
if (!images.length) return;
|
|
1352
|
+
event.preventDefault();
|
|
1353
|
+
event.stopPropagation();
|
|
1354
|
+
const sel = document.getSelection();
|
|
1355
|
+
const caret = sel && sel.rangeCount ? sel.getRangeAt(0).cloneRange() : null;
|
|
1356
|
+
for (const item of images) {
|
|
1357
|
+
const file = item.getAsFile();
|
|
1358
|
+
if (!file) continue;
|
|
1359
|
+
pasteSeq += 1;
|
|
1360
|
+
const id = `paste_${pasteSeq}`;
|
|
1361
|
+
pendingPastes.set(id, caret);
|
|
1362
|
+
file.arrayBuffer().then((bytes) => post("eh:asset", { id, assetType: file.type, bytes }));
|
|
1363
|
+
}
|
|
1364
|
+
},
|
|
1365
|
+
true
|
|
1366
|
+
);
|
|
1367
|
+
|
|
1368
|
+
const insertPastedImage = (id, src, stagedId) => {
|
|
1369
|
+
const caret = pendingPastes.get(id);
|
|
1370
|
+
pendingPastes.delete(id);
|
|
1371
|
+
userEdited = true;
|
|
1372
|
+
const img = document.createElement("img");
|
|
1373
|
+
img.setAttribute("src", src);
|
|
1374
|
+
img.style.maxWidth = "100%";
|
|
1375
|
+
const anchored = caret && caret.startContainer && document.body.contains(caret.startContainer) && !isOurs(caret.startContainer);
|
|
1376
|
+
if (anchored) {
|
|
1377
|
+
const target = targetFor(caret.startContainer);
|
|
1378
|
+
if (target) captureOriginal(target.el);
|
|
1379
|
+
caret.collapse(false);
|
|
1380
|
+
caret.insertNode(img);
|
|
1381
|
+
} else {
|
|
1382
|
+
document.body.appendChild(img);
|
|
1383
|
+
}
|
|
1384
|
+
const landed = targetFor(img);
|
|
1385
|
+
emitBlockEdit(landed ? landed.el : img, landed ? landed.label : "Pasted image", {
|
|
1386
|
+
...(stagedId ? { staged_assets: [{ id: stagedId, preview_src: src }] } : {}),
|
|
1387
|
+
});
|
|
1388
|
+
flushSave();
|
|
1389
|
+
};
|
|
1390
|
+
|
|
1391
|
+
document.addEventListener("input", (event) => {
|
|
1392
|
+
if (isOurs(event.target)) return;
|
|
1393
|
+
// A drop fires deleteByDrag/insertFromDrop input events whose selection
|
|
1394
|
+
// points anywhere; finalizeImageDrag writes the real rows instead.
|
|
1395
|
+
if (dragging) return;
|
|
1396
|
+
// Typing over a selection is an edit, not a comment: retire the card.
|
|
1397
|
+
if (pending) {
|
|
1398
|
+
clearPending();
|
|
1399
|
+
post("eh:dismiss", {});
|
|
1400
|
+
}
|
|
1401
|
+
const sel = document.getSelection();
|
|
1402
|
+
const node = sel && sel.anchorNode ? sel.anchorNode : event.target;
|
|
1403
|
+
const target = targetFor(node);
|
|
1404
|
+
// Text alone loses formatting-only edits (bold, italic, underline change
|
|
1405
|
+
// markup, not textContent), so the block's cleaned HTML travels too.
|
|
1406
|
+
queueEdit({
|
|
1407
|
+
label: target ? target.label : "Document body",
|
|
1408
|
+
kind: "edited",
|
|
1409
|
+
before: target ? originalText.get(target.el) : undefined,
|
|
1410
|
+
after: target ? target.el.textContent : undefined,
|
|
1411
|
+
before_html: target ? originalHtml.get(target.el) : undefined,
|
|
1412
|
+
after_html: target ? blockHtml(target.el) : undefined,
|
|
1413
|
+
});
|
|
1414
|
+
scheduleSave();
|
|
1415
|
+
});
|
|
1416
|
+
|
|
1417
|
+
const reposition = () => {
|
|
1418
|
+
place(els.outline, hoverTarget);
|
|
1419
|
+
showChip(hoverTarget);
|
|
1420
|
+
showMover(moving ? null : hoverMove);
|
|
1421
|
+
showGrip(resizing ? resizing.el : hoverMedia);
|
|
1422
|
+
// The popup is viewport-fixed; scrolled away from its text it just lies.
|
|
1423
|
+
if (linkState) closeLinkbox(false);
|
|
1424
|
+
};
|
|
1425
|
+
// getBoundingClientRect on every scroll event forces layout mid-scroll;
|
|
1426
|
+
// one reposition per frame is plenty.
|
|
1427
|
+
let repositionQueued = false;
|
|
1428
|
+
const scheduleReposition = () => {
|
|
1429
|
+
if (repositionQueued) return;
|
|
1430
|
+
repositionQueued = true;
|
|
1431
|
+
requestAnimationFrame(() => {
|
|
1432
|
+
repositionQueued = false;
|
|
1433
|
+
reposition();
|
|
1434
|
+
});
|
|
1435
|
+
};
|
|
1436
|
+
window.addEventListener("scroll", scheduleReposition, true);
|
|
1437
|
+
window.addEventListener("resize", scheduleReposition);
|
|
1438
|
+
|
|
1439
|
+
let scrollQueued = false;
|
|
1440
|
+
window.addEventListener(
|
|
1441
|
+
"scroll",
|
|
1442
|
+
() => {
|
|
1443
|
+
if (scrollQueued) return;
|
|
1444
|
+
scrollQueued = true;
|
|
1445
|
+
requestAnimationFrame(() => {
|
|
1446
|
+
scrollQueued = false;
|
|
1447
|
+
post("eh:scroll", { x: window.scrollX, y: window.scrollY });
|
|
1448
|
+
});
|
|
1449
|
+
},
|
|
1450
|
+
{ passive: true }
|
|
1451
|
+
);
|
|
1452
|
+
|
|
1453
|
+
window.addEventListener("message", (event) => {
|
|
1454
|
+
// Only the chrome page may drive the SDK — not popups the artifact opened,
|
|
1455
|
+
// and not the artifact's own scripts.
|
|
1456
|
+
if (event.source !== window.parent || event.origin !== CHROME_ORIGIN) return;
|
|
1457
|
+
const msg = event.data || {};
|
|
1458
|
+
if (!matchesFrameMessage(msg)) return;
|
|
1459
|
+
switch (msg.type) {
|
|
1460
|
+
case "eh:anchors":
|
|
1461
|
+
reanchor(msg.comments || []);
|
|
1462
|
+
break;
|
|
1463
|
+
case "eh:commit":
|
|
1464
|
+
commitPending(msg.id);
|
|
1465
|
+
composeOpen = false;
|
|
1466
|
+
break;
|
|
1467
|
+
case "eh:cancel":
|
|
1468
|
+
clearPending();
|
|
1469
|
+
composeOpen = false;
|
|
1470
|
+
break;
|
|
1471
|
+
case "eh:composeOpen":
|
|
1472
|
+
composeOpen = true;
|
|
1473
|
+
break;
|
|
1474
|
+
case "eh:remove":
|
|
1475
|
+
unwrap(msg.id);
|
|
1476
|
+
document.querySelectorAll(`[data-eh-el="${CSS.escape(msg.id)}"]`).forEach((el) => el.removeAttribute("data-eh-el"));
|
|
1477
|
+
place(els.activeBox, null);
|
|
1478
|
+
break;
|
|
1479
|
+
case "eh:activate":
|
|
1480
|
+
activate(msg.id, !!msg.scroll);
|
|
1481
|
+
break;
|
|
1482
|
+
case "eh:flush":
|
|
1483
|
+
flushSave();
|
|
1484
|
+
// Posted after the flushed eh:edit/eh:html messages, so when the
|
|
1485
|
+
// chrome sees it, everything pending has already been handed over.
|
|
1486
|
+
post("eh:flushed", {});
|
|
1487
|
+
break;
|
|
1488
|
+
case "eh:abortSave":
|
|
1489
|
+
// A revert is in flight: anything queued would re-write the edits.
|
|
1490
|
+
clearTimeout(saveTimer);
|
|
1491
|
+
clearTimeout(editTimer);
|
|
1492
|
+
editQueue.clear();
|
|
1493
|
+
break;
|
|
1494
|
+
case "eh:raw":
|
|
1495
|
+
checkDynamic(String(msg.html || ""));
|
|
1496
|
+
break;
|
|
1497
|
+
case "eh:feedbackOnly":
|
|
1498
|
+
// The chrome already knows saves must not happen (e.g. a Markdown
|
|
1499
|
+
// source rendered for review); no comparison needed.
|
|
1500
|
+
dynamic = true;
|
|
1501
|
+
break;
|
|
1502
|
+
case "eh:restoreScroll":
|
|
1503
|
+
window.scrollTo(msg.x || 0, msg.y || 0);
|
|
1504
|
+
break;
|
|
1505
|
+
case "eh:assetSaved":
|
|
1506
|
+
insertPastedImage(msg.id, String(msg.src || ""), String(msg.stagedId || ""));
|
|
1507
|
+
break;
|
|
1508
|
+
case "eh:assetFailed":
|
|
1509
|
+
pendingPastes.delete(msg.id);
|
|
1510
|
+
break;
|
|
1511
|
+
default:
|
|
1512
|
+
break;
|
|
1513
|
+
}
|
|
1514
|
+
});
|
|
1515
|
+
|
|
1516
|
+
post("eh:ready", { scrollHeight: document.body.scrollHeight });
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", boot);
|
|
1520
|
+
else boot();
|