@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.
@@ -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, warningsEl, pointer, chip, docBubble;
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
- setStatus('sending\u2026');
287
- showButtons('stop');
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 panel.
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) setStatus(text); }
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') { revertSession(); setStatus('cancelled'); showButtons('send'); }
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
- setStatus(payload.model ? `done (${payload.model})` : 'done');
399
- showWarnings(warnings);
400
- showButtons('keep', 'revert');
401
- textarea.value = '';
402
- fitTextarea();
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
- setStatus(message, 'warn');
409
- showButtons('send');
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, s.finalCandidate); // recorded: the whole edit = one undo step
469
+ sessionMorph(s, liveResult); // recorded: the whole edit = one undo step
439
470
  positionChrome();
440
- showButtons('send');
441
- setStatus('saving\u2026');
471
+ showBar('saving');
472
+ setBarStatus('Saving\u2026');
473
+ const lifecycle = barLifecycle;
442
474
  const result = await window.clay.save();
443
- setStatus((result && result.msg) || 'saved', result && result.msgType === 'error' ? 'warn' : '');
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
- function cancelStream() {
447
- // Stop is live from the moment the request starts, which includes the window where
448
- // an @page save is still in flight and there is no handle yet.
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
- setStatus('cancelled');
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
- const variants = { send: 'primary', stop: 'default', revert: 'quiet', keep: 'primary' };
521
- const labels = { send: 'Send', stop: 'Stop', revert: 'Revert', keep: 'Keep' };
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, buttons.stop, buttons.revert, buttons.keep);
529
-
530
- warningsEl = part(bevelText('div', [
531
- 'display:block', 'margin-top:8px', `color:${TOKENS.ox}`, 'white-space:pre-line',
532
- ]), 'warnings');
533
- setShown(warningsEl, false);
534
-
535
- panel.append(pointer, row, statusEl, warningsEl);
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
- buttons.stop.addEventListener('click', cancelStream);
555
- buttons.keep.addEventListener('click', keepSession);
556
- buttons.revert.addEventListener('click', () => { revertSession(); setStatus('reverted'); showButtons('send'); });
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 && !panel.hidden) closePanel();
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. A plain
617
- // page gets the selection chip and the shortcut instead.
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
- setShown(docBubble, helperState === 'ready' && !!doc.querySelector('[data-edit-id]'), 'inline-grid');
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 showButtons(...names) {
734
- for (const [name, button] of Object.entries(buttons)) {
735
- setShown(button, names.includes(name), 'inline-flex');
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 showWarnings(warnings) {
741
- setShown(warningsEl, warnings.length > 0);
742
- warningsEl.textContent = warnings.map(w => '\u26a0 ' + w).join('\n');
743
- placePanelAgain();
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 || panel.hidden) return;
748
- if (!anchorEl.isConnected) { closePanel(); return; }
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
- showWarnings([]);
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. Only a click on bare section padding
859
- // opens the comment box; a click away from an open, empty panel closes it.
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
- if (panel.contains(event.target) || chip.contains(event.target) || docBubble.contains(event.target)) return;
862
- if (session) return; // one edit at a time
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
- if (section !== anchorEl || panel.hidden) openPanel(section, quoteFromSelection(section));
867
- } else if (!panel.hidden && !textarea.value.trim() && !(anchorEl && anchorEl.contains(event.target))) {
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 (!session && anchorEl && !anchorEl.isConnected) closePanel();
889
- if (session || pageOwnsFocus()) return;
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' || panel.hidden) return;
906
- if (session?.state === 'requesting') {
907
- cancelStream();
908
- } else if (session?.state === 'deciding') {
909
- revertSession();
910
- setStatus('reverted');
911
- showButtons('send');
912
- } else {
913
- closePanel();
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
- if (!helperState) return; // no ai-edit helper on this host: stay dormant
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 };