@panphora/clayjs 1.7.0 → 1.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/clay.standalone.js +6277 -5396
- package/package.json +2 -1
- package/src/core/etag.js +67 -3
- package/src/core/host-attrs.js +14 -0
- package/src/core/save.js +13 -5
- package/src/lib/root-attrs.js +14 -0
- package/src/plugins/ai-edit.js +305 -89
- package/src/sync/conflict-footprints.js +272 -35
- package/src/sync/conflict-revert.js +18 -8
- package/src/sync/conflicts.js +2 -0
- package/src/sync/live-sync.js +328 -37
- package/src/sync/stream.js +17 -4
- package/src/vendor/hyper-morph.vendor.js +4 -4
package/src/plugins/ai-edit.js
CHANGED
|
@@ -26,6 +26,12 @@
|
|
|
26
26
|
* the plugin saves first and then sends `{ page: true }`, because a whole page never
|
|
27
27
|
* fits the envelope.
|
|
28
28
|
*
|
|
29
|
+
* The comment box is compose only: a click anywhere outside it closes it, keeping the
|
|
30
|
+
* text as a draft for that target, and Send moves the whole live state to one compact
|
|
31
|
+
* bar at the bottom of the viewport. The bar carries the host's progress and Stop, the
|
|
32
|
+
* reply's warnings and Keep/Revert, and then the save, so no part of a running edit
|
|
33
|
+
* depends on a popover the person may want out of the way.
|
|
34
|
+
*
|
|
29
35
|
* Undo integration: observers pause at request start. On Keep the element is rewound
|
|
30
36
|
* to the snapshot while still paused, observers resume, then one final morph lands
|
|
31
37
|
* the whole AI edit as a single undoable step. On Revert the rewind happens under
|
|
@@ -40,13 +46,13 @@ import { mergeTagRecognizers } from "../sync/merge-tags.js";
|
|
|
40
46
|
import Mutation from "../lib/mutation.js";
|
|
41
47
|
import { isEditMode } from "../core/is-edit-mode.js";
|
|
42
48
|
import { holdAllSaves, releaseAllSaves } from "../core/save.js";
|
|
43
|
-
import { STRIP_FROM_SAVE } from "../lib/region-policy.js";
|
|
49
|
+
import { STRIP_FROM_SAVE, SNAPSHOT_REMOVE_SELECTOR } from "../lib/region-policy.js";
|
|
44
50
|
import { enableContentEditable } from "../core/admin-contenteditable.js";
|
|
45
51
|
import onDomReady from "../lib/dom-ready.js";
|
|
46
52
|
import wire from "./wire.js";
|
|
47
53
|
import { set } from "../lib/hostile-css.js";
|
|
48
|
-
import { bevelBox, bevelButton, bevelSurface, bevelText, bevelInput, pageScheme, setShown, RUNTIME_ONLY } from "../ui/bevel-controls.js";
|
|
49
|
-
import { TOKENS, FONT_SANS, FONT_MONO, SHADOW } from "../ui/bevel.js";
|
|
54
|
+
import { bevelBox, bevelButton, bevelSurface, bevelText, bevelInput, bevelIconButton, pageScheme, setShown, RUNTIME_ONLY } from "../ui/bevel-controls.js";
|
|
55
|
+
import { TOKENS, GLYPHS, FONT_SANS, FONT_MONO, SHADOW } from "../ui/bevel.js";
|
|
50
56
|
|
|
51
57
|
const HELPER = "ai-edit";
|
|
52
58
|
const UNIT_SELECTOR = "h1,h2,h3,h4,h5,h6,p,figure";
|
|
@@ -60,11 +66,22 @@ const NOT_TEXT = "script,style,template,textarea,input,select,button,iframe,svg,
|
|
|
60
66
|
// here, where the message can say what to do about it.
|
|
61
67
|
const MAX_PAYLOAD_BYTES = 900 * 1024;
|
|
62
68
|
const TOO_LARGE = "This section is too large for AI editing; select a smaller part.";
|
|
69
|
+
const SAVE_FAILED = "The save did not finish; the edit is still on the page.";
|
|
63
70
|
|
|
64
71
|
let requestCounter = 0;
|
|
65
72
|
let session = null; // one edit at a time
|
|
66
|
-
let panel, ring, textarea, statusEl,
|
|
73
|
+
let panel, ring, textarea, statusEl, pointer, chip, docBubble;
|
|
74
|
+
let bar, barStatus, barStop, barKeep, barRevert, barClose;
|
|
67
75
|
let buttons = {};
|
|
76
|
+
// A visible bar is chrome in its own right, so its mode is tracked explicitly rather
|
|
77
|
+
// than inferred from the session: Error and Saving outlive the session.
|
|
78
|
+
let barMode = null; // "working", "ready", "saving" or "error"
|
|
79
|
+
// One request's whole life on the bar, Working through Ready or Error to Saving. A save
|
|
80
|
+
// continuation or a close timer from an older lifecycle must never touch a newer bar.
|
|
81
|
+
let barLifecycle = null;
|
|
82
|
+
let barCloseTimer = null;
|
|
83
|
+
let panelPointerDown = false; // the last pointerdown began in the panel: its click is not a page click
|
|
84
|
+
const drafts = new WeakMap(); // compose text a target keeps while the panel is shut
|
|
68
85
|
let anchorEl = null; // element the panel is currently anchored to
|
|
69
86
|
let chipTarget = null; // unit the hover chip currently points at
|
|
70
87
|
let chipHideTimer = null;
|
|
@@ -276,31 +293,36 @@ async function sendRequest(el, comment, quote) {
|
|
|
276
293
|
// Refused here, before anything is sent or paused: the envelope would refuse it
|
|
277
294
|
// anyway, and a refusal the page cannot explain is worse than one it can.
|
|
278
295
|
if (new Blob([JSON.stringify(session.payload)]).size > MAX_PAYLOAD_BYTES) {
|
|
296
|
+
// Not a Send: the box stays open with its text, draft included.
|
|
279
297
|
session = null;
|
|
280
298
|
setStatus(TOO_LARGE, 'warn');
|
|
281
|
-
showButtons('send');
|
|
282
299
|
return;
|
|
283
300
|
}
|
|
301
|
+
// The sent comment is this target's draft until the reply is ready: Error, Stop, a
|
|
302
|
+
// refused reply, a morph failure and a host cancellation all leave it in place.
|
|
303
|
+
drafts.set(el, comment);
|
|
304
|
+
closePanelForSend();
|
|
284
305
|
session.paused = true;
|
|
285
306
|
pauseObservers();
|
|
286
|
-
|
|
287
|
-
|
|
307
|
+
barLifecycle = {};
|
|
308
|
+
showBar('working');
|
|
309
|
+
setBarStatus('Sending\u2026');
|
|
288
310
|
// @page reads the file the host has on disk, so the page has to be on disk first.
|
|
289
311
|
if (session.payload.page) await window.clay.save();
|
|
290
312
|
if (!session) return; // cancelled while saving
|
|
291
313
|
// The host owns the deadline; this side only renders. A named request defaults to
|
|
292
314
|
// `document: "none"`, so nothing here asks anyone to write the file.
|
|
293
315
|
// Only the live session's statuses land: a line from a request the user already
|
|
294
|
-
// cancelled or replaced must not repaint the
|
|
316
|
+
// cancelled or replaced must not repaint the bar.
|
|
295
317
|
const handle = wire.send(session.payload, {
|
|
296
318
|
helper: HELPER,
|
|
297
|
-
onStatus: ({ text }) => { if (session?.handle === handle)
|
|
319
|
+
onStatus: ({ text }) => { if (session?.handle === handle) setBarStatus(text); }
|
|
298
320
|
});
|
|
299
321
|
session.handle = handle;
|
|
300
322
|
const outcome = await handle.done;
|
|
301
323
|
if (!session || session.handle !== handle) return;
|
|
302
324
|
if (outcome.state === 'done') onDone(outcome.result || {});
|
|
303
|
-
else if (outcome.state === 'cancelled')
|
|
325
|
+
else if (outcome.state === 'cancelled') onError('HTML Clay stopped this edit.');
|
|
304
326
|
else onError(outcome.error || 'the helper reported an error');
|
|
305
327
|
}
|
|
306
328
|
|
|
@@ -395,18 +417,24 @@ function onDone(payload) {
|
|
|
395
417
|
return;
|
|
396
418
|
}
|
|
397
419
|
positionChrome();
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
420
|
+
drafts.delete(session.el); // delivered: the comment is no longer this target's draft
|
|
421
|
+
showBar('ready');
|
|
422
|
+
setBarStatus(readyText(warnings, payload.model), warnings.length ? 'warn' : '');
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// The reply's own caveats ride with the invitation to keep it, and the model that
|
|
426
|
+
// wrote it is named when the host said which one it was.
|
|
427
|
+
function readyText(warnings, model) {
|
|
428
|
+
const parts = ['Edit ready.', ...warnings.map(w => '\u26a0 ' + w)];
|
|
429
|
+
if (model) parts.push(`(${model})`);
|
|
430
|
+
return parts.join(' ');
|
|
403
431
|
}
|
|
404
432
|
|
|
405
433
|
function onError(message) {
|
|
406
434
|
if (!session) return;
|
|
407
435
|
revertSession();
|
|
408
|
-
|
|
409
|
-
|
|
436
|
+
showBar('error');
|
|
437
|
+
setBarStatus(message, 'warn');
|
|
410
438
|
}
|
|
411
439
|
|
|
412
440
|
// Rewind to the pre-edit snapshot and release observers. The paused rewind means
|
|
@@ -428,28 +456,40 @@ function revertSession() {
|
|
|
428
456
|
|
|
429
457
|
async function keepSession() {
|
|
430
458
|
const s = session;
|
|
459
|
+
if (!s) return;
|
|
431
460
|
session = null;
|
|
461
|
+
// What is on screen is what Keep keeps: the preview may have been edited by hand.
|
|
462
|
+
const liveResult = s.docMode ? strippedBodyHTML() : s.el.outerHTML;
|
|
432
463
|
try {
|
|
433
464
|
sessionMorph(s, s.snapshot, { rewind: true }); // rewind while observers are still paused
|
|
434
465
|
} finally {
|
|
435
466
|
if (s.paused) resumeObservers(); // boundary drain discards the rewind
|
|
436
467
|
if (s.held) releaseAllSaves({ replay: false });
|
|
437
468
|
}
|
|
438
|
-
sessionMorph(s,
|
|
469
|
+
sessionMorph(s, liveResult); // recorded: the whole edit = one undo step
|
|
439
470
|
positionChrome();
|
|
440
|
-
|
|
441
|
-
|
|
471
|
+
showBar('saving');
|
|
472
|
+
setBarStatus('Saving\u2026');
|
|
473
|
+
const lifecycle = barLifecycle;
|
|
442
474
|
const result = await window.clay.save();
|
|
443
|
-
|
|
475
|
+
// An older lifecycle's save answers an older bar, and has nothing to say to this one.
|
|
476
|
+
if (lifecycle !== barLifecycle) return;
|
|
477
|
+
if (result && result.ok === false) {
|
|
478
|
+
showBar('error'); // a failed or conflicting save stays on screen until it is dismissed
|
|
479
|
+
setBarStatus(result.msg || SAVE_FAILED, 'warn');
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
setBarStatus((result && result.msg) || 'Saved', result && result.msgType === 'error' ? 'warn' : '');
|
|
483
|
+
scheduleBarClose();
|
|
444
484
|
}
|
|
445
485
|
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
486
|
+
// Stop is live from the moment the request starts, which includes the window where an
|
|
487
|
+
// @page save is still in flight and there is no handle yet. Nothing is left on screen:
|
|
488
|
+
// the edit is rewound and the bar goes with it.
|
|
489
|
+
function stopSession() {
|
|
449
490
|
session?.handle?.cancel();
|
|
450
491
|
revertSession();
|
|
451
|
-
|
|
452
|
-
showButtons('send');
|
|
492
|
+
closeBar();
|
|
453
493
|
}
|
|
454
494
|
|
|
455
495
|
// ---------------------------------------------------------------- chrome
|
|
@@ -517,22 +557,40 @@ function buildChrome() {
|
|
|
517
557
|
statusEl.setAttribute('role', 'status');
|
|
518
558
|
statusEl.setAttribute('aria-live', 'polite');
|
|
519
559
|
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
for (const name of ['send', 'stop', 'revert', 'keep']) {
|
|
523
|
-
buttons[name] = part(bevelButton(labels[name], { variant: variants[name], extra: ['min-height:40px', 'flex:none'] }), name);
|
|
524
|
-
setShown(buttons[name], name === 'send', 'inline-flex');
|
|
525
|
-
}
|
|
560
|
+
buttons.send = part(bevelButton('Send', { variant: 'primary', extra: ['min-height:40px', 'flex:none'] }), 'send');
|
|
561
|
+
setShown(buttons.send, true, 'inline-flex');
|
|
526
562
|
|
|
527
563
|
const row = bevelBox('div', ['display:flex', 'align-items:flex-start', 'gap:8px']);
|
|
528
|
-
row.append(textarea, buttons.send
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
564
|
+
row.append(textarea, buttons.send);
|
|
565
|
+
|
|
566
|
+
panel.append(pointer, row, statusEl);
|
|
567
|
+
|
|
568
|
+
// One line, bottom centre, never wider than the viewport it is centred in: the live
|
|
569
|
+
// state of an edit that no longer has a popover. The text takes what the controls
|
|
570
|
+
// leave and ellipsises there, so a long host line cannot push a button off a 375px
|
|
571
|
+
// screen; its full wording stays in the DOM and in the accessible name.
|
|
572
|
+
bar = marked(bevelSurface('div', [
|
|
573
|
+
'position:fixed', 'left:50%', 'bottom:16px', 'transform:translateX(-50%)',
|
|
574
|
+
'z-index:99999', 'width:min(35rem, calc(100vw - 32px))',
|
|
575
|
+
'padding:8px 10px', 'border-radius:12px', 'gap:8px', 'align-items:center',
|
|
576
|
+
`box-shadow:${SHADOW}`, `font:13px/1.5 ${FONT_SANS}`, scheme,
|
|
577
|
+
]), 'data-clay-ai-edit', 'status-bar');
|
|
578
|
+
setShown(bar, false, 'flex');
|
|
579
|
+
|
|
580
|
+
barStatus = part(bevelText('div', [
|
|
581
|
+
'display:block', 'flex:1', 'min-width:0', 'overflow:hidden', 'text-overflow:ellipsis',
|
|
582
|
+
'white-space:nowrap', `color:${TOKENS.muted}`, `font:12.5px/1.5 ${FONT_MONO}`,
|
|
583
|
+
]), 'bar-status');
|
|
584
|
+
barStatus.setAttribute('role', 'status');
|
|
585
|
+
barStatus.setAttribute('aria-live', 'polite');
|
|
586
|
+
|
|
587
|
+
barStop = part(bevelButton('Stop', { extra: ['flex:none'] }), 'bar-stop');
|
|
588
|
+
barKeep = part(bevelButton('Keep', { variant: 'primary', extra: ['flex:none'] }), 'bar-keep');
|
|
589
|
+
barRevert = part(bevelButton('Revert', { variant: 'quiet', extra: ['flex:none'] }), 'bar-revert');
|
|
590
|
+
barClose = part(bevelIconButton(GLYPHS.toastClose, { label: 'Dismiss error' }), 'bar-close');
|
|
591
|
+
barClose.pin({ flex: 'none' });
|
|
592
|
+
for (const control of [barStop, barKeep, barRevert, barClose]) setShown(control, false, 'inline-flex');
|
|
593
|
+
bar.append(barStatus, barStop, barKeep, barRevert, barClose);
|
|
536
594
|
|
|
537
595
|
chip = marked(bevelButton('AI', {
|
|
538
596
|
small: true,
|
|
@@ -547,13 +605,14 @@ function buildChrome() {
|
|
|
547
605
|
docBubble.title = 'Comment on the whole page';
|
|
548
606
|
placeBubble();
|
|
549
607
|
|
|
550
|
-
document.body.append(ring, panel, chip, docBubble);
|
|
608
|
+
document.body.append(ring, panel, bar, chip, docBubble);
|
|
551
609
|
syncBubble();
|
|
552
610
|
|
|
553
611
|
buttons.send.addEventListener('click', submit);
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
612
|
+
barStop.addEventListener('click', stopSession);
|
|
613
|
+
barKeep.addEventListener('click', keepSession);
|
|
614
|
+
barRevert.addEventListener('click', () => { revertSession(); closeBar(); });
|
|
615
|
+
barClose.addEventListener('click', () => { if (barMode === 'error') closeBar(); });
|
|
557
616
|
textarea.addEventListener('keydown', (event) => {
|
|
558
617
|
if (event.key === 'Enter' && !event.shiftKey) {
|
|
559
618
|
event.preventDefault();
|
|
@@ -569,10 +628,10 @@ function buildChrome() {
|
|
|
569
628
|
const target = chipTarget;
|
|
570
629
|
const selection = chipSelection;
|
|
571
630
|
hideChip();
|
|
572
|
-
if (target && !session) openPanel(target, selection || quoteFromSelection(target));
|
|
631
|
+
if (target && !session && !barMode) openPanel(target, selection || quoteFromSelection(target));
|
|
573
632
|
});
|
|
574
633
|
docBubble.addEventListener('click', () => {
|
|
575
|
-
if (session) return;
|
|
634
|
+
if (session || barMode) return;
|
|
576
635
|
// Like a click outside, a second click keeps a typed comment: it closes an empty panel only.
|
|
577
636
|
if (!panel.hidden && anchorEl === document.body) {
|
|
578
637
|
if (textarea.value.trim()) textarea.focus();
|
|
@@ -595,9 +654,10 @@ function buildChrome() {
|
|
|
595
654
|
}).observe(document.body, { childList: true });
|
|
596
655
|
|
|
597
656
|
// A target removed from the page ends its edit, so the save hold and the paused
|
|
598
|
-
// observers never outlive it.
|
|
657
|
+
// observers never outlive it. A running bar is no exception: it is torn down with
|
|
658
|
+
// the target it was describing.
|
|
599
659
|
new MutationObserver(() => {
|
|
600
|
-
if (anchorEl && !anchorEl.isConnected
|
|
660
|
+
if (anchorEl && !anchorEl.isConnected) abandonTarget();
|
|
601
661
|
}).observe(document.body, { childList: true, subtree: true });
|
|
602
662
|
}
|
|
603
663
|
|
|
@@ -613,12 +673,14 @@ function fitTextarea() {
|
|
|
613
673
|
placePanelAgain();
|
|
614
674
|
}
|
|
615
675
|
|
|
616
|
-
// The whole-page bubble belongs to pages built from [data-edit-id] sections
|
|
617
|
-
// page gets the selection chip and
|
|
676
|
+
// The whole-page bubble belongs to pages built from [data-edit-id] sections, and it
|
|
677
|
+
// stands down while the bar holds the screen. A plain page gets the selection chip and
|
|
678
|
+
// the shortcut instead.
|
|
618
679
|
function syncBubble() {
|
|
619
680
|
const doc = docBubble?.ownerDocument;
|
|
620
681
|
if (!doc) return;
|
|
621
|
-
|
|
682
|
+
const wanted = !barMode && helperState === 'ready' && !!doc.querySelector('[data-edit-id]');
|
|
683
|
+
setShown(docBubble, wanted, 'inline-grid');
|
|
622
684
|
}
|
|
623
685
|
|
|
624
686
|
// The selected words stay marked while the person types, through the CSS Custom
|
|
@@ -637,11 +699,10 @@ function applyHelperState() {
|
|
|
637
699
|
textarea.disabled = !on;
|
|
638
700
|
if (on) {
|
|
639
701
|
if (statusEl.textContent === OFF_MESSAGE) setStatus('');
|
|
640
|
-
if (!session) showButtons('send');
|
|
641
702
|
} else {
|
|
642
703
|
setStatus(OFF_MESSAGE, 'warn');
|
|
643
|
-
showButtons();
|
|
644
704
|
}
|
|
705
|
+
setShown(buttons.send, on, 'inline-flex');
|
|
645
706
|
syncBubble();
|
|
646
707
|
}
|
|
647
708
|
|
|
@@ -699,7 +760,7 @@ function scheduleChipHide() {
|
|
|
699
760
|
// The chip at the end of a selection: for people who do not know the shortcut, and
|
|
700
761
|
// for browsers that keep Ctrl+J for themselves.
|
|
701
762
|
function showChipForSelection() {
|
|
702
|
-
if (session || !panel.hidden || helperState !== 'ready' || pageOwnsFocus()) return;
|
|
763
|
+
if (session || barMode || !panel.hidden || helperState !== 'ready' || pageOwnsFocus()) return;
|
|
703
764
|
const range = liveRange();
|
|
704
765
|
const target = range && targetFromRange(range);
|
|
705
766
|
const selection = target && selectionIn(range, target);
|
|
@@ -721,6 +782,8 @@ function showChipForSelection() {
|
|
|
721
782
|
setShown(chip, true, 'inline-grid');
|
|
722
783
|
}
|
|
723
784
|
|
|
785
|
+
// Compose-only messages: the host's switch being off and a request too large to send
|
|
786
|
+
// both belong to the box the person is typing in, because the box stays open for them.
|
|
724
787
|
function setStatus(text, tone) {
|
|
725
788
|
set(statusEl, 'margin-top', text ? '8px' : '0');
|
|
726
789
|
statusEl.textContent = text || '';
|
|
@@ -730,26 +793,84 @@ function setStatus(text, tone) {
|
|
|
730
793
|
placePanelAgain();
|
|
731
794
|
}
|
|
732
795
|
|
|
733
|
-
function
|
|
734
|
-
|
|
735
|
-
|
|
796
|
+
function setBarStatus(text, tone) {
|
|
797
|
+
barStatus.textContent = text || '';
|
|
798
|
+
if (tone === 'warn') barStatus.setAttribute('data-tone', 'warn');
|
|
799
|
+
else barStatus.removeAttribute('data-tone');
|
|
800
|
+
set(barStatus, 'color', tone === 'warn' ? TOKENS.ox : TOKENS.muted);
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
// One place decides what a bar state looks like, so the bubble and the ring can never
|
|
804
|
+
// disagree with it. A pending delayed close belongs to the state that scheduled it.
|
|
805
|
+
function showBar(mode) {
|
|
806
|
+
clearTimeout(barCloseTimer);
|
|
807
|
+
barCloseTimer = null;
|
|
808
|
+
const wasOpen = !!barMode;
|
|
809
|
+
const focused = document.activeElement;
|
|
810
|
+
barMode = mode;
|
|
811
|
+
const controls = {
|
|
812
|
+
working: [barStop],
|
|
813
|
+
ready: [barKeep, barRevert],
|
|
814
|
+
error: [barClose],
|
|
815
|
+
saving: [],
|
|
816
|
+
}[mode] || [];
|
|
817
|
+
const all = [barStop, barKeep, barRevert, barClose];
|
|
818
|
+
for (const control of all) {
|
|
819
|
+
setShown(control, controls.includes(control), 'inline-flex');
|
|
820
|
+
}
|
|
821
|
+
hideChip();
|
|
822
|
+
set(bar, 'color-scheme', pageScheme());
|
|
823
|
+
setShown(bar, true, 'flex');
|
|
824
|
+
syncBubble();
|
|
825
|
+
positionChrome();
|
|
826
|
+
// The bar opens on the control that belongs to its state, and a state change moves
|
|
827
|
+
// focus only when the button holding it has just gone away.
|
|
828
|
+
const first = controls[0];
|
|
829
|
+
if (first && (!wasOpen || (all.includes(focused) && !controls.includes(focused)))) {
|
|
830
|
+
first.focus({ preventScroll: true });
|
|
736
831
|
}
|
|
737
|
-
fitTextarea();
|
|
738
832
|
}
|
|
739
833
|
|
|
740
|
-
function
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
834
|
+
function scheduleBarClose() {
|
|
835
|
+
clearTimeout(barCloseTimer);
|
|
836
|
+
const lifecycle = barLifecycle;
|
|
837
|
+
barCloseTimer = setTimeout(() => {
|
|
838
|
+
if (lifecycle && lifecycle === barLifecycle) closeBar();
|
|
839
|
+
}, 1500);
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
// The bar is over: the edit it described is finished, so the target goes with it.
|
|
843
|
+
function closeBar(lifecycle) {
|
|
844
|
+
if (lifecycle !== undefined && lifecycle !== barLifecycle) return; // a stale teardown
|
|
845
|
+
clearTimeout(barCloseTimer);
|
|
846
|
+
barCloseTimer = null;
|
|
847
|
+
barLifecycle = null;
|
|
848
|
+
if (!panel.hidden) storeDraft(); // an open composer keeps its text through a teardown
|
|
849
|
+
barMode = null;
|
|
850
|
+
setShown(bar, false, 'flex');
|
|
851
|
+
setBarStatus('');
|
|
852
|
+
anchorEl = null;
|
|
853
|
+
anchorRange = null;
|
|
854
|
+
returnFocus = null;
|
|
855
|
+
pendingSelection = undefined;
|
|
856
|
+
clearHighlight();
|
|
857
|
+
setShown(panel, false);
|
|
858
|
+
textarea.value = '';
|
|
859
|
+
setShown(ring, false);
|
|
860
|
+
setShown(pointer, false);
|
|
861
|
+
syncBubble();
|
|
744
862
|
}
|
|
745
863
|
|
|
864
|
+
// The ring follows the target while anything is on screen for it, which includes every
|
|
865
|
+
// bar mode; the panel is placed only while it is the thing on screen.
|
|
746
866
|
function positionChrome() {
|
|
747
|
-
if (!anchorEl
|
|
748
|
-
if (!anchorEl.isConnected) {
|
|
867
|
+
if (!anchorEl) return;
|
|
868
|
+
if (!anchorEl.isConnected) { abandonTarget(); return; }
|
|
749
869
|
if (anchorEl === document.body) {
|
|
750
870
|
// document mode: no ring, panel pinned above the bubble
|
|
751
871
|
setShown(ring, false);
|
|
752
872
|
setShown(pointer, false);
|
|
873
|
+
if (panel.hidden) return;
|
|
753
874
|
const bubble = docBubble.getBoundingClientRect();
|
|
754
875
|
const width = panel.offsetWidth || 480;
|
|
755
876
|
set(panel, 'left', Math.max(16, Math.min((bubble.right || window.innerWidth - 16) - width, window.innerWidth - width - 16)) + 'px');
|
|
@@ -758,11 +879,12 @@ function positionChrome() {
|
|
|
758
879
|
}
|
|
759
880
|
const rect = anchorEl.getBoundingClientRect();
|
|
760
881
|
// With selected words the highlight marks them until Send; the ring is for a whole element and for a running edit.
|
|
761
|
-
setShown(ring, !pendingSelection || !!session);
|
|
882
|
+
setShown(ring, !!barMode || !pendingSelection || !!session);
|
|
762
883
|
set(ring, 'left', rect.left - 5 + 'px');
|
|
763
884
|
set(ring, 'top', rect.top - 5 + 'px');
|
|
764
885
|
set(ring, 'width', rect.width + 6 + 'px');
|
|
765
886
|
set(ring, 'height', rect.height + 6 + 'px');
|
|
887
|
+
if (panel.hidden) return;
|
|
766
888
|
// Under the selection while it is still in the page, else under the element.
|
|
767
889
|
const at = anchorRange && anchorRange.startContainer.isConnected && typeof anchorRange.getBoundingClientRect === 'function'
|
|
768
890
|
? anchorRange.getBoundingClientRect() : null;
|
|
@@ -788,6 +910,9 @@ function positionChrome() {
|
|
|
788
910
|
}
|
|
789
911
|
|
|
790
912
|
function openPanel(el, quote) {
|
|
913
|
+
// A composer already open on another target hands its text over as that target's draft
|
|
914
|
+
// before anything here replaces the target or the box.
|
|
915
|
+
if (!panel.hidden && anchorEl && anchorEl !== el) storeDraft();
|
|
791
916
|
const range = quote ? liveRange() : null; // read before focus moves into the panel
|
|
792
917
|
const active = document.activeElement;
|
|
793
918
|
returnFocus = active && active !== document.body && !panel.contains(active) ? active : null;
|
|
@@ -801,19 +926,28 @@ function openPanel(el, quote) {
|
|
|
801
926
|
const shown = quote ? (quote.text.trim().length > 400 ? quote.text.trim().slice(0, 400) + '\u2026' : quote.text.trim()) : '';
|
|
802
927
|
panel.dataset.quote = shown;
|
|
803
928
|
setStatus('');
|
|
804
|
-
|
|
805
|
-
showButtons('send');
|
|
929
|
+
textarea.value = drafts.get(el) || '';
|
|
806
930
|
highlight(anchorRange);
|
|
807
931
|
applyHelperState();
|
|
808
932
|
positionChrome();
|
|
933
|
+
fitTextarea();
|
|
809
934
|
textarea.focus();
|
|
810
935
|
refreshHelperState();
|
|
811
936
|
}
|
|
812
937
|
|
|
938
|
+
// Typing that was not sent is not thrown away: it is the target's draft until the
|
|
939
|
+
// panel is opened on that target again.
|
|
940
|
+
function storeDraft() {
|
|
941
|
+
if (!anchorEl) return;
|
|
942
|
+
if (textarea.value) drafts.set(anchorEl, textarea.value);
|
|
943
|
+
else drafts.delete(anchorEl);
|
|
944
|
+
}
|
|
945
|
+
|
|
813
946
|
function closePanel() {
|
|
814
947
|
if (session) { session.handle?.cancel(); revertSession(); }
|
|
815
948
|
const back = returnFocus;
|
|
816
949
|
const hadFocus = panel.contains(document.activeElement);
|
|
950
|
+
storeDraft();
|
|
817
951
|
anchorEl = null;
|
|
818
952
|
anchorRange = null;
|
|
819
953
|
returnFocus = null;
|
|
@@ -826,10 +960,30 @@ function closePanel() {
|
|
|
826
960
|
if (hadFocus && back && back.isConnected) back.focus({ preventScroll: true });
|
|
827
961
|
}
|
|
828
962
|
|
|
963
|
+
// The send path closes the box without cancelling anything and without letting the
|
|
964
|
+
// target go: the request is now about that element, and the ring stays on it.
|
|
965
|
+
function closePanelForSend() {
|
|
966
|
+
anchorRange = null;
|
|
967
|
+
returnFocus = null;
|
|
968
|
+
pendingSelection = undefined;
|
|
969
|
+
clearHighlight();
|
|
970
|
+
setShown(panel, false);
|
|
971
|
+
textarea.value = '';
|
|
972
|
+
fitTextarea();
|
|
973
|
+
positionChrome();
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
// The target is gone from the page: whatever was live for it ends, and no observer,
|
|
977
|
+
// save hold or piece of chrome is left behind.
|
|
978
|
+
function abandonTarget() {
|
|
979
|
+
const s = session;
|
|
980
|
+
if (s) { s.handle?.cancel(); revertSession(); }
|
|
981
|
+
closeBar();
|
|
982
|
+
}
|
|
983
|
+
|
|
829
984
|
function submit() {
|
|
830
985
|
const comment = textarea.value.trim();
|
|
831
986
|
if (!comment || !anchorEl || session || helperState !== 'ready') return;
|
|
832
|
-
clearHighlight();
|
|
833
987
|
sendRequest(anchorEl, comment, pendingSelection);
|
|
834
988
|
}
|
|
835
989
|
|
|
@@ -853,18 +1007,47 @@ function pageOwnsFocus() {
|
|
|
853
1007
|
return el.matches('input,textarea,select');
|
|
854
1008
|
}
|
|
855
1009
|
|
|
1010
|
+
// An Escape the page owns stays the page's: it was already handled, a modal is up, or the
|
|
1011
|
+
// caret is in a field the page owns. Nothing this plugin drew counts as somebody else's.
|
|
1012
|
+
const MODAL_SELECTOR = '[data-clay-modal], dialog[open], [aria-modal="true"]';
|
|
1013
|
+
const PAGE_FIELD = 'input,textarea,select,[contenteditable=""],[contenteditable="true"]';
|
|
1014
|
+
|
|
1015
|
+
function escapeIsMine(event) {
|
|
1016
|
+
if (event.defaultPrevented) return false;
|
|
1017
|
+
if (document.querySelector(MODAL_SELECTOR)) return false;
|
|
1018
|
+
const el = document.activeElement;
|
|
1019
|
+
if (!el || el === document.body) return true;
|
|
1020
|
+
if (panel.contains(el) || bar.contains(el)) return true;
|
|
1021
|
+
if (el.isContentEditable === true) return false;
|
|
1022
|
+
return !el.matches(PAGE_FIELD);
|
|
1023
|
+
}
|
|
1024
|
+
|
|
856
1025
|
function wireInteractions() {
|
|
857
1026
|
// Clicks: text units are contenteditable (caret) and figures keep their own
|
|
858
|
-
// interactivity, so neither is intercepted
|
|
859
|
-
//
|
|
1027
|
+
// interactivity, so neither is intercepted, and nothing here prevents the click from
|
|
1028
|
+
// landing. Any click on the page away from ClayJS chrome closes the box, a click
|
|
1029
|
+
// inside the anchored element included: the caret goes where the person clicked and
|
|
1030
|
+
// what they had typed waits as that target's draft. Bare section padding still opens
|
|
1031
|
+
// the box, and clicking another section moves it there.
|
|
1032
|
+
// Where the pointer went down decides whose click this is: a drag that starts in the
|
|
1033
|
+
// box and ends on the page is still the box's, not a click away from it.
|
|
1034
|
+
document.addEventListener('pointerdown', (event) => {
|
|
1035
|
+
panelPointerDown = panel.contains(event.target);
|
|
1036
|
+
}, { capture: true });
|
|
1037
|
+
|
|
860
1038
|
document.addEventListener('click', (event) => {
|
|
861
|
-
|
|
862
|
-
|
|
1039
|
+
const startedInPanel = panelPointerDown;
|
|
1040
|
+
panelPointerDown = false;
|
|
1041
|
+
if (startedInPanel) return;
|
|
1042
|
+
if (panel.contains(event.target) || bar.contains(event.target) || chip.contains(event.target) || docBubble.contains(event.target)) return;
|
|
1043
|
+
if (session || barMode) return; // one edit at a time, and the bar owns the screen
|
|
863
1044
|
const section = event.target.closest?.('[data-edit-id]');
|
|
864
1045
|
const unit = section ? unitFrom(event.target) : null;
|
|
865
1046
|
if (section && unit === section) {
|
|
866
|
-
|
|
867
|
-
|
|
1047
|
+
const same = !panel.hidden && section === anchorEl;
|
|
1048
|
+
if (!panel.hidden) closePanel();
|
|
1049
|
+
if (!same) openPanel(section, quoteFromSelection(section));
|
|
1050
|
+
} else if (!panel.hidden) {
|
|
868
1051
|
closePanel();
|
|
869
1052
|
}
|
|
870
1053
|
});
|
|
@@ -875,7 +1058,7 @@ function wireInteractions() {
|
|
|
875
1058
|
const now = Date.now();
|
|
876
1059
|
if (now - lastMove < 80) return;
|
|
877
1060
|
lastMove = now;
|
|
878
|
-
if (session || !panel.hidden) { scheduleChipHide(); return; }
|
|
1061
|
+
if (session || barMode || !panel.hidden) { scheduleChipHide(); return; }
|
|
879
1062
|
if (helperState !== 'ready' || chipSelection) return;
|
|
880
1063
|
if (chip.contains(event.target)) { clearTimeout(chipHideTimer); chipHideTimer = null; return; }
|
|
881
1064
|
const unit = unitFrom(event.target);
|
|
@@ -885,8 +1068,8 @@ function wireInteractions() {
|
|
|
885
1068
|
|
|
886
1069
|
document.addEventListener('keydown', (event) => {
|
|
887
1070
|
if (isShortcut(event)) {
|
|
888
|
-
if (
|
|
889
|
-
if (
|
|
1071
|
+
if (barMode || session || pageOwnsFocus()) return;
|
|
1072
|
+
if (anchorEl && !anchorEl.isConnected) closePanel();
|
|
890
1073
|
const range = liveRange();
|
|
891
1074
|
let target = range ? targetFromRange(range) : null;
|
|
892
1075
|
if (!target) {
|
|
@@ -902,16 +1085,15 @@ function wireInteractions() {
|
|
|
902
1085
|
openPanel(target, quoteFromSelection(target));
|
|
903
1086
|
return;
|
|
904
1087
|
}
|
|
905
|
-
if (event.key !== 'Escape'
|
|
906
|
-
if (
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
}
|
|
1088
|
+
if (event.key !== 'Escape') return;
|
|
1089
|
+
if (!escapeIsMine(event)) return;
|
|
1090
|
+
// The bar answers before the box: Working stops, Ready keeps (the edit is one undo
|
|
1091
|
+
// step, so keeping is the safe answer), Error and Saving have nothing to do.
|
|
1092
|
+
if (barMode === 'working') { stopSession(); return; }
|
|
1093
|
+
if (barMode === 'ready') { keepSession(); return; }
|
|
1094
|
+
if (barMode === 'error') { closeBar(); return; }
|
|
1095
|
+
if (barMode) return;
|
|
1096
|
+
if (!panel.hidden) closePanel();
|
|
915
1097
|
});
|
|
916
1098
|
|
|
917
1099
|
document.addEventListener('selectionchange', () => {
|
|
@@ -925,11 +1107,45 @@ function wireInteractions() {
|
|
|
925
1107
|
async function init() {
|
|
926
1108
|
if (!isEditMode || panel) return; // an owner/editor feature, built once
|
|
927
1109
|
helperState = await helperStateNow();
|
|
928
|
-
|
|
1110
|
+
// No ai-edit helper on this host: stay dormant. A concurrent open() that got here
|
|
1111
|
+
// first may also have built the chrome already.
|
|
1112
|
+
if (!helperState || panel) return;
|
|
929
1113
|
buildChrome();
|
|
930
1114
|
wireInteractions();
|
|
931
1115
|
}
|
|
932
1116
|
|
|
1117
|
+
// A target the composer can be opened on: a connected content element of this
|
|
1118
|
+
// document. ClayJS chrome, transient UI, everything the save strips and everything
|
|
1119
|
+
// that holds no text are not targets.
|
|
1120
|
+
function canOpenTarget(el) {
|
|
1121
|
+
return el instanceof Element && el.ownerDocument === document &&
|
|
1122
|
+
document.body.contains(el) && !el.closest('[data-clay-ai-edit]') &&
|
|
1123
|
+
!el.closest(STRIP_FROM_SAVE) && !el.closest(SNAPSHOT_REMOVE_SELECTOR) && !el.closest(NOT_TEXT);
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
// `await clay.aiEdit.open(element, { prompt: 'Add a bar chart above the table. Keep the table.' })`
|
|
1127
|
+
// opens the existing composer and returns true when opened, false when unavailable,
|
|
1128
|
+
// busy or the target cannot be edited. It does not send automatically. The prompt
|
|
1129
|
+
// seeds an empty per-target draft; previously typed text is preserved. The target
|
|
1130
|
+
// must be a connected content element in this document. Controls and transient UI
|
|
1131
|
+
// are not valid targets. A listed but disabled helper opens the existing setup
|
|
1132
|
+
// explanation with Send unavailable, just like the keyboard shortcut.
|
|
1133
|
+
async function open(el, { prompt } = {}) {
|
|
1134
|
+
if (!canOpenTarget(el)) return false;
|
|
1135
|
+
await init();
|
|
1136
|
+
// A click listener that calls this runs its microtasks before the same click reaches
|
|
1137
|
+
// the click-away handler, so the opening has to wait for that event to finish.
|
|
1138
|
+
await new Promise(resolve => setTimeout(resolve, 0));
|
|
1139
|
+
if (!panel || session || barMode || !canOpenTarget(el)) return false;
|
|
1140
|
+
if (!panel.hidden && anchorEl === el) {
|
|
1141
|
+
textarea.focus();
|
|
1142
|
+
return true;
|
|
1143
|
+
}
|
|
1144
|
+
if (typeof prompt === 'string' && !drafts.has(el)) drafts.set(el, prompt);
|
|
1145
|
+
openPanel(el, null);
|
|
1146
|
+
return true;
|
|
1147
|
+
}
|
|
1148
|
+
|
|
933
1149
|
onDomReady(() => { init().catch(() => {}); });
|
|
934
1150
|
|
|
935
|
-
export const aiEdit = { init };
|
|
1151
|
+
export const aiEdit = { init, open };
|