@panphora/clayjs 1.3.0 → 1.4.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.
@@ -0,0 +1,625 @@
1
+ /**
2
+ * ai-edit — comment-to-edit AI editing over clay.wire.
3
+ *
4
+ * Direct editing stays primary: text units carry editmode:contenteditable, so a
5
+ * click places the caret and a figure keeps its own interactivity. This adds the
6
+ * comment box on demand: a floating chip near the hovered unit, Cmd+K for the unit
7
+ * holding the caret (the selection rides along as a quote), a click on bare section
8
+ * padding, or the fixed bottom-right bubble for the whole document. Units resolve to
9
+ * the nearest h1-h6/p/figure inside a [data-edit-id] section, falling back to the
10
+ * section itself.
11
+ *
12
+ * The request leaves as a named helper request on the wire
13
+ * (`clay.wire.send(payload, { helper: "ai-edit" })`) and comes back as the edited
14
+ * element's own HTML. A named request defaults to `document: "none"`, so the host
15
+ * writes nothing: this page morphs the result in and Keep saves it through
16
+ * `clay.save()`, exactly as before.
17
+ *
18
+ * Dormant unless the host lists a ready `ai-edit` helper. On HTML Clay, or with the
19
+ * desktop toggle off, `clay.wire.helpers()` never lists one and nothing is built at
20
+ * all: the page is then just a page.
21
+ *
22
+ * The bus version previewed the reply as it streamed. The wire carries status lines
23
+ * only — replaceable progress, capped and droppable — so the page shows throttled
24
+ * status lines ("Writing, 1.8 KB") and then the result once, with Keep/Revert as
25
+ * before. `@page` reads the saved file from disk rather than a copy in the payload:
26
+ * the plugin saves first and then sends `{ page: true }`, because a whole page never
27
+ * fits the envelope.
28
+ *
29
+ * Undo integration: observers pause at request start. On Keep the element is rewound
30
+ * to the snapshot while still paused, observers resume, then one final morph lands
31
+ * the whole AI edit as a single undoable step. On Revert the rewind happens under
32
+ * pause, so history never sees the edit at all.
33
+ *
34
+ * All injected chrome is runtime-only (`no-save no-watch no-snapshot`): never saved,
35
+ * invisible to the mutation system (its status text changes constantly), absent from
36
+ * every snapshot and from what peers receive.
37
+ */
38
+ import { HyperMorph } from "../vendor/hyper-morph.vendor.js";
39
+ import { mergeTagRecognizers } from "../sync/merge-tags.js";
40
+ import Mutation from "../lib/mutation.js";
41
+ import { isEditMode } from "../core/is-edit-mode.js";
42
+ import { STRIP_FROM_SAVE } from "../lib/region-policy.js";
43
+ import { enableContentEditable } from "../core/admin-contenteditable.js";
44
+ import onDomReady from "../lib/dom-ready.js";
45
+ import wire from "./wire.js";
46
+
47
+ const HELPER = "ai-edit";
48
+ const UNIT_SELECTOR = "h1,h2,h3,h4,h5,h6,p,figure";
49
+ const RUNTIME_ONLY = "no-save no-watch no-snapshot";
50
+ // The request ceiling, kept below the host's 1 MiB envelope so the refusal happens
51
+ // here, where the message can say what to do about it.
52
+ const MAX_PAYLOAD_BYTES = 900 * 1024;
53
+ const TOO_LARGE = "This section is too large for AI editing; select a smaller part.";
54
+
55
+ let requestCounter = 0;
56
+ let session = null; // one edit at a time
57
+ let panel, ring, textarea, quoteEl, statusEl, warningsEl, chip, docBubble;
58
+ let buttons = {};
59
+ let anchorEl = null; // element the panel is currently anchored to
60
+ let chipTarget = null; // unit the hover chip currently points at
61
+ let chipHideTimer = null;
62
+
63
+ // ---------------------------------------------------------------- transport
64
+
65
+ async function available() {
66
+ const list = await wire.helpers();
67
+ return list.some(h => h.name === HELPER && h.state === "ready");
68
+ }
69
+
70
+ // ---------------------------------------------------------------- observers (undo/autosave)
71
+
72
+ function pauseObservers() {
73
+ Mutation.pause();
74
+ }
75
+
76
+ function resumeObservers() {
77
+ Mutation.resume();
78
+ }
79
+
80
+ // ---------------------------------------------------------------- units
81
+
82
+ // The editable unit for an interaction: nearest heading/paragraph/figure inside a
83
+ // [data-edit-id] section, else the section itself.
84
+ function unitFrom(target) {
85
+ if (!(target instanceof Element)) return null;
86
+ const section = target.closest('[data-edit-id]');
87
+ if (!section) return null;
88
+ const unit = target.closest(UNIT_SELECTOR);
89
+ return unit && section.contains(unit) ? unit : section;
90
+ }
91
+
92
+ function editLabel(el) {
93
+ if (el === document.body) return 'document';
94
+ const own = el.getAttribute('data-edit-id');
95
+ if (own) return own;
96
+ const section = el.closest('[data-edit-id]');
97
+ return (section ? section.getAttribute('data-edit-id') + ' \u203a ' : '') + el.tagName.toLowerCase();
98
+ }
99
+
100
+ function quoteFromSelection(scope) {
101
+ const selection = window.getSelection();
102
+ if (!selection || selection.isCollapsed) return undefined;
103
+ const text = selection.toString().trim();
104
+ if (!text) return undefined;
105
+ let node = selection.getRangeAt(0).commonAncestorContainer;
106
+ if (node.nodeType !== 1) node = node.parentElement;
107
+ if (scope && scope !== document.body && !scope.contains(node)) return undefined;
108
+ return text.length > 400 ? text.slice(0, 400) + '\u2026' : text;
109
+ }
110
+
111
+ function strippedBodyHTML() {
112
+ const clone = document.body.cloneNode(true);
113
+ clone.querySelectorAll(STRIP_FROM_SAVE).forEach(node => node.remove());
114
+ return clone.outerHTML;
115
+ }
116
+
117
+ // ---------------------------------------------------------------- morphing
118
+
119
+ function morphTo(el, content, scripts) {
120
+ return HyperMorph.morph(el, content, { morphStyle: 'outerHTML', restoreFocus: true, scripts });
121
+ }
122
+
123
+ // Session-aware morph. Document mode morphs the whole body but vetoes removal of the
124
+ // chrome this plugin injected (the model never saw it, so a plain morph would delete
125
+ // the panel mid-flight). Either way, re-run the contenteditable pass afterwards: a
126
+ // reply may drop the runtime attribute.
127
+ // Forward morphs three-way merge mergeable script tags ([merge] + rules tags)
128
+ // against the snapshot the model saw, so data the user changed mid-flight survives
129
+ // the reply. Rewinds must restore the snapshot exactly, so they disable merging.
130
+ function sessionMorph(s, content, { rewind = false } = {}) {
131
+ const scripts = rewind
132
+ ? { merge: false }
133
+ : { mergeBase: s.snapshot, mergeTags: mergeTagRecognizers };
134
+ if (s.docMode) {
135
+ if (typeof content === 'string') {
136
+ // Parse to an element ourselves: hyper-morph parses a body string into a
137
+ // full document and would insert DOMParser's synthesized empty <head>
138
+ // alongside the morphed body. An element gets sibling-free treatment.
139
+ content = new DOMParser().parseFromString(content, 'text/html').body;
140
+ }
141
+ HyperMorph.morph(document.body, content, {
142
+ morphStyle: 'outerHTML',
143
+ restoreFocus: true,
144
+ scripts,
145
+ callbacks: {
146
+ beforeNodeRemoved: node => !(node.nodeType === 1 && node.matches(STRIP_FROM_SAVE))
147
+ }
148
+ });
149
+ } else {
150
+ morphTo(s.el, content, scripts);
151
+ }
152
+ enableContentEditable();
153
+ }
154
+
155
+ // Extract a morphable single-root candidate from a (possibly partial, possibly
156
+ // fenced) reply. Returns an inert element or null. Template parsing auto-closes
157
+ // half-open tags and never executes scripts — but silently strips <body> tags, so a
158
+ // body candidate parses via DOMParser (equally inert).
159
+ function candidateFrom(html, tag) {
160
+ const start = html.search(new RegExp('<' + tag + '(\\s|>)', 'i'));
161
+ if (start === -1) return null;
162
+ const cleaned = html.slice(start).replace(/\s*`{1,3}\s*$/, '');
163
+ if (tag.toLowerCase() === 'body') {
164
+ return new DOMParser().parseFromString(cleaned, 'text/html').body;
165
+ }
166
+ const template = document.createElement('template');
167
+ template.innerHTML = cleaned;
168
+ const candidate = template.content.firstElementChild;
169
+ if (!candidate || candidate.tagName.toLowerCase() !== tag.toLowerCase()) return null;
170
+ return candidate;
171
+ }
172
+
173
+ // ---------------------------------------------------------------- session lifecycle
174
+
175
+ function newSession(el, comment, quote) {
176
+ const docMode = el === document.body;
177
+ // Context refs are @tokens containing a dot or slash (@notes.md, @src/x.js). Bare
178
+ // @words are not refs: a leading one is an engine token (@fable, @codex — routed
179
+ // helper-side), and mid-text ones are prose.
180
+ const contextRefs = [...comment.matchAll(/@([\w.-]*[/.][\w./-]*)/g)]
181
+ .map(m => m[1])
182
+ .filter(ref => ref !== 'page');
183
+ const elementHTML = docMode ? strippedBodyHTML() : el.outerHTML;
184
+ const payload = {
185
+ id: 'req-' + Date.now().toString(36) + '-' + (++requestCounter),
186
+ editId: editLabel(el),
187
+ tag: el.tagName.toLowerCase(),
188
+ elementHTML,
189
+ comment,
190
+ contextRefs
191
+ };
192
+ if (quote) payload.quote = quote;
193
+ // Document mode reads the live page itself, and every other @page request reads
194
+ // the file from disk: the plugin saves first, then asks for it by flag.
195
+ if (!docMode && /@page\b/.test(comment)) payload.page = true;
196
+ return {
197
+ id: payload.id,
198
+ el,
199
+ docMode,
200
+ tag: payload.tag,
201
+ reassertId: docMode ? null : el.getAttribute('data-edit-id'),
202
+ snapshot: elementHTML,
203
+ payload,
204
+ state: 'requesting',
205
+ paused: false,
206
+ handle: null,
207
+ finalCandidate: null
208
+ };
209
+ }
210
+
211
+ async function sendRequest(el, comment, quote) {
212
+ session = newSession(el, comment, quote);
213
+ // Refused here, before anything is sent or paused: the envelope would refuse it
214
+ // anyway, and a refusal the page cannot explain is worse than one it can.
215
+ if (new Blob([JSON.stringify(session.payload)]).size > MAX_PAYLOAD_BYTES) {
216
+ session = null;
217
+ setStatus(TOO_LARGE, 'warn');
218
+ showButtons('send');
219
+ return;
220
+ }
221
+ session.paused = true;
222
+ pauseObservers();
223
+ setStatus('sending\u2026');
224
+ showButtons('stop');
225
+ // @page reads the file the host has on disk, so the page has to be on disk first.
226
+ if (/@page\b/.test(comment) && !session.docMode) await window.clay.save();
227
+ if (!session) return; // cancelled while saving
228
+ // The host owns the deadline; this side only renders. A named request defaults to
229
+ // `document: "none"`, so nothing here asks anyone to write the file.
230
+ // Only the live session's statuses land: a line from a request the user already
231
+ // cancelled or replaced must not repaint the panel.
232
+ const handle = wire.send(session.payload, {
233
+ helper: HELPER,
234
+ onStatus: ({ text }) => { if (session?.handle === handle) setStatus(text); }
235
+ });
236
+ session.handle = handle;
237
+ const outcome = await handle.done;
238
+ if (!session || session.handle !== handle) return;
239
+ if (outcome.state === 'done') onDone(outcome.result || {});
240
+ else if (outcome.state === 'cancelled') { revertSession(); setStatus('cancelled'); showButtons('send'); }
241
+ else onError(outcome.error || 'the helper reported an error');
242
+ }
243
+
244
+ function onDone(payload) {
245
+ if (!session || session.state !== 'requesting') return;
246
+ session.state = 'deciding';
247
+
248
+ const warnings = [];
249
+ let candidate = candidateFrom(payload.html || '', session.tag);
250
+ if (!candidate) {
251
+ onError(`reply is not a single <${session.tag}> element`);
252
+ return;
253
+ }
254
+ if (!session.docMode) { // template parsing strips <body>, making this check meaningless there
255
+ const template = document.createElement('template');
256
+ template.innerHTML = payload.html || '';
257
+ if (template.content.children.length > 1) {
258
+ warnings.push('reply had extra root elements \u2014 kept the first');
259
+ }
260
+ }
261
+ if (session.reassertId) candidate.setAttribute('data-edit-id', session.reassertId);
262
+ if (!session.docMode) { // the real body legitimately contains scripts; skip in doc mode
263
+ if (candidate.querySelector('script')) {
264
+ warnings.push('reply contains <script> \u2014 review before keeping');
265
+ }
266
+ for (const el of [candidate, ...candidate.querySelectorAll('*')]) {
267
+ if ([...el.attributes].some(a => a.name.toLowerCase().startsWith('on'))) {
268
+ warnings.push('reply contains inline event handlers \u2014 review before keeping');
269
+ break;
270
+ }
271
+ }
272
+ }
273
+
274
+ session.finalCandidate = candidate;
275
+ sessionMorph(session, candidate); // authoritative morph from the full final HTML
276
+ positionChrome();
277
+ setStatus(payload.model ? `done (${payload.model})` : 'done');
278
+ showWarnings(warnings);
279
+ showButtons('keep', 'revert');
280
+ textarea.value = '';
281
+ }
282
+
283
+ function onError(message) {
284
+ if (!session) return;
285
+ revertSession();
286
+ setStatus(message, 'warn');
287
+ showButtons('send');
288
+ }
289
+
290
+ // Rewind to the pre-edit snapshot and release observers. The paused rewind means
291
+ // undo history never sees the abandoned edit.
292
+ function revertSession() {
293
+ const s = session;
294
+ session = null;
295
+ if (!s) return;
296
+ if (s.state === 'requesting' || s.state === 'deciding') {
297
+ sessionMorph(s, s.snapshot, { rewind: true });
298
+ }
299
+ if (s.paused) resumeObservers();
300
+ positionChrome();
301
+ }
302
+
303
+ async function keepSession() {
304
+ const s = session;
305
+ session = null;
306
+ sessionMorph(s, s.snapshot, { rewind: true }); // rewind while observers are still paused
307
+ if (s.paused) resumeObservers(); // boundary drain discards the rewind
308
+ sessionMorph(s, s.finalCandidate); // recorded: the whole edit = one undo step
309
+ positionChrome();
310
+ showButtons('send');
311
+ setStatus('saving\u2026');
312
+ const result = await window.clay.save();
313
+ setStatus((result && result.msg) || 'saved', result && result.msgType === 'error' ? 'warn' : '');
314
+ }
315
+
316
+ function cancelStream() {
317
+ // Stop is live from the moment the request starts, which includes the window where
318
+ // an @page save is still in flight and there is no handle yet.
319
+ session?.handle?.cancel();
320
+ revertSession();
321
+ setStatus('cancelled');
322
+ showButtons('send');
323
+ }
324
+
325
+ // ---------------------------------------------------------------- chrome
326
+
327
+ function markRuntimeOnly(el) {
328
+ el.setAttribute('clay', RUNTIME_ONLY);
329
+ return el;
330
+ }
331
+
332
+ function buildChrome() {
333
+ const style = document.createElement('style');
334
+ style.setAttribute('clay', RUNTIME_ONLY);
335
+ style.textContent = `
336
+ #hyper-edit-panel {
337
+ position: fixed; z-index: 99999; width: min(30rem, calc(100vw - 2rem));
338
+ background: #16161d; color: #e8e8f0; border: 1px solid #34343f;
339
+ border-radius: 10px; box-shadow: 0 12px 40px rgba(0,0,0,.45);
340
+ font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace;
341
+ padding: 10px;
342
+ }
343
+ #hyper-edit-panel[hidden] { display: none; }
344
+ #hyper-edit-panel .hep-quote {
345
+ color: #9a9ab0; border-left: 2px solid #4a4a5a; padding-left: 8px;
346
+ margin-bottom: 8px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
347
+ }
348
+ #hyper-edit-panel textarea {
349
+ width: 100%; box-sizing: border-box; min-height: 3.2em; resize: vertical;
350
+ background: #0e0e13; color: inherit; border: 1px solid #34343f;
351
+ border-radius: 6px; padding: 8px; font: inherit; outline: none;
352
+ }
353
+ #hyper-edit-panel textarea:focus { border-color: #6a6a8a; }
354
+ #hyper-edit-panel .hep-row {
355
+ display: flex; align-items: center; gap: 8px; margin-top: 8px;
356
+ }
357
+ #hyper-edit-panel .hep-status { flex: 1; color: #9a9ab0; min-width: 0; }
358
+ #hyper-edit-panel .hep-status.warn { color: #e8b04a; }
359
+ #hyper-edit-panel button {
360
+ background: #26262f; color: inherit; border: 1px solid #44444f;
361
+ border-radius: 6px; padding: 4px 12px; font: inherit; cursor: pointer;
362
+ }
363
+ #hyper-edit-panel button:hover { background: #32323d; }
364
+ #hyper-edit-panel .hep-keep { background: #1e3a2a; border-color: #2e5a40; }
365
+ #hyper-edit-panel .hep-warnings {
366
+ margin-top: 8px; color: #e8b04a; white-space: pre-line;
367
+ }
368
+ #hyper-edit-ring {
369
+ position: fixed; z-index: 99998; pointer-events: none;
370
+ border: 1px solid #7a7aff; border-radius: 4px; opacity: .8;
371
+ }
372
+ #hyper-edit-ring[hidden] { display: none; }
373
+ #hyper-edit-chip {
374
+ position: fixed; z-index: 99999; width: 26px; height: 26px; padding: 0;
375
+ display: flex; align-items: center; justify-content: center;
376
+ background: #16161d; color: #e8e8f0; border: 1px solid #44444f;
377
+ border-radius: 50%; cursor: pointer; font-size: 13px; line-height: 1;
378
+ box-shadow: 0 4px 14px rgba(0,0,0,.4);
379
+ }
380
+ #hyper-edit-chip[hidden] { display: none; }
381
+ #hyper-edit-chip:hover { background: #32323d; border-color: #6a6a8a; }
382
+ #hyper-edit-doc-bubble {
383
+ position: fixed; right: 16px; bottom: 16px; z-index: 99999;
384
+ width: 44px; height: 44px; padding: 0; border-radius: 50%;
385
+ display: flex; align-items: center; justify-content: center;
386
+ background: #16161d; color: #e8e8f0; border: 1px solid #44444f;
387
+ font-size: 18px; cursor: pointer; box-shadow: 0 6px 20px rgba(0,0,0,.45);
388
+ }
389
+ #hyper-edit-doc-bubble:hover { background: #32323d; border-color: #6a6a8a; }
390
+ [editmode\\:contenteditable][contenteditable]:focus {
391
+ outline: 1px solid #4a4a6a; outline-offset: 4px; border-radius: 2px;
392
+ }
393
+ `;
394
+ document.head.appendChild(style);
395
+
396
+ ring = markRuntimeOnly(document.createElement('div'));
397
+ ring.id = 'hyper-edit-ring';
398
+ ring.hidden = true;
399
+
400
+ panel = markRuntimeOnly(document.createElement('div'));
401
+ panel.id = 'hyper-edit-panel';
402
+ panel.hidden = true;
403
+ panel.innerHTML = `
404
+ <div class="hep-quote" hidden></div>
405
+ <textarea rows="2" placeholder="Describe the change\u2026 (@file.ext adds context, @fable / @claude picks the agent)"></textarea>
406
+ <div class="hep-row">
407
+ <span class="hep-status"></span>
408
+ <button class="hep-send" type="button">Send</button>
409
+ <button class="hep-stop" type="button" hidden>Stop</button>
410
+ <button class="hep-revert" type="button" hidden>Revert</button>
411
+ <button class="hep-keep" type="button" hidden>Keep</button>
412
+ </div>
413
+ <div class="hep-warnings" hidden></div>
414
+ `;
415
+
416
+ chip = markRuntimeOnly(document.createElement('button'));
417
+ chip.id = 'hyper-edit-chip';
418
+ chip.type = 'button';
419
+ chip.title = 'Comment on this (\u2318K)';
420
+ chip.textContent = '\ud83d\udcac';
421
+ chip.hidden = true;
422
+
423
+ docBubble = markRuntimeOnly(document.createElement('button'));
424
+ docBubble.id = 'hyper-edit-doc-bubble';
425
+ docBubble.type = 'button';
426
+ docBubble.title = 'Comment on the whole page';
427
+ docBubble.textContent = '\ud83d\udcac';
428
+
429
+ document.body.append(ring, panel, chip, docBubble);
430
+
431
+ quoteEl = panel.querySelector('.hep-quote');
432
+ textarea = panel.querySelector('textarea');
433
+ statusEl = panel.querySelector('.hep-status');
434
+ warningsEl = panel.querySelector('.hep-warnings');
435
+ for (const name of ['send', 'stop', 'keep', 'revert']) {
436
+ buttons[name] = panel.querySelector('.hep-' + name);
437
+ }
438
+
439
+ buttons.send.addEventListener('click', submit);
440
+ buttons.stop.addEventListener('click', cancelStream);
441
+ buttons.keep.addEventListener('click', keepSession);
442
+ buttons.revert.addEventListener('click', () => { revertSession(); setStatus('reverted'); showButtons('send'); });
443
+ textarea.addEventListener('keydown', (event) => {
444
+ if (event.key === 'Enter' && !event.shiftKey) {
445
+ event.preventDefault();
446
+ if (!buttons.send.hidden) submit();
447
+ }
448
+ });
449
+
450
+ chip.addEventListener('click', () => {
451
+ const target = chipTarget;
452
+ hideChip();
453
+ if (target && !session) openPanel(target, quoteFromSelection(target));
454
+ });
455
+ docBubble.addEventListener('click', () => {
456
+ if (session) return;
457
+ openPanel(document.body, quoteFromSelection(document.body));
458
+ });
459
+
460
+ window.addEventListener('scroll', () => { positionChrome(); hideChip(); }, { passive: true });
461
+ window.addEventListener('resize', positionChrome, { passive: true });
462
+ }
463
+
464
+ // ---------------------------------------------------------------- hover chip
465
+
466
+ function showChipFor(unit) {
467
+ clearTimeout(chipHideTimer);
468
+ chipHideTimer = null;
469
+ chipTarget = unit;
470
+ const rect = unit.getBoundingClientRect();
471
+ chip.hidden = false;
472
+ chip.style.left = Math.min(rect.right + 8, window.innerWidth - 34) + 'px';
473
+ chip.style.top = Math.max(8, rect.top) + 'px';
474
+ }
475
+
476
+ function hideChip() {
477
+ clearTimeout(chipHideTimer);
478
+ chipHideTimer = null;
479
+ chip.hidden = true;
480
+ chipTarget = null;
481
+ }
482
+
483
+ function scheduleChipHide() {
484
+ if (chip.hidden || chipHideTimer) return;
485
+ chipHideTimer = setTimeout(hideChip, 400);
486
+ }
487
+
488
+ function setStatus(text, tone) {
489
+ statusEl.textContent = text || '';
490
+ statusEl.classList.toggle('warn', tone === 'warn');
491
+ }
492
+
493
+ function showButtons(...names) {
494
+ for (const [name, button] of Object.entries(buttons)) {
495
+ button.hidden = !names.includes(name);
496
+ }
497
+ }
498
+
499
+ function showWarnings(warnings) {
500
+ warningsEl.hidden = !warnings.length;
501
+ warningsEl.textContent = warnings.map(w => '\u26a0 ' + w).join('\n');
502
+ }
503
+
504
+ function positionChrome() {
505
+ if (!anchorEl || panel.hidden) return;
506
+ if (!anchorEl.isConnected) { closePanel(); return; }
507
+ if (anchorEl === document.body) {
508
+ // document mode: no ring, panel pinned above the bottom-right bubble
509
+ ring.hidden = true;
510
+ panel.style.left = Math.max(16, window.innerWidth - (panel.offsetWidth || 480) - 16) + 'px';
511
+ panel.style.top = Math.max(16, window.innerHeight - (panel.offsetHeight || 120) - 76) + 'px';
512
+ return;
513
+ }
514
+ const rect = anchorEl.getBoundingClientRect();
515
+ ring.hidden = false;
516
+ ring.style.left = rect.left - 4 + 'px';
517
+ ring.style.top = rect.top - 4 + 'px';
518
+ ring.style.width = rect.width + 6 + 'px';
519
+ ring.style.height = rect.height + 6 + 'px';
520
+ const panelWidth = panel.offsetWidth || 480;
521
+ const left = Math.max(16, Math.min(rect.left, window.innerWidth - panelWidth - 16));
522
+ let top = rect.bottom + 10;
523
+ const panelHeight = panel.offsetHeight || 120;
524
+ if (top + panelHeight > window.innerHeight - 16) {
525
+ top = Math.max(16, rect.top - panelHeight - 10);
526
+ }
527
+ panel.style.left = left + 'px';
528
+ panel.style.top = top + 'px';
529
+ }
530
+
531
+ function openPanel(el, quote) {
532
+ anchorEl = el;
533
+ hideChip();
534
+ panel.hidden = false;
535
+ quoteEl.hidden = !quote;
536
+ quoteEl.textContent = quote ? `\u201c${quote}\u201d` : '';
537
+ panel.dataset.quote = quote || '';
538
+ setStatus('');
539
+ showWarnings([]);
540
+ showButtons('send');
541
+ positionChrome();
542
+ textarea.focus();
543
+ }
544
+
545
+ function closePanel() {
546
+ if (session) revertSession();
547
+ anchorEl = null;
548
+ panel.hidden = true;
549
+ ring.hidden = true;
550
+ textarea.value = '';
551
+ }
552
+
553
+ function submit() {
554
+ const comment = textarea.value.trim();
555
+ if (!comment || !anchorEl || session) return;
556
+ sendRequest(anchorEl, comment, panel.dataset.quote || undefined);
557
+ }
558
+
559
+ // ---------------------------------------------------------------- interactions
560
+
561
+ function wireInteractions() {
562
+ // Clicks: text units are contenteditable (caret) and figures keep their own
563
+ // interactivity, so neither is intercepted. Only a click on bare section padding
564
+ // opens the comment box; a click away from an open, empty panel closes it.
565
+ document.addEventListener('click', (event) => {
566
+ if (panel.contains(event.target) || chip.contains(event.target) || docBubble.contains(event.target)) return;
567
+ if (session) return; // one edit at a time
568
+ const section = event.target.closest?.('[data-edit-id]');
569
+ const unit = section ? unitFrom(event.target) : null;
570
+ if (section && unit === section) {
571
+ if (section !== anchorEl || panel.hidden) openPanel(section, quoteFromSelection(section));
572
+ } else if (!panel.hidden && !textarea.value.trim() && !(anchorEl && anchorEl.contains(event.target))) {
573
+ closePanel();
574
+ }
575
+ });
576
+
577
+ // Hover chip: follows the unit under the cursor while no panel is open.
578
+ let lastMove = 0;
579
+ document.addEventListener('mousemove', (event) => {
580
+ const now = Date.now();
581
+ if (now - lastMove < 80) return;
582
+ lastMove = now;
583
+ if (session || !panel.hidden) { scheduleChipHide(); return; }
584
+ if (chip.contains(event.target)) { clearTimeout(chipHideTimer); chipHideTimer = null; return; }
585
+ const unit = unitFrom(event.target);
586
+ if (unit) showChipFor(unit);
587
+ else scheduleChipHide();
588
+ });
589
+
590
+ document.addEventListener('keydown', (event) => {
591
+ if ((event.metaKey || event.ctrlKey) && !event.shiftKey && !event.altKey && event.key.toLowerCase() === 'k') {
592
+ if (session) return;
593
+ event.preventDefault();
594
+ const selection = window.getSelection();
595
+ let node = selection && selection.anchorNode;
596
+ if (node && node.nodeType !== 1) node = node.parentElement;
597
+ const target = (node && unitFrom(node)) || chipTarget;
598
+ if (target) openPanel(target, quoteFromSelection(target));
599
+ return;
600
+ }
601
+ if (event.key !== 'Escape' || panel.hidden) return;
602
+ if (session?.state === 'requesting') {
603
+ cancelStream();
604
+ } else if (session?.state === 'deciding') {
605
+ revertSession();
606
+ setStatus('reverted');
607
+ showButtons('send');
608
+ } else {
609
+ closePanel();
610
+ }
611
+ });
612
+ }
613
+
614
+ // ---------------------------------------------------------------- boot
615
+
616
+ async function init() {
617
+ if (!isEditMode) return; // an owner/editor feature, nothing else
618
+ if (!(await available())) return; // no ai-edit helper (e.g. HTML Clay, or the toggle off): stay dormant
619
+ buildChrome();
620
+ wireInteractions();
621
+ }
622
+
623
+ onDomReady(() => { init().catch(() => {}); });
624
+
625
+ export const aiEdit = { init };