@panphora/clayjs 1.5.3 → 1.6.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.
Files changed (45) hide show
  1. package/README.md +5 -3
  2. package/THIRD-PARTY-NOTICES.md +6 -0
  3. package/dist/clay.standalone.js +14965 -10714
  4. package/package.json +9 -4
  5. package/src/core/conflict-download.js +91 -0
  6. package/src/core/conflict-notice.js +720 -0
  7. package/src/core/conflict-presentation.js +301 -0
  8. package/src/core/save-conflict-notice.js +2 -192
  9. package/src/core/save-feedback.js +6 -0
  10. package/src/core/save.js +53 -1
  11. package/src/core/snapshot.js +21 -0
  12. package/src/core/stale-host-notice.js +21 -24
  13. package/src/core/unsaved-warning.js +35 -10
  14. package/src/lib/content-dom.js +4 -2
  15. package/src/lib/dirty-gate.js +9 -0
  16. package/src/lib/unsaved-state.js +55 -0
  17. package/src/loader-logic.js +26 -15
  18. package/src/loader.js +4 -3
  19. package/src/plugins/ai-edit.js +489 -179
  20. package/src/plugins/cms.js +12 -0
  21. package/src/plugins/indicator.js +35 -16
  22. package/src/plugins/quickcrop.js +103 -0
  23. package/src/plugins/richclay.js +21 -0
  24. package/src/plugins/source.js +13 -6
  25. package/src/plugins/wire.js +4 -0
  26. package/src/sync/conflict-revert.js +1138 -0
  27. package/src/sync/conflicts.js +159 -0
  28. package/src/sync/live-sync.js +130 -109
  29. package/src/sync/presence.js +42 -36
  30. package/src/sync/section-notice.js +53 -28
  31. package/src/ui/bevel-controls.js +277 -0
  32. package/src/ui/bevel-dialog.js +112 -0
  33. package/src/ui/bevel.js +247 -0
  34. package/src/ui/dialogs.js +63 -86
  35. package/src/ui/index.js +22 -11
  36. package/src/ui/modal.js +80 -387
  37. package/src/ui/skins/cms-toggle.js +15 -0
  38. package/src/ui/skins/cms.js +4 -0
  39. package/src/ui/skins/quickcrop.js +4 -0
  40. package/src/ui/skins/richclay.js +4 -0
  41. package/src/ui/toast.js +116 -401
  42. package/src/ui/vendor-skin.js +65 -0
  43. package/src/vendor/hyper-morph.vendor.js +4 -4
  44. package/src/vendor/quickcrop.vendor.js +14 -8
  45. package/src/vendor/richclay.vendor.js +22 -41
