@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.
@@ -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, 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: '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
- 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
+ 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 && !panel.hidden) closePanel();
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. A plain
617
- // page gets the selection chip and the shortcut instead.
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
- setShown(docBubble, helperState === 'ready' && !!doc.querySelector('[data-edit-id]'), 'inline-grid');
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 showButtons(...names) {
734
- for (const [name, button] of Object.entries(buttons)) {
735
- setShown(button, names.includes(name), 'inline-flex');
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 showWarnings(warnings) {
741
- setShown(warningsEl, warnings.length > 0);
742
- warningsEl.textContent = warnings.map(w => '\u26a0 ' + w).join('\n');
743
- placePanelAgain();
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 || panel.hidden) return;
748
- if (!anchorEl.isConnected) { closePanel(); return; }
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
- showWarnings([]);
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. Only a click on bare section padding
859
- // opens the comment box; a click away from an open, empty panel closes it.
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
- if (panel.contains(event.target) || chip.contains(event.target) || docBubble.contains(event.target)) return;
862
- if (session) return; // one edit at a time
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
- if (section !== anchorEl || panel.hidden) openPanel(section, quoteFromSelection(section));
867
- } else if (!panel.hidden && !textarea.value.trim() && !(anchorEl && anchorEl.contains(event.target))) {
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 (!session && anchorEl && !anchorEl.isConnected) closePanel();
889
- if (session || pageOwnsFocus()) return;
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' || 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
- }
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
- if (!helperState) return; // no ai-edit helper on this host: stay dormant
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 };