@panphora/clayjs 1.5.3 → 1.6.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.
Files changed (46) hide show
  1. package/README.md +5 -3
  2. package/THIRD-PARTY-NOTICES.md +6 -0
  3. package/dist/clay.standalone.js +15041 -10657
  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-core.js +35 -2
  10. package/src/core/save-feedback.js +6 -0
  11. package/src/core/save.js +157 -3
  12. package/src/core/snapshot.js +21 -0
  13. package/src/core/stale-host-notice.js +21 -24
  14. package/src/core/unsaved-warning.js +64 -16
  15. package/src/lib/content-dom.js +4 -2
  16. package/src/lib/dirty-gate.js +37 -1
  17. package/src/lib/unsaved-state.js +55 -0
  18. package/src/loader-logic.js +26 -15
  19. package/src/loader.js +10 -3
  20. package/src/plugins/ai-edit.js +489 -179
  21. package/src/plugins/cms.js +12 -0
  22. package/src/plugins/indicator.js +35 -16
  23. package/src/plugins/quickcrop.js +103 -0
  24. package/src/plugins/richclay.js +21 -0
  25. package/src/plugins/source.js +13 -6
  26. package/src/plugins/wire.js +4 -0
  27. package/src/sync/conflict-revert.js +1138 -0
  28. package/src/sync/conflicts.js +159 -0
  29. package/src/sync/live-sync.js +130 -109
  30. package/src/sync/presence.js +42 -36
  31. package/src/sync/section-notice.js +53 -28
  32. package/src/ui/bevel-controls.js +277 -0
  33. package/src/ui/bevel-dialog.js +112 -0
  34. package/src/ui/bevel.js +247 -0
  35. package/src/ui/dialogs.js +63 -86
  36. package/src/ui/index.js +22 -11
  37. package/src/ui/modal.js +80 -387
  38. package/src/ui/skins/cms-toggle.js +15 -0
  39. package/src/ui/skins/cms.js +4 -0
  40. package/src/ui/skins/quickcrop.js +4 -0
  41. package/src/ui/skins/richclay.js +4 -0
  42. package/src/ui/toast.js +116 -401
  43. package/src/ui/vendor-skin.js +65 -0
  44. package/src/vendor/hyper-morph.vendor.js +4 -4
  45. package/src/vendor/quickcrop.vendor.js +14 -8
  46. 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";
@@ -25,6 +25,12 @@ import {
25
25
  let saveInProgress = false;
26
26
  const SAVE_PATH = '/_/save';
27
27
  const SAVE_TIMEOUT_MS = 12000;
28
+ // The browser caps a keepalive body (Chromium refuses one over 64 KB), and it counts
29
+ // BYTES: the hint is only attached to a save whose UTF-8 encoding fits well inside that.
30
+ const KEEPALIVE_MAX_BODY = 60000;
31
+ // Set by `withKeepalive`, read once by the next `sendOnce`: the hint belongs to one
32
+ // request, not to the save pipeline.
33
+ let nextSaveKeepalive = false;
28
34
 
29
35
  // Listeners for "the host took these exact bytes". This is the only place in the
30
36
  // library that knows both the body that went out and that the host accepted it, and
@@ -37,6 +43,26 @@ const SAVE_TIMEOUT_MS = 12000;
37
43
  // CustomEvent would put it on a bus any script on the page can listen to.
38
44
  const saveAcceptedHooks = [];
39
45
 
46
+ /**
47
+ * Send the next save with `keepalive`, so a request whose page is going away (a tab
48
+ * closing, a navigation) is still finished by the transport.
49
+ *
50
+ * A transport hint and nothing more: the save is the same save, and the body has to
51
+ * fit the browser's keepalive cap for the hint to go on the wire at all.
52
+ *
53
+ * @param {boolean} keepalive
54
+ * @param {Function} run - the save to run with the hint set
55
+ * @returns {Promise<Object>} whatever `run` resolves with
56
+ */
57
+ export async function withKeepalive(keepalive, run) {
58
+ nextSaveKeepalive = Boolean(keepalive);
59
+ try {
60
+ return await run();
61
+ } finally {
62
+ nextSaveKeepalive = false;
63
+ }
64
+ }
65
+
40
66
  /**
41
67
  * Check if a save is currently in progress.
42
68
  * @returns {boolean}
@@ -258,9 +284,10 @@ function skippedResult(msg) {
258
284
  * @param {AbortSignal} signal
259
285
  * @param {string} saveId - this attempt's §6 receipt id
260
286
  * @param {?string} etag - the stamp to send as If-Match, or null
287
+ * @param {boolean} keepalive - whether to ask the transport to see the request through
261
288
  * @returns {{url: string, options: Object}}
262
289
  */
263
- function buildSaveRequest(content, userDriven, signal, saveId, etag) {
290
+ function buildSaveRequest(content, userDriven, signal, saveId, etag, keepalive = false) {
264
291
  const token = saveToken();
265
292
  const path = token ? `${SAVE_PATH}/${token}` : SAVE_PATH;
266
293
  const options = {
@@ -287,6 +314,8 @@ function buildSaveRequest(content, userDriven, signal, saveId, etag) {
287
314
 
288
315
  if (etag) options.headers['If-Match'] = etag;
289
316
 
317
+ if (keepalive && new Blob([content]).size < KEEPALIVE_MAX_BODY) options.keepalive = true;
318
+
290
319
  return { url: resolveSaveUrl(path), options };
291
320
  }
292
321
 
@@ -357,7 +386,11 @@ function sendOnce(content, saveId, etag) {
357
386
  const gestureDriven = consumeUserDriven();
358
387
  const explicitlyAsked = consumeExplicitSave();
359
388
  const userDriven = gestureDriven || explicitlyAsked;
360
- const { url, options } = buildSaveRequest(content, userDriven, controller.signal, saveId, etag);
389
+ // Read-and-reset here too, at the one send that can still honour it: a save that
390
+ // returns early never reaches this point, so the hint cannot leak onto a later one.
391
+ const keepalive = nextSaveKeepalive;
392
+ nextSaveKeepalive = false;
393
+ const { url, options } = buildSaveRequest(content, userDriven, controller.signal, saveId, etag, keepalive);
361
394
  rememberSentId(saveId);
362
395
 
363
396
  return fetch(url, options)
@@ -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 };