@@ -0,0 +1,301 @@
1
+ // What a replaced edit is called in the notice, and what it said. Pure: reads the
2
+ // page, never writes it. Names come from what the person can see (the element's
3
+ // kind and the nearest heading above it), never from authored ids.
4
+
5
+ const KIND = {
6
+ p: "Paragraph", a: "Link", li: "List item", ul: "List", ol: "List", img: "Image",
7
+ section: "Section", article: "Article", div: "Block", span: "Text", td: "Table cell",
8
+ th: "Table cell", tr: "Table row", table: "Table", button: "Button", blockquote: "Quote",
9
+ pre: "Code block", code: "Code", script: "Data block", style: "Style", figure: "Figure",
10
+ video: "Video", input: "Field", textarea: "Field", select: "Field", label: "Label",
11
+ template: "Template",
12
+ };
13
+
14
+ const ATTR = {
15
+ href: "Link address", alt: "Image description", title: "Tooltip", class: "Style",
16
+ style: "Style", value: "Field value", placeholder: "Placeholder",
17
+ };
18
+
19
+ const CONTAINERS = new Set(["section", "article", "div", "li", "aside", "main", "header", "footer", "nav"]);
20
+ const HIDDEN = new Set(["script", "style", "template", "noscript"]);
21
+ const HEADINGS = "h1,h2,h3,h4,h5,h6";
22
+ const OWN_HEADING = ":scope > :is(h1,h2,h3,h4,h5,h6)";
23
+ const CONTEXT = 40;
24
+
25
+ export function cut(text, max = CONTEXT) {
26
+ const t = text.replace(/\s+/g, " ").trim();
27
+ return t.length > max ? t.slice(0, max).trimEnd() + "…" : t;
28
+ }
29
+
30
+ // The engine's slices can carry inline markup. Parsed inert, text only: nothing
31
+ // from another tab's edit is ever put into the notice as HTML.
32
+ export function plain(html) {
33
+ if (html == null) return "";
34
+ const t = document.createElement("template");
35
+ t.innerHTML = String(html);
36
+ return t.content.textContent;
37
+ }
38
+
39
+ function elementOf(node) {
40
+ if (!node) return null;
41
+ return node.nodeType === 1 ? node : node.parentElement;
42
+ }
43
+
44
+ // A node inside a live <template>'s content is not on the page; its host is.
45
+ function hostOf(node) {
46
+ let n = node;
47
+ while (n) {
48
+ if (n.isConnected) return elementOf(n);
49
+ const root = n.getRootNode?.();
50
+ if (root && root.nodeType === 11 && root.host) { n = root.host; continue; }
51
+ const tpl = root && root.nodeType === 11 ? findTemplateOwning(root) : null;
52
+ if (tpl) { n = tpl; continue; }
53
+ return null;
54
+ }
55
+ return null;
56
+ }
57
+
58
+ function findTemplateOwning(fragment) {
59
+ for (const t of document.querySelectorAll("template")) if (t.content === fragment) return t;
60
+ return null;
61
+ }
62
+
63
+ function kindName(el) {
64
+ if (!el) return "Edit";
65
+ const tag = el.localName;
66
+ if (/^h[1-6]$/.test(tag)) return "Heading";
67
+ return KIND[tag] || tag.charAt(0).toUpperCase() + tag.slice(1);
68
+ }
69
+
70
+ export function headingAbove(node) {
71
+ let found = null;
72
+ for (const h of document.body.querySelectorAll(HEADINGS)) {
73
+ if (h === node || h.contains(node)) continue;
74
+ if (h.compareDocumentPosition(node) & Node.DOCUMENT_POSITION_FOLLOWING) found = h;
75
+ }
76
+ return found ? cut(found.textContent) : null;
77
+ }
78
+
79
+ function nameOfElement(el, attrName) {
80
+ if (!el) return attrName ? `"${attrName}" attribute` : "Edit";
81
+ if (attrName) {
82
+ const label = attrName === "src"
83
+ ? (el.localName === "img" || el.localName === "video" ? "Image address" : "Address")
84
+ : ATTR[attrName] || `"${attrName}" attribute`;
85
+ const under = el.isConnected ? headingAbove(el) : null;
86
+ return under ? `${label} under ${under}` : label;
87
+ }
88
+ if (HIDDEN.has(el.localName)) return kindName(el);
89
+ if (/^h[1-6]$/.test(el.localName)) {
90
+ const under = headingAbove(el);
91
+ return under ? `Heading under ${under}` : "Page heading";
92
+ }
93
+ if (CONTAINERS.has(el.localName)) {
94
+ const own = el.querySelector(OWN_HEADING);
95
+ if (own && own.textContent.trim()) return `${cut(own.textContent)} section`;
96
+ }
97
+ const kind = kindName(el);
98
+ const under = el.isConnected ? headingAbove(el) : null;
99
+ return under ? `${kind} under ${under}` : kind;
100
+ }
101
+
102
+ // Up to 40 characters either side of the clash, cut back to a word boundary, with
103
+ // the ellipsis only where something was left out.
104
+ export function textContext(full, start, end) {
105
+ let before = full.slice(Math.max(0, start - CONTEXT), start);
106
+ let after = full.slice(end, end + CONTEXT);
107
+ let cutBefore = start - CONTEXT > 0;
108
+ let cutAfter = end + CONTEXT < full.length;
109
+ if (cutBefore) {
110
+ const i = before.indexOf(" ");
111
+ before = i >= 0 ? before.slice(i + 1) : before;
112
+ }
113
+ if (cutAfter) {
114
+ const i = after.lastIndexOf(" ");
115
+ after = i >= 0 ? after.slice(0, i) : after;
116
+ }
117
+ return { before, after, cutBefore, cutAfter };
118
+ }
119
+
120
+ function side(hit, ctx) {
121
+ return { before: ctx?.before || "", hit, after: ctx?.after || "", cutBefore: !!ctx?.cutBefore, cutAfter: !!ctx?.cutAfter };
122
+ }
123
+
124
+ const SENTENCE = {
125
+ "edit-beats-delete": (k) => `You deleted this ${k}. The other edit changed it at the same time, so it is still here.`,
126
+ "move-beats-delete": (k) => `You deleted this ${k}. The other edit moved it at the same time, so it is still here.`,
127
+ "both-moved": (k) => `You moved this ${k}. The other edit moved it somewhere else. Their place is showing.`,
128
+ "both-reordered": (k) => `You reordered the items in this ${k}. The other edit reordered them too. Their order is showing.`,
129
+ "insert-collision": () => "You added something here. The other edit added something else in the same place. Theirs is showing.",
130
+ };
131
+
132
+ export function structuralSentence(record, kind) {
133
+ const reason = record.detail || record.reason;
134
+ const make = SENTENCE[reason];
135
+ return make ? make(kind.toLowerCase()) : "Your change here was replaced by the other edit.";
136
+ }
137
+
138
+ function visibleContainer(el) {
139
+ let n = el.parentElement;
140
+ while (n && HIDDEN.has(n.localName)) n = n.parentElement;
141
+ return n && n !== document.documentElement ? n : null;
142
+ }
143
+
144
+ const ATOM = "\uFFFC";
145
+ const BREAK = "\u001E";
146
+
147
+ // The engine's flattened text marks an inline element (an image, a field) with
148
+ // U+FFFC and a block break with U+001E. Neither is shown as a control character.
149
+ function shown(text) {
150
+ return text.replaceAll(ATOM, "[item]").replaceAll(BREAK, "\n");
151
+ }
152
+
153
+ function textSide(t) {
154
+ if (!t || typeof t.text !== "string") return null;
155
+ const ctx = textContext(t.text, t.start, t.end);
156
+ return {
157
+ before: shown(ctx.before), hit: shown(t.text.slice(t.start, t.end)), after: shown(ctx.after),
158
+ cutBefore: ctx.cutBefore, cutAfter: ctx.cutAfter,
159
+ };
160
+ }
161
+
162
+ // A live node the engine named, still on the page or in a live template.
163
+ function liveOf(ref) {
164
+ for (const n of ref?.live || []) if (hostOf(n)) return n;
165
+ return null;
166
+ }
167
+
168
+ // Recovery paths index childNodes from the root the merge read as "mine" (the
169
+ // clone the ledger keeps); "content" enters a template's fragment.
170
+ function nodeAtPath(root, path) {
171
+ let n = root;
172
+ for (const step of path) {
173
+ if (!n) return null;
174
+ n = step === "content" ? n.content : n.childNodes[step];
175
+ }
176
+ return n || null;
177
+ }
178
+
179
+ function localNode(record, ref) {
180
+ const root = window.clay?.conflicts?.recoveryOf?.(record.id)?.root;
181
+ const path = ref?.local?.[0];
182
+ return root && path ? nodeAtPath(root, path) : null;
183
+ }
184
+
185
+ function visibleHost(node) {
186
+ const h = hostOf(node);
187
+ return h && HIDDEN.has(h.localName) ? visibleContainer(h) : h;
188
+ }
189
+
190
+ // Where a moved block went, in words: "into the FAQ section", "under Pricing".
191
+ function placeName(node) {
192
+ const el = elementOf(node);
193
+ if (!el) return null;
194
+ if (CONTAINERS.has(el.localName)) {
195
+ const own = el.querySelector(OWN_HEADING);
196
+ if (own && own.textContent.trim()) return `into the ${cut(own.textContent)} section`;
197
+ }
198
+ const under = el.isConnected ? headingAbove(el) : null;
199
+ return under ? `under ${under}` : null;
200
+ }
201
+
202
+ function recoverySentence(record, rv, kind) {
203
+ const s = rv.structure;
204
+ const k = kind.toLowerCase();
205
+ switch (`${s.localAction}/${s.remoteAction}`) {
206
+ case "deleted/edited":
207
+ return `You deleted this ${k}. The other edit changed it at the same time, so it is still here.`;
208
+ case "deleted/moved":
209
+ return `You deleted this ${k}. The other edit moved it, so it is still here.`;
210
+ case "reordered/reordered":
211
+ return `You changed the order of the items in this ${k}. The other edit changed their order too. Their order is showing.`;
212
+ case "inserted/inserted":
213
+ return "You and the other edit added different content in the same place. Their content is showing.";
214
+ case "moved/moved": {
215
+ const mine = placeName(liveOf(s.localPlacement?.parent) || localNode(record, s.localPlacement?.parent));
216
+ const theirs = placeName(liveOf(s.mergedPlacement?.parent));
217
+ if (mine && theirs && mine !== theirs) {
218
+ return `You moved this ${k} ${mine}. The other edit moved it ${theirs}. Their position is showing.`;
219
+ }
220
+ return `You moved this ${k}. The other edit moved it somewhere else. Their position is showing.`;
221
+ }
222
+ default:
223
+ return "Your change here was replaced by the other edit.";
224
+ }
225
+ }
226
+
227
+ const CHANGED_AGAIN = "The other edit replaced this, and the page changed again before it could be shown here. Download my copy keeps your version.";
228
+
229
+ const NON_ELEMENT = { 3: "Text", 8: "Comment" };
230
+
231
+ function recoveryRow(record, rv) {
232
+ const id = record.id;
233
+ // Output the engine could not map to the page: nothing here to point at, and
234
+ // what the page shows is not the other edit's text either.
235
+ const shown = rv.applied !== false;
236
+ const live = shown ? liveOf(rv.subject) : null;
237
+ const target = live ? visibleHost(live) : null;
238
+ const shape = elementOf(live) || elementOf(localNode(record, rv.subject));
239
+ const plainKind = NON_ELEMENT[rv.subject?.nodeType];
240
+ if (rv.attribute) {
241
+ return {
242
+ id, kind: "attr", name: nameOfElement(shape, rv.attribute.qualifiedName), target,
243
+ yours: side(String(record.local ?? "")), now: shown ? side(String(record.remote ?? "")) : null,
244
+ sentence: shown ? null : CHANGED_AGAIN,
245
+ };
246
+ }
247
+ if (rv.structure) {
248
+ const kind = plainKind || (shape ? kindName(shape) : "Block");
249
+ const name = plainKind || (live ? nameOfElement(elementOf(live)) : kind);
250
+ return {
251
+ id, kind: "struct", name, target, yours: null, now: null,
252
+ sentence: shown ? recoverySentence(record, rv, kind) : CHANGED_AGAIN,
253
+ };
254
+ }
255
+ const name = plainKind === "Comment" ? "Comment"
256
+ : shape && HIDDEN.has(shape.localName) ? kindName(shape)
257
+ : nameOfElement(shape);
258
+ return {
259
+ id, kind: "text", name, target,
260
+ yours: textSide(rv.text?.local), now: shown ? textSide(rv.text?.merged) : null,
261
+ sentence: shown ? null : CHANGED_AGAIN,
262
+ };
263
+ }
264
+
265
+ export function rowOf(record) {
266
+ const id = record.id;
267
+ if (record.recovery?.version === 1) return recoveryRow(record, record.recovery);
268
+ if (record.kind === "attr") {
269
+ const el = record.el || null;
270
+ return {
271
+ id, kind: "attr", name: nameOfElement(el, record.name), target: hostOf(el),
272
+ yours: side(String(record.local ?? "")), now: side(String(record.remote ?? "")), sentence: null,
273
+ };
274
+ }
275
+ if (record.kind === "structure") {
276
+ const live = record.el && record.el.isConnected ? record.el : null;
277
+ const shape = live || record.base || record.el || null;
278
+ const kind = kindName(elementOf(shape));
279
+ const sentence = structuralSentence(record, kind);
280
+ const both = typeof record.local === "string" && typeof record.remote === "string";
281
+ return {
282
+ id, kind: "struct", name: live ? nameOfElement(live) : kind, target: live ? hostOf(live) : null,
283
+ yours: both ? side(plain(record.local)) : null, now: both ? side(plain(record.remote)) : null, sentence,
284
+ };
285
+ }
286
+ const host = hostOf(record.node);
287
+ const el = elementOf(record.node);
288
+ const name = el && HIDDEN.has(el.localName) ? kindName(el)
289
+ : record.node?.nodeType === 8 ? "Comment"
290
+ : nameOfElement(el);
291
+ // Inline-merge slices (they carry lss) are HTML; whole-text and word-level
292
+ // conflicts are the raw text itself, and parsing that as HTML would eat a literal <.
293
+ const html = typeof record.lss === "number";
294
+ const text = (v) => (v == null ? "" : Array.isArray(v) ? v.join("") : String(v));
295
+ const yours = html ? plain(record.local) : text(record.local);
296
+ const now = html ? plain(record.remote) : text(record.remote);
297
+ return {
298
+ id, kind: "text", name, target: host && !HIDDEN.has(host.localName) ? host : (host ? visibleContainer(host) : null),
299
+ yours: side(yours), now: side(now), sentence: null,
300
+ };
301
+ }
@@ -1,192 +1,2 @@
1
- import { isEditMode } from "./is-edit-mode.js";
2
- import onDomReady from "../lib/dom-ready.js";
3
- import { style, set, make } from "../lib/hostile-css.js";
4
-
5
- // The page's only word on a refused save. Without it a document whose host said no
6
- // looks exactly like one that is saving fine, while autosave sits suspended: the
7
- // status chip that would have said so is a plugin, off by default, so most
8
- // documents show nothing at all.
9
- //
10
- // It sits at the bottom because that is where a hand already is on a phone, and
11
- // one line because the decision is small: keep this version, or take the other.
12
- // Discarding arms before it fires, since it is the only control here that destroys
13
- // work with no undo, and the page's own CSS is treated as hostile throughout.
14
-
15
- const BG = "var(--clay-conflict-bg,#222)";
16
- const INK = "var(--clay-conflict-ink,#fff)";
17
- const EDGE = "var(--clay-conflict-edge,rgba(255,255,255,.28))";
18
- const WARN = "var(--clay-conflict-warn,#e3a33f)";
19
- const FONT = "14px/1.45 system-ui,-apple-system,'Segoe UI',sans-serif";
20
-
21
- // The host names what moved the file when it knows. It often cannot: a plain
22
- // filesystem write has no author. Anything unrecognised falls back to the phrase
23
- // that is true in every case.
24
- const SOURCES = {
25
- "another-tab": "in another tab",
26
- "another-person": "by someone else",
27
- "an-agent": "by an agent",
28
- };
29
-
30
- const ARM_MS = 5000;
31
-
32
- let root = null, line = null, keep = null, drop = null;
33
- let armTimer = null, armed = false, busy = false;
34
-
35
- const still = () => !!window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
36
-
37
- // all:initial first, because a page restyling every button is the normal case, not
38
- // the adversarial one. Everything the control needs is restated after it.
39
- function button(label, rules) {
40
- const b = make("button", [
41
- "all:initial", "box-sizing:border-box", "cursor:pointer", `font:${FONT}`,
42
- "font-weight:500", "border-radius:6px", "padding:6px 12px", "white-space:nowrap",
43
- "flex:none", ...rules,
44
- ], label);
45
- b.type = "button";
46
- b.addEventListener("focus", () => {
47
- set(b, "outline", `2px solid ${WARN}`);
48
- set(b, "outline-offset", "2px");
49
- });
50
- b.addEventListener("blur", () => set(b, "outline", "none"));
51
- return b;
52
- }
53
-
54
- // A phone keyboard shrinks the visual viewport but leaves fixed elements pinned to
55
- // the layout viewport, which parks a bottom-anchored bar behind the keyboard at the
56
- // exact moment somebody is typing. Lift it by the difference instead.
57
- function place() {
58
- if (!root) return;
59
- const vv = window.visualViewport;
60
- const lift = vv ? Math.max(0, window.innerHeight - vv.height - vv.offsetTop) : 0;
61
- set(root, "bottom", `calc(${16 + lift}px + env(safe-area-inset-bottom,0px))`);
62
- }
63
-
64
- function build() {
65
- root = make("div", [
66
- "position:fixed", "left:50%", "transform:translateX(-50%)",
67
- "z-index:2147483001", "display:flex", "align-items:center", "gap:10px",
68
- "max-width:calc(100vw - 24px)", "flex-wrap:wrap", "justify-content:center",
69
- "padding:9px 12px", "border-radius:10px",
70
- `background:${BG}`, `color:${INK}`, `border:1px solid ${EDGE}`,
71
- "box-shadow:0 6px 24px rgba(0,0,0,.32),0 1px 2px rgba(0,0,0,.24)",
72
- `font:${FONT}`, "text-align:left",
73
- ]);
74
- root.setAttribute("clay", "no-save no-watch no-snapshot");
75
- root.setAttribute("data-clay-conflict", "");
76
- root.setAttribute("role", "alert");
77
-
78
- line = make("span", ["margin-right:2px"]);
79
- drop = button("Load theirs", [`color:${INK}`, "opacity:.72", "padding:6px 8px"]);
80
- keep = button("Keep mine", [`background:${INK}`, `color:${BG}`, "font-weight:600"]);
81
-
82
- drop.addEventListener("click", onDrop);
83
- keep.addEventListener("click", onKeep);
84
- root.append(line, drop, keep);
85
-
86
- if (!still()) {
87
- set(root, "transition", "opacity .18s");
88
- set(root, "opacity", "0");
89
- }
90
- document.body.appendChild(root);
91
- place();
92
- window.visualViewport?.addEventListener("resize", place);
93
- window.visualViewport?.addEventListener("scroll", place);
94
- requestAnimationFrame(() => set(root, "opacity", "1"));
95
- }
96
-
97
- // One arming click before the reload. A first click is a choice, not a confirmation
98
- // of anything, and this is the only control here that throws away work nothing can
99
- // bring back. It disarms itself so a bar left open does not stay one stray tap from
100
- // discarding an afternoon.
101
- function onDrop() {
102
- if (armed) { window.location.reload(); return; }
103
- armed = true;
104
- drop.textContent = "Yes, drop my edits";
105
- set(drop, "opacity", "1");
106
- set(drop, "color", WARN);
107
- set(drop, "box-shadow", `inset 0 0 0 1px ${WARN}`);
108
- clearTimeout(armTimer);
109
- armTimer = setTimeout(disarm, ARM_MS);
110
- }
111
-
112
- function disarm() {
113
- clearTimeout(armTimer);
114
- if (!armed) return;
115
- armed = false;
116
- drop.textContent = "Load theirs";
117
- set(drop, "opacity", ".72");
118
- set(drop, "color", INK);
119
- set(drop, "box-shadow", "none");
120
- }
121
-
122
- async function onKeep() {
123
- if (busy) return;
124
- busy = true;
125
- disarm();
126
- keep.textContent = "Saving…";
127
- set(keep, "opacity", ".65");
128
- try {
129
- const result = await window.clay?.save?.overwrite?.();
130
- if (result && result.ok === false) throw new Error(result.msg || "");
131
- } catch {
132
- // Left showing: the save still has not happened, and clearing the bar here
133
- // would put the page back to looking like one that is saving normally.
134
- line.textContent = "That did not save either. Your edits are still here.";
135
- keep.textContent = "Try again";
136
- set(keep, "opacity", "1");
137
- busy = false;
138
- return;
139
- }
140
- busy = false;
141
- }
142
-
143
- function onKeydown(e) {
144
- if (e.key === "Escape" && armed) { e.preventDefault(); drop.focus(); disarm(); }
145
- }
146
-
147
- function show(e) {
148
- if (!root) build();
149
- set(root, "display", "flex");
150
- const named = SOURCES[e?.detail?.changedBy];
151
- // Reassurance first, then the fact. What a person needs to know at this moment
152
- // is that nothing of theirs is gone and nothing is about to be overwritten; the
153
- // detail of what happened is the second half of the sentence, not the first.
154
- // "Saving is paused" led here once and read as a failure, which it is not: the
155
- // page is holding a version, and the two buttons below are the whole decision.
156
- //
157
- // An earlier save of this tab's timed out and the host could not say what became
158
- // of it, so this refusal may be answering that write rather than anybody else's.
159
- // Saying so is the whole reason the stamp is kept across a timeout instead of
160
- // quietly reconciled. A host that NAMED the writer, or that answered the question
161
- // with a receipt, knows better than this guess, so the name wins.
162
- if (!named && e?.detail?.afterTimeout) {
163
- line.textContent =
164
- "This page changed elsewhere, possibly by your own save that timed out. " +
165
- "Your edits here are safe, and nothing will be overwritten until you choose.";
166
- } else {
167
- line.textContent =
168
- `This page changed ${named || "elsewhere"}. ` +
169
- "Your edits here are safe, and nothing will be overwritten until you choose.";
170
- }
171
- keep.textContent = "Keep mine";
172
- set(keep, "opacity", "1");
173
- busy = false;
174
- disarm();
175
- place();
176
- }
177
-
178
- function hide() {
179
- if (!root) return;
180
- disarm();
181
- set(root, "display", "none");
182
- }
183
-
184
- function init() {
185
- if (!isEditMode) return;
186
- document.addEventListener("clay:save-conflict", show);
187
- document.addEventListener("clay:save-saved", hide);
188
- document.addEventListener("clay:save-conflict-resolved", hide);
189
- document.addEventListener("keydown", onKeydown);
190
- }
191
-
192
- onDomReady(init);
1
+ // Kept so older imports of this path still load the notice. Removed in ClayJS 1.6.
2
+ import "./conflict-notice.js";
@@ -0,0 +1,6 @@
1
+ // Which save feedback is on the page, so the indicator chip and the automatic toasts
2
+ // never report the same save twice: the chip owns saving and saved, the toasts own a
3
+ // save that failed or went offline. Each side sets its own flag when it loads; both
4
+ // read them only when an event arrives, so load order does not matter. savedToast is
5
+ // the page's `clay.saveToast` switch: Saved goes to a toast instead of the chip.
6
+ export const saveFeedback = { chip: false, toasts: false, savedToast: false };
package/src/core/save.js CHANGED
@@ -206,6 +206,45 @@ export function resumeAutosave() {
206
206
  savePageThrottled();
207
207
  }
