@panphora/clayjs 1.6.0 → 1.7.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/src/core/save.js CHANGED
@@ -18,15 +18,22 @@ import {
18
18
  replacePageWith as replacePageWithCore,
19
19
  addDocumentTransform,
20
20
  isSaveInProgress,
21
- saveFateIsUnknown
21
+ saveFateIsUnknown,
22
+ withKeepalive
22
23
  } from "./save-core.js";
23
24
  import { captureForComparison, captureForComparisonAndDirty, captureForSaveAndComparison } from "./snapshot.js";
24
- import { seedEtag } from "./etag.js";
25
+ import { seedEtag, lastSeenEtag } from "./etag.js";
25
26
  import { gateCaptureToken, gateClearIfUnchanged, pageMaybeDirty } from "../lib/dirty-gate.js";
27
+ import { hasUnsavedState } from "../lib/unsaved-state.js";
26
28
  import { autosaveActive } from "../lib/autosave-state.js";
27
29
  import { ROOT_LIBRARY_ATTRS, SAVE_TOKEN_ATTRS, LEGACY_SAVE_TOKEN_ATTRS } from "../lib/root-attrs.js";
28
30
  import { logSaveCheck, logBaseline } from "../lib/autosave-debug.js";
29
31
  import { initUserGesture, markExplicitSave, clearExplicitSave } from "../lib/user-gesture.js";
32
+ // A deliberate import cycle: unsaved-warning reads this module's saved baseline, and
33
+ // flush asks the SAME bytes question the close warning asks, through `bytesUnsaved`.
34
+ // Both read across it at call time, never while a module is evaluating, so neither
35
+ // side sees a half-built module.
36
+ import { bytesUnsaved } from "./unsaved-warning.js";
30
37
 
31
38
  // Keep this library's own root state out of the saved bytes.
32
39
  //
@@ -207,23 +214,26 @@ export function resumeAutosave() {
207
214
  }
208
215
 
209
216
  // ============================================
210
- // THE PREVIEW HOLD
217
+ // THE ALL-SAVE HOLD
211
218
  // ============================================
212
219
  //
213
- // While an AI edit's rewrite is on screen and not yet kept, the DOM holds words the
214
- // person has not accepted. Every save would write them, the explicit ones included,
215
- // so for that window no save goes out at all. A save asked for meanwhile is
216
- // remembered and runs on release; Keep releases without the replay because it saves
217
- // right after.
220
+ // While an operation owns the page's persisted state, no save goes out at all. A
221
+ // save asked for meanwhile is remembered and runs on release.
218
222
  let previewHold = 0;
219
223
  let previewMissed = false;
224
+ const PREVIEW_HOLD_REASON = 'Waiting for the AI edit to be kept or reverted';
225
+ const holdReasons = new Map();
220
226
 
