@panphora/clayjs 1.6.1 → 1.8.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/README.md +5 -4
- package/dist/clay.standalone.js +9578 -6063
- package/entries/clay-data.js +8 -6
- package/package.json +2 -1
- package/src/core/conflict-notice.js +2 -4
- package/src/core/etag.js +67 -3
- package/src/core/host-attrs.js +14 -0
- package/src/core/page-data.js +321 -0
- package/src/core/save.js +32 -16
- package/src/lib/content-dom.js +3 -3
- package/src/lib/root-attrs.js +14 -0
- package/src/loader-logic.js +2 -0
- package/src/loader.js +7 -2
- package/src/plugins/ai-edit.js +307 -89
- package/src/plugins/wire.js +4 -4
- package/src/sync/conflict-footprints.js +278 -0
- package/src/sync/conflict-revert.js +18 -5
- package/src/sync/conflicts.js +30 -1
- package/src/sync/live-sync.js +331 -38
- package/src/sync/stream.js +17 -4
- package/src/ui/bevel-controls.js +8 -2
- package/src/ui/bevel-dialog.js +2 -4
- package/src/ui/bevel.js +22 -2
- package/src/ui/dialogs.js +1 -1
- package/src/ui/skins/cms.js +1 -1
- package/src/ui/skins/quickcrop.js +1 -1
- package/src/ui/skins/richclay.js +2 -2
- package/src/ui/toast.js +7 -15
- package/src/vendor/hyper-morph.vendor.js +4 -4
- package/src/vendor/hypercms.vendor.js +12 -12
- package/src/vendor/quickcrop.vendor.js +1 -1
- package/src/vendor/richclay.vendor.js +27 -19
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/X, 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: 'Keep and close' }), '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,16 @@ 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
|
+
// X means Keep while the edit waits to be decided, and only closes the bar once the
|
|
616
|
+
// edit has been rewound out of the page.
|
|
617
|
+
barClose.addEventListener('click', () => { if (barMode === 'ready') keepSession(); else closeBar(); });
|
|
557
618
|
textarea.addEventListener('keydown', (event) => {
|
|
558
619
|
if (event.key === 'Enter' && !event.shiftKey) {
|
|
559
620
|
event.preventDefault();
|
|
@@ -569,10 +630,10 @@ function buildChrome() {
|
|
|
569
630
|
const target = chipTarget;
|
|
570
631
|
const selection = chipSelection;
|
|
571
632
|
hideChip();
|
|
572
|
-
if (target && !session) openPanel(target, selection || quoteFromSelection(target));
|
|
633
|
+
if (target && !session && !barMode) openPanel(target, selection || quoteFromSelection(target));
|
|
573
634
|
});
|
|
574
635
|
docBubble.addEventListener('click', () => {
|
|
575
|
-
if (session) return;
|
|
636
|
+
if (session || barMode) return;
|
|
576
637
|
// Like a click outside, a second click keeps a typed comment: it closes an empty panel only.
|
|
577
638
|
if (!panel.hidden && anchorEl === document.body) {
|
|
578
639
|
if (textarea.value.trim()) textarea.focus();
|
|
@@ -595,9 +656,10 @@ function buildChrome() {
|
|
|
595
656
|
}).observe(document.body, { childList: true });
|
|
596
657
|
|
|
597
658
|
// A target removed from the page ends its edit, so the save hold and the paused
|
|
598
|
-
// observers never outlive it.
|
|
659
|
+
// observers never outlive it. A running bar is no exception: it is torn down with
|
|
660
|
+
// the target it was describing.
|
|
599
661
|
new MutationObserver(() => {
|
|
600
|
-
if (anchorEl && !anchorEl.isConnected
|
|
662
|
+
if (anchorEl && !anchorEl.isConnected) abandonTarget();
|
|
601
663
|
}).observe(document.body, { childList: true, subtree: true });
|
|
602
664
|
}
|
|
603
665
|
|
|
@@ -613,12 +675,14 @@ function fitTextarea() {
|
|
|
613
675
|
placePanelAgain();
|
|
614
676
|
}
|
|
615
677
|
|
|
616
|
-
// The whole-page bubble belongs to pages built from [data-edit-id] sections
|
|
617
|
-
// page gets the selection chip and
|
|
678
|
+
// The whole-page bubble belongs to pages built from [data-edit-id] sections, and it
|
|
679
|
+
// stands down while the bar holds the screen. A plain page gets the selection chip and
|
|
680
|
+
// the shortcut instead.
|
|
618
681
|
function syncBubble() {
|
|
619
682
|
const doc = docBubble?.ownerDocument;
|
|
620
683
|
if (!doc) return;
|
|
621
|
-
|
|
684
|
+
const wanted = !barMode && helperState === 'ready' && !!doc.querySelector('[data-edit-id]');
|
|
685
|
+
setShown(docBubble, wanted, 'inline-grid');
|
|
622
686
|
}
|
|
623
687
|
|
|
624
688
|
// The selected words stay marked while the person types, through the CSS Custom
|
|
@@ -637,11 +701,10 @@ function applyHelperState() {
|
|
|
637
701
|
textarea.disabled = !on;
|
|
638
702
|
if (on) {
|
|
639
703
|
if (statusEl.textContent === OFF_MESSAGE) setStatus('');
|
|
640
|
-
if (!session) showButtons('send');
|
|
641
704
|
} else {
|
|
642
705
|
setStatus(OFF_MESSAGE, 'warn');
|
|
643
|
-
showButtons();
|
|
644
706
|
}
|
|
707
|
+
setShown(buttons.send, on, 'inline-flex');
|
|
645
708
|
syncBubble();
|
|
646
709
|
}
|
|
647
710
|
|
|
@@ -699,7 +762,7 @@ function scheduleChipHide() {
|
|
|
699
762
|
// The chip at the end of a selection: for people who do not know the shortcut, and
|
|
700
763
|
// for browsers that keep Ctrl+J for themselves.
|
|
701
764
|
function showChipForSelection() {
|
|
702
|
-
if (session || !panel.hidden || helperState !== 'ready' || pageOwnsFocus()) return;
|
|
765
|
+
if (session || barMode || !panel.hidden || helperState !== 'ready' || pageOwnsFocus()) return;
|
|
703
766
|
const range = liveRange();
|
|
704
767
|
const target = range && targetFromRange(range);
|
|
705
768
|
const selection = target && selectionIn(range, target);
|
|
@@ -721,6 +784,8 @@ function showChipForSelection() {
|
|
|
721
784
|
setShown(chip, true, 'inline-grid');
|
|
722
785
|
}
|
|
723
786
|
|
|
787
|
+
// Compose-only messages: the host's switch being off and a request too large to send
|
|
788
|
+
// both belong to the box the person is typing in, because the box stays open for them.
|
|
724
789
|
function setStatus(text, tone) {
|
|
725
790
|
set(statusEl, 'margin-top', text ? '8px' : '0');
|
|
726
791
|
statusEl.textContent = text || '';
|
|
@@ -730,26 +795,84 @@ function setStatus(text, tone) {
|
|
|
730
795
|
placePanelAgain();
|
|
731
796
|
}
|
|
732
797
|
|
|
733
|
-
function
|
|
734
|
-
|
|
735
|
-
|
|
798
|
+
function setBarStatus(text, tone) {
|
|
799
|
+
barStatus.textContent = text || '';
|
|
800
|
+
if (tone === 'warn') barStatus.setAttribute('data-tone', 'warn');
|
|
801
|
+
else barStatus.removeAttribute('data-tone');
|
|
802
|
+
set(barStatus, 'color', tone === 'warn' ? TOKENS.ox : TOKENS.muted);
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
// One place decides what a bar state looks like, so the bubble and the ring can never
|
|
806
|
+
// disagree with it. A pending delayed close belongs to the state that scheduled it.
|
|
807
|
+
function showBar(mode) {
|
|
808
|
+
clearTimeout(barCloseTimer);
|
|
809
|
+
barCloseTimer = null;
|
|
810
|
+
const wasOpen = !!barMode;
|
|
811
|
+
const focused = document.activeElement;
|
|
812
|
+
barMode = mode;
|
|
813
|
+
const controls = {
|
|
814
|
+
working: [barStop],
|
|
815
|
+
ready: [barKeep, barRevert, barClose],
|
|
816
|
+
error: [barClose],
|
|
817
|
+
saving: [],
|
|
818
|
+
}[mode] || [];
|
|
819
|
+
const all = [barStop, barKeep, barRevert, barClose];
|
|
820
|
+
for (const control of all) {
|
|
821
|
+
setShown(control, controls.includes(control), 'inline-flex');
|
|
822
|
+
}
|
|
823
|
+
hideChip();
|
|
824
|
+
set(bar, 'color-scheme', pageScheme());
|
|
825
|
+
setShown(bar, true, 'flex');
|
|
826
|
+
syncBubble();
|
|
827
|
+
positionChrome();
|
|
828
|
+
// The bar opens on the control that belongs to its state, and a state change moves
|
|
829
|
+
// focus only when the button holding it has just gone away.
|
|
830
|
+
const first = controls[0];
|
|
831
|
+
if (first && (!wasOpen || (all.includes(focused) && !controls.includes(focused)))) {
|
|
832
|
+
first.focus({ preventScroll: true });
|
|
736
833
|
}
|
|
737
|
-
fitTextarea();
|
|
738
834
|
}
|
|
739
835
|
|
|
740
|
-
function
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
836
|
+
function scheduleBarClose() {
|
|
837
|
+
clearTimeout(barCloseTimer);
|
|
838
|
+
const lifecycle = barLifecycle;
|
|
839
|
+
barCloseTimer = setTimeout(() => {
|
|
840
|
+
if (lifecycle && lifecycle === barLifecycle) closeBar();
|
|
841
|
+
}, 1500);
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
// The bar is over: the edit it described is finished, so the target goes with it.
|
|
845
|
+
function closeBar(lifecycle) {
|
|
846
|
+
if (lifecycle !== undefined && lifecycle !== barLifecycle) return; // a stale teardown
|
|
847
|
+
clearTimeout(barCloseTimer);
|
|
848
|
+
barCloseTimer = null;
|
|
849
|
+
barLifecycle = null;
|
|
850
|
+
if (!panel.hidden) storeDraft(); // an open composer keeps its text through a teardown
|
|
851
|
+
barMode = null;
|
|
852
|
+
setShown(bar, false, 'flex');
|
|
853
|
+
setBarStatus('');
|
|
854
|
+
anchorEl = null;
|
|
855
|
+
anchorRange = null;
|
|
856
|
+
returnFocus = null;
|
|
857
|
+
pendingSelection = undefined;
|
|
858
|
+
clearHighlight();
|
|
859
|
+
setShown(panel, false);
|
|
860
|
+
textarea.value = '';
|
|
861
|
+
setShown(ring, false);
|
|
862
|
+
setShown(pointer, false);
|
|
863
|
+
syncBubble();
|
|
744
864
|
}
|
|
745
865
|
|
|
866
|
+
// The ring follows the target while anything is on screen for it, which includes every
|
|
867
|
+
// bar mode; the panel is placed only while it is the thing on screen.
|
|
746
868
|
function positionChrome() {
|
|
747
|
-
if (!anchorEl
|
|
748
|
-
if (!anchorEl.isConnected) {
|
|
869
|
+
if (!anchorEl) return;
|
|
870
|
+
if (!anchorEl.isConnected) { abandonTarget(); return; }
|
|
749
871
|
if (anchorEl === document.body) {
|
|
750
872
|
// document mode: no ring, panel pinned above the bubble
|
|
751
873
|
setShown(ring, false);
|
|
752
874
|
setShown(pointer, false);
|
|
875
|
+
if (panel.hidden) return;
|
|
753
876
|
const bubble = docBubble.getBoundingClientRect();
|
|
754
877
|
const width = panel.offsetWidth || 480;
|
|
755
878
|
set(panel, 'left', Math.max(16, Math.min((bubble.right || window.innerWidth - 16) - width, window.innerWidth - width - 16)) + 'px');
|
|
@@ -758,11 +881,12 @@ function positionChrome() {
|
|
|
758
881
|
}
|
|
759
882
|
const rect = anchorEl.getBoundingClientRect();
|
|
760
883
|
// 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);
|
|
884
|
+
setShown(ring, !!barMode || !pendingSelection || !!session);
|
|
762
885
|
set(ring, 'left', rect.left - 5 + 'px');
|
|
763
886
|
set(ring, 'top', rect.top - 5 + 'px');
|
|
764
887
|
set(ring, 'width', rect.width + 6 + 'px');
|
|
765
888
|
set(ring, 'height', rect.height + 6 + 'px');
|
|
889
|
+
if (panel.hidden) return;
|
|
766
890
|
// Under the selection while it is still in the page, else under the element.
|
|
767
891
|
const at = anchorRange && anchorRange.startContainer.isConnected && typeof anchorRange.getBoundingClientRect === 'function'
|
|
768
892
|
? anchorRange.getBoundingClientRect() : null;
|
|
@@ -788,6 +912,9 @@ function positionChrome() {
|
|
|
788
912
|
}
|
|
789
913
|
|
|
790
914
|
function openPanel(el, quote) {
|
|
915
|
+
// A composer already open on another target hands its text over as that target's draft
|
|
916
|
+
// before anything here replaces the target or the box.
|
|
917
|
+
if (!panel.hidden && anchorEl && anchorEl !== el) storeDraft();
|
|
791
918
|
const range = quote ? liveRange() : null; // read before focus moves into the panel
|
|
792
919
|
const active = document.activeElement;
|
|
793
920
|
returnFocus = active && active !== document.body && !panel.contains(active) ? active : null;
|
|
@@ -801,19 +928,28 @@ function openPanel(el, quote) {
|
|
|
801
928
|
const shown = quote ? (quote.text.trim().length > 400 ? quote.text.trim().slice(0, 400) + '\u2026' : quote.text.trim()) : '';
|
|
802
929
|
panel.dataset.quote = shown;
|
|
803
930
|
setStatus('');
|
|
804
|
-
|
|
805
|
-
showButtons('send');
|
|
931
|
+
textarea.value = drafts.get(el) || '';
|
|
806
932
|
highlight(anchorRange);
|
|
807
933
|
applyHelperState();
|
|
808
934
|
positionChrome();
|
|
935
|
+
fitTextarea();
|
|
809
936
|
textarea.focus();
|
|
810
937
|
refreshHelperState();
|
|
811
938
|
}
|
|
812
939
|
|
|
940
|
+
// Typing that was not sent is not thrown away: it is the target's draft until the
|
|
941
|
+
// panel is opened on that target again.
|
|
942
|
+
function storeDraft() {
|
|
943
|
+
if (!anchorEl) return;
|
|
944
|
+
if (textarea.value) drafts.set(anchorEl, textarea.value);
|
|
945
|
+
else drafts.delete(anchorEl);
|
|
946
|
+
}
|
|
947
|
+
|
|
813
948
|
function closePanel() {
|
|
814
949
|
if (session) { session.handle?.cancel(); revertSession(); }
|
|
815
950
|
const back = returnFocus;
|
|
816
951
|
const hadFocus = panel.contains(document.activeElement);
|
|
952
|
+
storeDraft();
|
|
817
953
|
anchorEl = null;
|
|
818
954
|
anchorRange = null;
|
|
819
955
|
returnFocus = null;
|
|
@@ -826,10 +962,30 @@ function closePanel() {
|
|
|
826
962
|
if (hadFocus && back && back.isConnected) back.focus({ preventScroll: true });
|
|
827
963
|
}
|
|
828
964
|
|
|
965
|
+
// The send path closes the box without cancelling anything and without letting the
|
|
966
|
+
// target go: the request is now about that element, and the ring stays on it.
|
|
967
|
+
function closePanelForSend() {
|
|
968
|
+
anchorRange = null;
|
|
969
|
+
returnFocus = null;
|
|
970
|
+
pendingSelection = undefined;
|
|
971
|
+
clearHighlight();
|
|
972
|
+
setShown(panel, false);
|
|
973
|
+
textarea.value = '';
|
|
974
|
+
fitTextarea();
|
|
975
|
+
positionChrome();
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
// The target is gone from the page: whatever was live for it ends, and no observer,
|
|
979
|
+
// save hold or piece of chrome is left behind.
|
|
980
|
+
function abandonTarget() {
|
|
981
|
+
const s = session;
|
|
982
|
+
if (s) { s.handle?.cancel(); revertSession(); }
|
|
983
|
+
closeBar();
|
|
984
|
+
}
|
|
985
|
+
|
|
829
986
|
function submit() {
|
|
830
987
|
const comment = textarea.value.trim();
|
|
831
988
|
if (!comment || !anchorEl || session || helperState !== 'ready') return;
|
|
832
|
-
clearHighlight();
|
|
833
989
|
sendRequest(anchorEl, comment, pendingSelection);
|
|
834
990
|
}
|
|
835
991
|
|
|
@@ -853,18 +1009,47 @@ function pageOwnsFocus() {
|
|
|
853
1009
|
return el.matches('input,textarea,select');
|
|
854
1010
|
}
|
|
855
1011
|
|
|
1012
|
+
// An Escape the page owns stays the page's: it was already handled, a modal is up, or the
|
|
1013
|
+
// caret is in a field the page owns. Nothing this plugin drew counts as somebody else's.
|
|
1014
|
+
const MODAL_SELECTOR = '[data-clay-modal], dialog[open], [aria-modal="true"]';
|
|
1015
|
+
const PAGE_FIELD = 'input,textarea,select,[contenteditable=""],[contenteditable="true"]';
|
|
1016
|
+
|
|
1017
|
+
function escapeIsMine(event) {
|
|
1018
|
+
if (event.defaultPrevented) return false;
|
|
1019
|
+
if (document.querySelector(MODAL_SELECTOR)) return false;
|
|
1020
|
+
const el = document.activeElement;
|
|
1021
|
+
if (!el || el === document.body) return true;
|
|
1022
|
+
if (panel.contains(el) || bar.contains(el)) return true;
|
|
1023
|
+
if (el.isContentEditable === true) return false;
|
|
1024
|
+
return !el.matches(PAGE_FIELD);
|
|
1025
|
+
}
|
|
1026
|
+
|
|
856
1027
|
function wireInteractions() {
|
|
857
1028
|
// Clicks: text units are contenteditable (caret) and figures keep their own
|
|
858
|
-
// interactivity, so neither is intercepted
|
|
859
|
-
//
|
|
1029
|
+
// interactivity, so neither is intercepted, and nothing here prevents the click from
|
|
1030
|
+
// landing. Any click on the page away from ClayJS chrome closes the box, a click
|
|
1031
|
+
// inside the anchored element included: the caret goes where the person clicked and
|
|
1032
|
+
// what they had typed waits as that target's draft. Bare section padding still opens
|
|
1033
|
+
// the box, and clicking another section moves it there.
|
|
1034
|
+
// Where the pointer went down decides whose click this is: a drag that starts in the
|
|
1035
|
+
// box and ends on the page is still the box's, not a click away from it.
|
|
1036
|
+
document.addEventListener('pointerdown', (event) => {
|
|
1037
|
+
panelPointerDown = panel.contains(event.target);
|
|
1038
|
+
}, { capture: true });
|
|
1039
|
+
|
|
860
1040
|
document.addEventListener('click', (event) => {
|
|
861
|
-
|
|
862
|
-
|
|
1041
|
+
const startedInPanel = panelPointerDown;
|
|
1042
|
+
panelPointerDown = false;
|
|
1043
|
+
if (startedInPanel) return;
|
|
1044
|
+
if (panel.contains(event.target) || bar.contains(event.target) || chip.contains(event.target) || docBubble.contains(event.target)) return;
|
|
1045
|
+
if (session || barMode) return; // one edit at a time, and the bar owns the screen
|
|
863
1046
|
const section = event.target.closest?.('[data-edit-id]');
|
|
864
1047
|
const unit = section ? unitFrom(event.target) : null;
|
|
865
1048
|
if (section && unit === section) {
|
|
866
|
-
|
|
867
|
-
|
|
1049
|
+
const same = !panel.hidden && section === anchorEl;
|
|
1050
|
+
if (!panel.hidden) closePanel();
|
|
1051
|
+
if (!same) openPanel(section, quoteFromSelection(section));
|
|
1052
|
+
} else if (!panel.hidden) {
|
|
868
1053
|
closePanel();
|
|
869
1054
|
}
|
|
870
1055
|
});
|
|
@@ -875,7 +1060,7 @@ function wireInteractions() {
|
|
|
875
1060
|
const now = Date.now();
|
|
876
1061
|
if (now - lastMove < 80) return;
|
|
877
1062
|
lastMove = now;
|
|
878
|
-
if (session || !panel.hidden) { scheduleChipHide(); return; }
|
|
1063
|
+
if (session || barMode || !panel.hidden) { scheduleChipHide(); return; }
|
|
879
1064
|
if (helperState !== 'ready' || chipSelection) return;
|
|
880
1065
|
if (chip.contains(event.target)) { clearTimeout(chipHideTimer); chipHideTimer = null; return; }
|
|
881
1066
|
const unit = unitFrom(event.target);
|
|
@@ -885,8 +1070,8 @@ function wireInteractions() {
|
|
|
885
1070
|
|
|
886
1071
|
document.addEventListener('keydown', (event) => {
|
|
887
1072
|
if (isShortcut(event)) {
|
|
888
|
-
if (
|
|
889
|
-
if (
|
|
1073
|
+
if (barMode || session || pageOwnsFocus()) return;
|
|
1074
|
+
if (anchorEl && !anchorEl.isConnected) closePanel();
|
|
890
1075
|
const range = liveRange();
|
|
891
1076
|
let target = range ? targetFromRange(range) : null;
|
|
892
1077
|
if (!target) {
|
|
@@ -902,16 +1087,15 @@ function wireInteractions() {
|
|
|
902
1087
|
openPanel(target, quoteFromSelection(target));
|
|
903
1088
|
return;
|
|
904
1089
|
}
|
|
905
|
-
if (event.key !== 'Escape'
|
|
906
|
-
if (
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
}
|
|
1090
|
+
if (event.key !== 'Escape') return;
|
|
1091
|
+
if (!escapeIsMine(event)) return;
|
|
1092
|
+
// The bar answers before the box: Working stops, Ready keeps (the edit is one undo
|
|
1093
|
+
// step, so keeping is the safe answer), Error and Saving have nothing to do.
|
|
1094
|
+
if (barMode === 'working') { stopSession(); return; }
|
|
1095
|
+
if (barMode === 'ready') { keepSession(); return; }
|
|
1096
|
+
if (barMode === 'error') { closeBar(); return; }
|
|
1097
|
+
if (barMode) return;
|
|
1098
|
+
if (!panel.hidden) closePanel();
|
|
915
1099
|
});
|
|
916
1100
|
|
|
917
1101
|
document.addEventListener('selectionchange', () => {
|
|
@@ -925,11 +1109,45 @@ function wireInteractions() {
|
|
|
925
1109
|
async function init() {
|
|
926
1110
|
if (!isEditMode || panel) return; // an owner/editor feature, built once
|
|
927
1111
|
helperState = await helperStateNow();
|
|
928
|
-
|
|
1112
|
+
// No ai-edit helper on this host: stay dormant. A concurrent open() that got here
|
|
1113
|
+
// first may also have built the chrome already.
|
|
1114
|
+
if (!helperState || panel) return;
|
|
929
1115
|
buildChrome();
|
|
930
1116
|
wireInteractions();
|
|
931
1117
|
}
|
|
932
1118
|
|
|
1119
|
+
// A target the composer can be opened on: a connected content element of this
|
|
1120
|
+
// document. ClayJS chrome, transient UI, everything the save strips and everything
|
|
1121
|
+
// that holds no text are not targets.
|
|
1122
|
+
function canOpenTarget(el) {
|
|
1123
|
+
return el instanceof Element && el.ownerDocument === document &&
|
|
1124
|
+
document.body.contains(el) && !el.closest('[data-clay-ai-edit]') &&
|
|
1125
|
+
!el.closest(STRIP_FROM_SAVE) && !el.closest(SNAPSHOT_REMOVE_SELECTOR) && !el.closest(NOT_TEXT);
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
// `await clay.aiEdit.open(element, { prompt: 'Add a bar chart above the table. Keep the table.' })`
|
|
1129
|
+
// opens the existing composer and returns true when opened, false when unavailable,
|
|
1130
|
+
// busy or the target cannot be edited. It does not send automatically. The prompt
|
|
1131
|
+
// seeds an empty per-target draft; previously typed text is preserved. The target
|
|
1132
|
+
// must be a connected content element in this document. Controls and transient UI
|
|
1133
|
+
// are not valid targets. A listed but disabled helper opens the existing setup
|
|
1134
|
+
// explanation with Send unavailable, just like the keyboard shortcut.
|
|
1135
|
+
async function open(el, { prompt } = {}) {
|
|
1136
|
+
if (!canOpenTarget(el)) return false;
|
|
1137
|
+
await init();
|
|
1138
|
+
// A click listener that calls this runs its microtasks before the same click reaches
|
|
1139
|
+
// the click-away handler, so the opening has to wait for that event to finish.
|
|
1140
|
+
await new Promise(resolve => setTimeout(resolve, 0));
|
|
1141
|
+
if (!panel || session || barMode || !canOpenTarget(el)) return false;
|
|
1142
|
+
if (!panel.hidden && anchorEl === el) {
|
|
1143
|
+
textarea.focus();
|
|
1144
|
+
return true;
|
|
1145
|
+
}
|
|
1146
|
+
if (typeof prompt === 'string' && !drafts.has(el)) drafts.set(el, prompt);
|
|
1147
|
+
openPanel(el, null);
|
|
1148
|
+
return true;
|
|
1149
|
+
}
|
|
1150
|
+
|
|
933
1151
|
onDomReady(() => { init().catch(() => {}); });
|
|
934
1152
|
|
|
935
|
-
export const aiEdit = { init };
|
|
1153
|
+
export const aiEdit = { init, open };
|