208
208
 
209
+ // ============================================
210
+ // THE PREVIEW HOLD
211
+ // ============================================
212
+ //
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.
218
+ let previewHold = 0;
219
+ let previewMissed = false;
220
+
221
+ export function holdAllSaves() {
222
+ previewHold++;
223
+ }
224
+
225
+ export function releaseAllSaves({ replay = true } = {}) {
226
+ if (previewHold === 0) return;
227
+ previewHold--;
228
+ if (previewHold > 0) return;
229
+ const missed = previewMissed;
230
+ previewMissed = false;
231
+ if (missed && replay) savePage();
232
+ }
233
+
234
+ export function savesHeld() {
235
+ return previewHold > 0;
236
+ }
237
+
238
+ function heldForPreview(callback, resolve) {
239
+ if (previewHold === 0) return false;
240
+ previewMissed = true;
241
+ clearExplicitSave();
242
+ const skipped = skipped_('Waiting for the AI edit to be kept or reverted');
243
+ callback(skipped);
244
+ resolve(skipped);
245
+ return true;
246
+ }
247
+
209
248
  // ============================================
210
249
  // THE CONFLICT HOLD
211
250
  // ============================================
@@ -257,7 +296,8 @@ export function isSaveConflicted() {
257
296
  * missed save. On a manual-save page with unsaved work the root stays in
258
297
  * 'conflict' and the notice stays up until the person saves, since nothing has
259
298
  * been written yet. Not clay:save-saved: no save happened, and that event runs
260
- * every [onaftersave] handler.
299
+ * every [onaftersave] handler. Either way clay:save-conflict-released fires, so a
300
+ * notice can drop the refusal it was showing.
261
301
  */
262
302
  export function conflictResolvedBySync(etag) {
263
303
  if (!conflictHold) return;
@@ -273,6 +313,7 @@ export function conflictResolvedBySync(etag) {
273
313
  detail: { timestamp: Date.now() }
274
314
  }));