221
- export function holdAllSaves() {
227
+ export function holdAllSaves(reason = PREVIEW_HOLD_REASON) {
222
228
  previewHold++;
229
+ holdReasons.set(reason, (holdReasons.get(reason) || 0) + 1);
223
230
  }
224
231
 
225
- export function releaseAllSaves({ replay = true } = {}) {
226
- if (previewHold === 0) return;
232
+ export function releaseAllSaves({ replay = true, reason = PREVIEW_HOLD_REASON } = {}) {
233
+ const count = holdReasons.get(reason) || 0;
234
+ if (count === 0) return;
235
+ if (count === 1) holdReasons.delete(reason);
236
+ else holdReasons.set(reason, count - 1);
227
237
  previewHold--;
228
238
  if (previewHold > 0) return;
229
239
  const missed = previewMissed;
@@ -235,11 +245,16 @@ export function savesHeld() {
235
245
  return previewHold > 0;
236
246
  }
237
247
 
248
+ export function saveHoldReason() {
249
+ if (previewHold === 0) return null;
250
+ return holdReasons.keys().next().value || PREVIEW_HOLD_REASON;
251
+ }
252
+
238
253
  function heldForPreview(callback, resolve) {
239
254
  if (previewHold === 0) return false;
240
255
  previewMissed = true;
241
256
  clearExplicitSave();
242
- const skipped = skipped_('Waiting for the AI edit to be kept or reverted');
257
+ const skipped = skipped_(saveHoldReason());
243
258
  callback(skipped);
244
259
  resolve(skipped);
245
260
  return true;
@@ -560,6 +575,101 @@ export function savePageForce(callback = () => {}) {
560
575
  });
561
576
  }
562
577
 
578
+ // Resolve on the next save that settles, or reject once the deadline has passed.
579
+ // Every settled state is one, not just success: a save that failed is an answer about
580
+ // the page's bytes too, and the caller decides what to do with it.
581
+ function settled(deadline) {
582
+ const events = ['clay:save-saved', 'clay:save-error', 'clay:save-offline', 'clay:save-conflict'];
583
+ return new Promise((resolve, reject) => {
584
+ let timer = null;
585
+ const cleanup = () => {
586
+ clearTimeout(timer);
587
+ for (const name of events) document.removeEventListener(name, onSettle);
588
+ };
589
+ const onSettle = () => {
590
+ cleanup();
591
+ resolve();
592
+ };
593
+ const remaining = deadline - Date.now();
594
+ if (remaining <= 0) {
595
+ reject(Object.assign(new Error('Save timed out.'), { state: 'failed' }));
596
+ return;
597
+ }
598
+ timer = setTimeout(() => {
599
+ cleanup();
600
+ reject(Object.assign(new Error('Save timed out.'), { state: 'failed' }));
601
+ }, remaining);
602
+ for (const name of events) document.addEventListener(name, onSettle);
603
+ });
604
+ }
605
+
606
+ function flushError(message, state) {
607
+ return Object.assign(new Error(message), { state });
608
+ }
609
+
610
+ // Resolve when `name` fires on document, or reject at the deadline.
611
+ function nextEvent(name, deadline) {
612
+ return new Promise((resolve, reject) => {
613
+ const remaining = deadline - Date.now();
614
+ if (remaining <= 0) return reject(flushError('Save timed out.', 'failed'));
615
+ const timer = setTimeout(() => {
616
+ document.removeEventListener(name, onEvent);
617
+ reject(flushError('Save timed out.', 'failed'));
618
+ }, remaining);
619
+ const onEvent = () => { clearTimeout(timer); resolve(); };
620
+ document.addEventListener(name, onEvent, { once: true });
621
+ });
622
+ }
623
+
624
+ // Save now; answer only when the host has accepted the bytes the page holds now.
625
+ // Resolves { state: 'view' | 'clean' | 'saved', etag }. Rejects with an Error carrying
626
+ // state 'failed' (including a timeout), 'conflict', or 'blocked' (the bytes are on the
627
+ // host but the page holds work a save cannot write, registered through
628
+ // clay.registerUnsavedState; only a person can resolve that). Never overwrites.
629
+ // `keepalive` is a transport hint honoured under 60 KB of UTF-8.
630
+ export async function flushSave({ keepalive = false, timeoutMs = 15000 } = {}) {
631
+ if (!isEditMode) return { state: 'view', etag: null };
632
+ const deadline = Date.now() + timeoutMs;
633
+ let timer = null;
634
+ const timeout = new Promise((_, reject) => {
635
+ timer = setTimeout(() => reject(flushError('Save timed out.', 'failed')), Math.max(0, timeoutMs));
636
+ });
637
+ try {
638
+ return await Promise.race([flushBytes(keepalive, deadline), timeout]);
639
+ } catch (err) {
640
+ if (err && typeof err.state === 'string') throw err;
641
+ throw flushError(err?.message || 'Save failed', 'failed');
642
+ } finally {
643
+ clearTimeout(timer);
644
+ }
645
+ }
646
+
647
+ async function flushBytes(keepalive, deadline) {
648
+ // Load-time module churn is not the person's work: autosave never saves it, and
649
+ // neither does a flush. Wait for the baseline to settle (at most MAX_SETTLE_MS).
650
+ if (!baselineSettled()) await nextEvent('clay:baseline-settled', deadline);
651
+
652
+ let saved = false;
653
+ let etag = null;
654
+ for (let attempt = 0; attempt < 4; attempt++) {
655
+ if (isSaveInProgress()) await settled(deadline);
656
+ if (isSaveConflicted()) throw flushError('This document has a conflict to resolve.', 'conflict');
657
+ const token = gateCaptureToken();
658
+ if (!bytesUnsaved()) {
659
+ gateClearIfUnchanged(token);
660
+ if (hasUnsavedState()) throw flushError('This document has unsaved work a save cannot write.', 'blocked');
661
+ return { state: saved ? 'saved' : 'clean', etag: etag ?? lastSeenEtag() };
662
+ }
663
+ const result = await withKeepalive(keepalive, () => savePage());
664
+ // The lane was busy (a drained pending save is on the wire): wait it out next turn.
665
+ if (result.msgType === 'skipped') continue;
666
+ if (!result.ok) throw flushError(result.msg || 'Save failed', result.msgType === 'conflict' || isSaveConflicted() ? 'conflict' : 'failed');
667
+ saved = true;
668
+ etag = result.etag ?? null;
669
+ }
670
+ throw flushError('The document kept changing while saving.', 'failed');
671
+ }
672
+
563
673
  /**
564
674
  * Keep this tab's version, over the one the host is holding.
565
675
  *
@@ -752,7 +862,7 @@ export function savePageThrottled(callback = () => {}) {
752
862
 
753
863
  if (previewHold > 0) {
754
864
  previewMissed = true;
755
- const skipped = skipped_('Waiting for the AI edit to be kept or reverted');
865
+ const skipped = skipped_(saveHoldReason());
756
866
  callback(skipped);
757
867
  return Promise.resolve(skipped);
758
868
  }
@@ -43,6 +43,39 @@ preloadIfEnabled();
43
43
  // sandboxed document, which cannot read cookies at all. Those are exactly the
44
44
  // documents where an unsaved edit is easiest to lose. If the page is editable,
45
45
  // the person editing it deserves the warning.
46
+ //
47
+ // The same predicate is exposed as `clay.hasUnsavedChanges`, which a host asks
48
+ // synchronously before deciding whether a save is worth asking for.
49
+ export function hasUnsavedChanges() {
50
+ if (!isEditMode) return false;
51
+
52
+ // Work outside the DOM first: it needs no capture, and a capture that throws
53
+ // must not hide it. The demo plugin does not save it either, so it warns there too.
54
+ if (hasUnsavedState()) return true;
55
+
56
+ // The demo plugin saves the page's bytes into this browser's own storage, so
57
+ // leaving loses none of them.
58
+ if (window.clay?.demo) return false;
59
+
60
+ return bytesUnsaved();
61
+ }
62
+
63
+ // The page's own bytes against the last bytes the host accepted. A capture that throws
64
+ // counts as unsaved: something on the page is broken, and "nothing to lose" is the one
65
+ // answer that can lose work.
66
+ //
67
+ // The DIRTY domain, not the autosave domain. An edit inside a no-trigger-autosave
68
+ // region never starts a save by itself, which is exactly why closing the tab on one has
69
+ // to warn: nothing else is going to write it.
70
+ export function bytesUnsaved() {
71
+ if (!isEditMode) return false;
72
+ try {
73
+ return captureForDirtyCheck() !== getLastSavedDirty();
74
+ } catch {
75
+ return true;
76
+ }
77
+ }
78
+
46
79
  window.addEventListener('beforeunload', (event) => {
47
80
  if (!isEditMode) return;
48
81
 
@@ -51,26 +84,16 @@ window.addEventListener('beforeunload', (event) => {
51
84
  return;
52
85
  }
53
86
 
54
- // Work outside the DOM first: it needs no capture, and a capture that throws
55
- // must not hide it. The demo plugin does not save it either, so it warns there too.
56
- const held = hasUnsavedState();
57
-
58
- // The demo plugin saves the page's bytes into this browser's own storage, so
59
- // leaving loses none of them.
60
- let bytesDiffer = false;
61
- if (!held && !window.clay?.demo) {
62
- // The DIRTY domain, not the autosave domain. An edit inside a
63
- // no-trigger-autosave region never starts a save by itself, which is exactly
64
- // why closing the tab on one has to warn: nothing else is going to write it.
65
- const currentForCompare = captureForDirtyCheck();
66
- const lastSaved = getLastSavedDirty();
67
- bytesDiffer = currentForCompare !== lastSaved;
68
- // Debug: log what's different before showing the warning
69
- if (bytesDiffer) logUnloadDiffSync(currentForCompare, lastSaved);
70
- }
87
+ if (!hasUnsavedChanges()) return;
71
88
 
72
- if (held || bytesDiffer) {
73
- event.preventDefault();
74
- event.returnValue = '';
89
+ event.preventDefault();
90
+ event.returnValue = '';
91
+ // Debug only, after the decision: a capture that throws here must not cancel the warning.
92
+ if (!window.clay?.demo && !hasUnsavedState()) {
93
+ try {
94
+ const currentForCompare = captureForDirtyCheck();
95
+ const lastSaved = getLastSavedDirty();
96
+ if (currentForCompare !== lastSaved) logUnloadDiffSync(currentForCompare, lastSaved);
97
+ } catch {}
75
98
  }
76
99
  });
@@ -5,9 +5,9 @@ const UNSUPPORTED_SELECTOR = /:(?:focus(?:-within|-visible)?|hover|active|visite
5
5
  function copyControlState(source, copy) {
6
6
  if (!/^(INPUT|TEXTAREA|SELECT|OPTION)$/.test(source.tagName || '')) return
7
7
  const type = (source.getAttribute?.('type') || '').toLowerCase()
8
- if ('value' in source && 'value' in copy && source.tagName !== 'OPTION' && type !== 'checkbox' && type !== 'radio') copy.value = source.value
9
- if ('checked' in source && 'checked' in copy) copy.checked = source.checked
10
- if ('selected' in source && 'selected' in copy) copy.selected = source.selected
8
+ if ('value' in source && 'value' in copy && source.tagName !== 'OPTION' && type !== 'checkbox' && type !== 'radio' && type !== 'file') copy.value = source.value
9
+ if (source.tagName === 'INPUT' && (type === 'checkbox' || type === 'radio')) copy.checked = source.checked
10
+ if (source.tagName === 'OPTION') copy.selected = source.selected
11
11
  if (source.tagName === 'SELECT') {
12
12
  for (let i = 0; i < source.options.length; i++) copy.options[i].selected = source.options[i].selected
13
13
  }
@@ -35,6 +35,26 @@ let clearedAt = 0;
35
35
  let paused = false;
36
36
  let started = false;
37
37
 
38
+ // The state change, announced on the document: `clay:dirty` when work nothing has
39
+ // written appears, `clay:clean` when a save (or the oracle) has accounted for it. A
40
+ // burst of edits is ONE announcement, since what a listener wants is the state, not
41
+ // the edit. Only a transition is dispatched.
42
+ let announcedDirty = false;
43
+ let queued = false;
44
+
45
+ function scheduleAnnounce() {
46
+ if (!isEditMode || queued) return;
47
+ queued = true;
48
+ queueMicrotask(() => {
49
+ queued = false;
50
+ if (typeof document === 'undefined') return;
51
+ const dirty = pageMaybeDirty();
52
+ if (dirty === announcedDirty) return;
53
+ announcedDirty = dirty;
54
+ document.dispatchEvent(new CustomEvent(dirty ? 'clay:dirty' : 'clay:clean'));
55
+ });
56
+ }
57
+
38
58
  const PERSIST_CONTROLS = `input${PERSIST}, textarea${PERSIST}, select${PERSIST}`;
39
59
 
40
60
  // Regions the loss oracle never sees. The hub feed already skips them, through
@@ -81,6 +101,7 @@ function onUserInput(event) {
81
101
  if (!(el.matches('input, textarea, select') || el.isContentEditable)) return;
82
102
  if (el.closest(GATE_IGNORE)) return;
83
103
  changes++;
104
+ scheduleAnnounce();
84
105
  }
85
106
 
86
107
  export function startDirtyGate() {
@@ -89,7 +110,10 @@ export function startDirtyGate() {
89
110
  Mutation.onAnyChange(
90
111
  { omitChangeDetails: true, require: 'dirty' },
91
112
  () => {
92
- if (!paused) changes++;
113
+ if (!paused) {
114
+ changes++;
115
+ scheduleAnnounce();
116
+ }
93
117
  }
94
118
  );
95
119
  document.addEventListener('input', onUserInput, true);
@@ -151,6 +175,7 @@ export function probeMarkClean() {
151
175
  for (const el of gatedPersistControls()) {
152
176
  probeCache.set(el, controlSignature(el));
153
177
  }
178
+ scheduleAnnounce();
154
179
  }
155
180
 
156
181
  export function pageMaybeDirty() {
@@ -173,6 +198,7 @@ export function gateCaptureToken() {
173
198
  /** A change nothing in the DOM recorded: the page holds text a save has not written. */
174
199
  export function gateMarkDirty() {
175
200
  changes++;
201
+ scheduleAnnounce();
176
202
  }
177
203
 
178
204
  export function gateClearIfUnchanged(token) {
@@ -186,6 +212,7 @@ export function gateClearIfUnchanged(token) {
186
212
  for (const [el, sig] of token.probe) {
187
213
  if (controlSignature(el) === sig) probeCache.set(el, sig);
188
214
  }
215
+ scheduleAnnounce();
189
216
  }
190
217
 
191
218
  if (typeof document !== 'undefined' && isEditMode) {
@@ -8,6 +8,7 @@ export const CORE_WAVES = {
8
8
  // is exactly the case where edit mode is off, so the editOnly wave would never
9
9
  // reach it. It draws nothing on any other page.
10
10
  "core/stale-host-notice.js",
11
+ "core/page-data.js",
11
12
  ],
12
13
  editOnly: [
13
14
  "vendor/hyper-morph.vendor.js",
@@ -66,6 +67,7 @@ export const MODULES = {
66
67
  "lib/mutation.js": () => import("./lib/mutation.js"),
67
68
  "core/edit-mode.js": () => import("./core/edit-mode.js"),
68
69
  "core/stale-host-notice.js": () => import("./core/stale-host-notice.js"),
70
+ "core/page-data.js": () => import("./core/page-data.js"),
69
71
  "core/snapshot.js": () => import("./core/snapshot.js"),
70
72
  "core/save-core.js": () => import("./core/save-core.js"),
71
73
  "core/save.js": () => import("./core/save.js"),
package/src/loader.js CHANGED
@@ -50,7 +50,7 @@ export async function boot(base, params, readyResolve) {
50
50
  continue;
51
51
  }
52
52
  loaded[path] = mod;
53
- attachPluginMember(path, mod); // immediately, not after the loop: hypercms's ?cms=true
53
+ attachPluginMember(path, mod, loaded); // immediately, not after the loop: hypercms's ?cms=true
54
54
  // auto-open runs as a microtask queued during ITS evaluation
55
55
  // (before boot resumes) and reads clay.RichClay and clay.undo,
56
56
  // which earlier plugins must have attached by then
@@ -83,13 +83,23 @@ function assembleCore(loaded, { isEditMode, isOwner }, regionPolicy) {
83
83
  const save = loaded["core/save.js"];
84
84
  const cacheBustMod = loaded["lib/cache-bust.js"];
85
85
  const hyperMorph = loaded["vendor/hyper-morph.vendor.js"];
86
+ const pageData = loaded["core/page-data.js"];
87
+
88
+ clay.readData = pageData.readData;
89
+ clay.writeData = pageData.writeData;
86
90
 
87
91
  if (save) {
88
92
  const saveFn = save.savePage || save.default;
89
93
  saveFn.force = save.savePageForce;
90
94
  saveFn.overwrite = save.saveOverwritingConflict;
95
+ saveFn.flush = save.flushSave;
91
96
  clay.save = saveFn;
92
97
  }
98
+ // The close warning's own predicate, reachable without a capture. It falls back to
99
+ // false rather than to absent: the module is edit-only, and a document that cannot
100
+ // save has nothing unsaved. Older clayjs has no such member, and that is the answer
101
+ // a host feature-detects.
102
+ clay.hasUnsavedChanges = loaded["core/unsaved-warning.js"]?.hasUnsavedChanges ?? (() => false);
93
103
  if (snapshot) {
94
104
  clay.getHTML = snapshot.getPageContents;
95
105
  clay.addDocumentTransform = snapshot.addDocumentTransform;
@@ -101,7 +111,7 @@ function assembleCore(loaded, { isEditMode, isOwner }, regionPolicy) {
101
111
  if (hyperMorph) clay.morph = hyperMorph.morph;
102
112
  }
103
113
 
104
- function attachPluginMember(path, mod) {
114
+ function attachPluginMember(path, mod, loaded) {
105
115
  const clay = window.clay;
106
116
 
107
117
  if (path === "plugins/undo.js") {
@@ -109,6 +119,7 @@ function attachPluginMember(path, mod) {
109
119
  } else if (path === "sync/live-sync.js") {
110
120
  clay.morph = mod.morph;
111
121
  clay.conflicts = mod.conflicts;
122
+ loaded["core/page-data.js"].setPageDataLiveSync(mod.liveSync);
112
123
  } else if (path === "plugins/cms.js") {
113
124
  clay.cms = mod.cms || mod.default;
114
125
  } else if (path === "plugins/upload.js") {
@@ -506,10 +506,10 @@ async function flushSave() {
506
506
  // surface is an opt-in satellite script, absent on every page the loader
507
507
  // builds, so reading it here made "is a save on the wire" permanently false.
508
508
  const { isSaveInProgress } = await import("../core/save-core.js");
509
- // A held save is an AI edit preview waiting for Keep or Revert, not a page with
510
- // nothing to save: posting now would hand the agent a file that is about to change.
511
- const { savesHeld } = await import("../core/save.js");
512
- if (savesHeld()) throw new Error("keep or revert the AI edit first");
509
+ // A held save means another operation owns the page's persisted state, not a
510
+ // page with nothing to save: posting now could hand the agent stale bytes.
511
+ const { savesHeld, saveHoldReason } = await import("../core/save.js");
512
+ if (savesHeld()) throw new Error(saveHoldReason());
513
513
  const deadline = Date.now() + SAVE_FLUSH_TIMEOUT_MS;
514
514
 
515
515
  for (let attempt = 0; attempt < 3; attempt++) {
@@ -0,0 +1,41 @@
1
+ import { conflicts } from './conflicts.js';
2
+
3
+ const replacements = new WeakMap();
4
+
5
+ export const replacementFootprint = (rec) => replacements.get(rec) || null;
6
+
7
+ export function trackConflictFootprints(merge) {
8
+ const watched = new Map();
9
+ for (const rec of conflicts.list()) {
10
+ if (!rec.recovery?.text) continue;
11
+ const live = rec.recovery.subject.live;
12
+ const node = replacements.get(rec) || (live.length === 1 ? live[0] : null);
13
+ if (node?.nodeType === 1 && node.ownerDocument === document && node.isConnected) watched.set(rec, node);
14
+ }
15
+ if (!watched.size) return merge();
16
+ const observer = new MutationObserver(() => {});
17
+ observer.observe(document, { subtree: true, childList: true, attributes: true, characterData: true });
18
+ try {
19
+ const pending = merge();
20
+ const changes = observer.takeRecords();
21
+ if (changes.length !== 2) return pending;
22
+ const [insert, remove] = changes;
23
+ if (insert.type !== 'childList' || remove.type !== 'childList' || insert.target !== remove.target ||
24
+ insert.addedNodes.length !== 1 || insert.removedNodes.length ||
25
+ remove.removedNodes.length !== 1 || remove.addedNodes.length) return pending;
26
+ const old = remove.removedNodes[0];
27
+ const fresh = insert.addedNodes[0];
28
+ if (old.nodeType !== 1 || fresh.nodeType !== 1 || old.isConnected || !fresh.isConnected ||
29
+ old.namespaceURI !== fresh.namespaceURI || old.tagName === fresh.tagName ||
30
+ insert.nextSibling !== old || remove.previousSibling !== fresh ||
31
+ fresh.parentNode !== insert.target || fresh.previousSibling !== insert.previousSibling ||
32
+ fresh.nextSibling !== remove.nextSibling) return pending;
33
+ if (old.childNodes.length !== 1 || fresh.childNodes.length !== 1 || old.firstChild.nodeType !== 3 ||
34
+ !old.firstChild.isEqualNode(fresh.firstChild) || old.attributes.length !== fresh.attributes.length ||
35
+ [...old.attributes].some((a) => fresh.getAttributeNS(a.namespaceURI, a.localName) !== a.value)) return pending;
36
+ for (const [rec, node] of watched) if (node === old) replacements.set(rec, fresh);
37
+ return pending;
38
+ } finally {
39
+ observer.disconnect();
40
+ }
41
+ }
@@ -1,5 +1,6 @@
1
1
  import { HyperMorph } from "../vendor/hyper-morph.vendor.js";
2
2
  import { conflicts } from "./conflicts.js";
3
+ import { replacementFootprint } from "./conflict-footprints.js";
3
4
  import { SYNC_IGNORE_SELECTOR, REMOTE_WINS_SELECTOR } from "./live-sync.js";
4
5
  import { savePage } from "../core/save.js";
5
6
  import { gateMarkDirty } from "../lib/dirty-gate.js";
@@ -802,6 +803,8 @@ function footprintOf(rec, apply) {
802
803
  }
803
804
  const live = subject ? liveOf(subject, apply, subject.nodeType) : null;
804
805
  if (live) return live;
806
+ const replacement = replacementFootprint(rec);
807
+ if (replacement && onPage(replacement)) return replacement;
805
808
  const parent = r.structure && r.structure.localPlacement ? liveOf(r.structure.localPlacement.parent, apply, 1) : null;
806
809
  if (parent) return parent;
807
810
  const path = subject && subject.local.length ? subject.local[0] : [];
@@ -63,6 +63,33 @@ export function beginApply({ source, seq = null, etag = null, domain, root }) {
63
63
  return id;
64
64
  }
65
65
 
66
+ function insertionProof(raw) {
67
+ if (!raw.every((c) => c.kind === 'structure' && c.detail === 'insert-collision' && c.recovery?.applied === true && !c.recovery.unavailable && c.recovery.structure?.localAction === 'inserted' && c.recovery.structure.fragmentKind === 'element')) return null;
68
+ const subject = raw[0].recovery.subject;
69
+ if (subject?.local?.length !== 1 || subject.merged?.length !== 1 || subject.live?.length !== 1) return null;
70
+ return { local: subject.local[0], merged: subject.merged[0], live: subject.live[0] };
71
+ }
72
+
73
+ function foldInsertions(groups) {
74
+ const proofs = new Map(groups.map((raw) => [raw, insertionProof(raw)]));
75
+ const below = (parent, child) => parent.length < child.length && parent.every((step, i) => step === child[i]);
76
+ const at = (root, path) => path.reduce((node, step) => step === 'content' ? node?.content : node?.childNodes[step], root);
77
+ const folded = new Map();
78
+ for (const raw of groups) {
79
+ const child = proofs.get(raw);
80
+ let owner = raw;
81
+ if (child) {
82
+ for (const candidate of groups) {
83
+ const parent = proofs.get(candidate);
84
+ if (parent && parent.local.length < proofs.get(owner).local.length && below(parent.local, child.local) && below(parent.merged, child.merged) && (parent.live.contains(child.live) || at(parent.live, child.merged.slice(parent.merged.length)) === child.live)) owner = candidate;
85
+ }
86
+ }
87
+ if (!folded.has(owner)) folded.set(owner, [...owner]);
88
+ if (raw !== owner) folded.get(owner).push(...raw);
89
+ }
90
+ return [...folded.values()];
91
+ }
92
+
66
93
  export function completeApply(applyId, conflicts, { ticket }) {
67
94
  const apply = applies.get(applyId);
68
95
  if (!apply) return [];
@@ -74,7 +101,7 @@ export function completeApply(applyId, conflicts, { ticket }) {
74
101
  if (!groups.has(key)) groups.set(key, []);
75
102
  groups.get(key).push(c);
76
103
  }
77
- const ids = [...groups.values()].map((raw) => install(apply, raw));
104
+ const ids = foldInsertions([...groups.values()]).map((raw) => install(apply, raw));
78
105
  pending.delete(applyId);
79
106
  if (!ids.length) applies.delete(applyId);
80
107
  if (ids.length) changed({ added: ids.map((id) => records.get(id)) });
@@ -60,6 +60,7 @@ import { pageMaybeDirty, pauseGate, resumeGate, gateCaptureToken, gateClearIfUnc
60
60
  import { gestureSeen } from '../lib/user-gesture.js';
61
61
  import { SyncStream } from './stream.js';
62
62
  import { conflicts, beginApply, completeApply, failApply } from './conflicts.js';
63
+ import { trackConflictFootprints } from './conflict-footprints.js';
63
64
 
64
65
  // What a live-sync merge never reads or touches on any side: editor chrome,
65
66
  // content kept out of the save or the snapshot, frozen regions, and nodes
@@ -1339,6 +1340,7 @@ class LiveSync {
1339
1340
  * @param {object} [lane.extra] - extra mergeDocument options (the disk lane's beforeApply)
1340
1341
  */
1341
1342
  async _mergeIncoming(html, identityMap, { base, baseIdentityMap = null, captureLocal, synthetic, source = 'peer', seq = null, etag = null, extra = {} }) {
1343
+ const mergeDocument = (options) => trackConflictFootprints(() => HyperMorph.mergeDocument(options));
1342
1344
  const store = this.identity;
1343
1345
  // A frame may neither write these onto our root nor, by not carrying them,
1344
1346
  // take ours away. Returning false is hyper-morph's veto for both directions.
@@ -1396,7 +1398,7 @@ class LiveSync {
1396
1398
  let localRoot = null;
1397
1399
  let applyId = null;
1398
1400
  if (base == null) {
1399
- pending = HyperMorph.mergeDocument({
1401
+ pending = mergeDocument({
1400
1402
  ...common,
1401
1403
  base: null,
1402
1404
  local: { root: captureLocal(), toLive: originalSnapshotNode },
@@ -1414,7 +1416,7 @@ class LiveSync {
1414
1416
  const localRoot = captureLocal();
1415
1417
  const baseDoc = localRoot.ownerDocument;
1416
1418
  if (baseDoc.documentElement !== localRoot) baseDoc.replaceChild(localRoot, baseDoc.documentElement);
1417
- pending = HyperMorph.mergeDocument({
1419
+ pending = mergeDocument({
1418
1420
  ...common,
1419
1421
  base: baseDoc,
1420
1422
  local: { root: localRoot, toLive: originalSnapshotNode },
@@ -1432,7 +1434,7 @@ class LiveSync {
1432
1434
  domain: source === 'disk' ? 'save' : 'sync',
1433
1435
  });
1434
1436
  try {
1435
- pending = HyperMorph.mergeDocument({
1437
+ pending = mergeDocument({
1436
1438
  ...common,
1437
1439
  base,
1438
1440
  local: { root: localRoot, toLive: originalSnapshotNode },
@@ -72,8 +72,10 @@ function protectIcon(svg, size) {
72
72
  `fill:${attr('fill', svg.getAttribute('fill') ?? 'currentColor')}`,
73
73
  `stroke:${attr('stroke', svg.getAttribute('stroke') ?? 'none')}`,
74
74
  ];
75
- if (el.hasAttribute('stroke-width')) rules.push(`stroke-width:${el.getAttribute('stroke-width')}`);
76
- if (el.hasAttribute('stroke-linecap')) rules.push(`stroke-linecap:${el.getAttribute('stroke-linecap')}`);
75
+ for (const prop of ['stroke-width', 'stroke-linecap', 'stroke-linejoin']) {
76
+ const value = el.getAttribute(prop) ?? svg.getAttribute(prop);
77
+ if (value !== null) rules.push(`${prop}:${value}`);
78
+ }
77
79
  if (el.localName === 'path' && el.hasAttribute('d')) rules.push(`d:path("${el.getAttribute('d')}")`);
78
80
  style(el, rules);
79
81
  }
@@ -84,6 +86,7 @@ export function bevelButton(label, { variant = 'default', small = false, onClick
84
86
  const size = small ? SIZE.small : SIZE.normal;
85
87
  const reducedMotion = media('(prefers-reduced-motion: reduce)');
86
88
  const forced = media('(forced-colors: active)');
89
+ const coarse = media('(pointer: coarse)');
87
90
  const flags = { hovered: false, pressed: false, focusVisible: false, disabled: false };
88
91
  const pinned = new Map();
89
92
 
@@ -103,6 +106,7 @@ export function bevelButton(label, { variant = 'default', small = false, onClick
103
106
  }
104
107
  if (flags.focusVisible) rules.push(...RULES.focus);
105
108
  if (forced) rules.push(...MEDIA.forcedColors);
109
+ if (coarse) rules.push(...MEDIA.coarseButton);
106
110
  rules.push(...extra);
107
111
  for (const [prop, value] of pinned) rules.push(`${prop}:${value}`);
108
112
  restyle(b, rules);
@@ -234,6 +238,7 @@ export function bevelText(tag, rules = [], text) {
234
238
  export function paintInput(el, { rules = [] } = {}) {
235
239
  const flags = { hovered: false, focused: false };
236
240
  const forced = media('(forced-colors: active)');
241
+ const coarse = media('(pointer: coarse)');
237
242
  runtime(el);
238
243
  function applyState() {
239
244
  const height = el.localName === 'textarea' ? el.style.getPropertyValue('height') : '';
@@ -242,6 +247,7 @@ export function paintInput(el, { rules = [] } = {}) {
242
247
  if (flags.focused) list.push(...RULES.inputFocus);
243
248
  if (forced) list.push(...MEDIA.forcedColors);
244
249
  list.push(...rules);
250
+ if (coarse) list.push(...MEDIA.coarseInput);
245
251
  if (height) list.push(`height:${height}`);
246
252
  restyle(el, list);
247
253
  }
@@ -3,7 +3,7 @@
3
3
  // backdrop clicks and settling, so each keeps the rules it already had.
4
4
  import { set } from "../lib/hostile-css.js";
5
5
  import { bevelBox, bevelSurface, bevelText, pageScheme, protectIcon } from "./bevel-controls.js";
6
- import { TOKENS, FONT_SANS, RULES } from "./bevel.js";
6
+ import { TOKENS, FONT_SANS, RULES, GLYPHS } from "./bevel.js";
7
7
 
8
8
  // A frame's own way to settle itself. The modal is a singleton and clears every frame
9
9
  // when it opens; a frame it does not own (the crop dialog) registers here, so it is
@@ -32,8 +32,6 @@ export function keepFocusIn(container, event) {
32
32
  // The dashboard's dialog (Bevel's React dialog with the dashboard's overrides), drawn
33
33
  // inline. ClayJS loads no webfonts, so the title's Newsreader falls back to Georgia.
34
34
  const LINE = `1px solid ${TOKENS["line-2"]}`;
35
- const CLOSE_X =
36
- '<svg viewBox="0 0 256 256" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><path d="M205.66,194.34a8,8,0,0,1-11.32,11.32L128,139.31,61.66,205.66a8,8,0,0,1-11.32-11.32L116.69,128,50.34,61.66A8,8,0,0,1,61.66,50.34L128,116.69l66.34-66.35a8,8,0,0,1,11.32,11.32L139.31,128Z"/></svg>';
37
35
 
38
36
  // The close: a column at the header's right edge in the button face, or a 62px square
39
37
  // at the panel's corner when there is no header.
@@ -48,7 +46,7 @@ function dialogClose(label, inHeader) {
48
46
  b.type = "button";
49
47
  b.setAttribute("aria-label", label);
50
48
  b.title = label;
51
- b.innerHTML = CLOSE_X;
49
+ b.innerHTML = GLYPHS.dialogClose;
52
50
  protectIcon(b.firstElementChild, 16);
53
51
  b.addEventListener("pointerenter", () => set(b, "background", hover));
54
52
  b.addEventListener("pointerleave", () => set(b, "background", TOKENS.face));