275
315
  }
316
+ document.dispatchEvent(new CustomEvent('clay:save-conflict-released', { detail: { etag: etag ?? null } }));
276
317
  resumeAutosave();
277
318
  }
278
319
 
@@ -391,6 +432,8 @@ export function savePage(callback = () => {}) {
391
432
  return resolve(skipped);
392
433
  }
393
434
 
435
+ if (heldForPreview(callback, resolve)) return;
436
+
394
437
  // A save is already on the wire. Remember that a newer state is waiting rather
395
438
  // than dropping it: the in-flight request carries the older bytes, and if no
396
439
  // further mutation happens to retrigger autosave, the newer ones would never
@@ -476,6 +519,8 @@ export function savePageForce(callback = () => {}) {
476
519
  return resolve(skipped);
477
520
  }
478
521
 
522
+ if (heldForPreview(callback, resolve)) return;
523
+
479
524
  if (isSaveInProgress()) {
480
525
  pendingSave = true;
481
526
  const skipped = skipped_('Save already in progress');
@@ -705,6 +750,13 @@ export function savePageThrottled(callback = () => {}) {
705
750
  return Promise.resolve(skipped);
706
751
  }
707
752
 
753
+ if (previewHold > 0) {
754
+ previewMissed = true;
755
+ const skipped = skipped_('Waiting for the AI edit to be kept or reverted');
756
+ callback(skipped);
757
+ return Promise.resolve(skipped);
758
+ }
759
+
708
760
  // Every autosave path lands here — the mutation-driven one, the [persist] input
709
761
  // timer, and live-sync's convergence save after a protected apply — which is
710
762
  // why the suspension lives at this one entry rather than at each caller.
@@ -679,3 +679,24 @@ export function captureBodyForSync() {
679
679
  export function getPageContents() {
680
680
  return captureForSave({ emitForSync: false });
681
681
  }
682
+
683
+ /**
684
+ * The page as this tab had it before a merge replaced some of it, as the bytes a
685
+ * save would have written. The ledger's clone is shared and must come back
686
+ * untouched, so this works on a copy. A snapshot-domain clone still needs the save
687
+ * preparation (transforms, the [no-save] strip) but not the authored onbeforesave
688
+ * handlers: this is a download, not a save. A save-domain clone is already prepared.
689
+ */
690
+ export function serializeRecoveryClone(clone, { prepared = false } = {}) {
691
+ const copy = clonePreventingOnclone(clone);
692
+ return serializeClone(prepared ? copy : prepareCloneForSave(copy, { authored: false }));
693
+ }
694
+
695
+ /**
696
+ * The live page as a save would write it, for a download: the save transforms and
697
+ * the [no-save] strip, but no authored handler runs and the undo batch stays open.
698
+ * Nothing is sent and no event fires.
699
+ */
700
+ export function captureRecoveryPage() {
701
+ return serializeSaveClone(captureSaveClone({ flushUndo: false, authored: false }));
702
+ }