@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.
- package/README.md +5 -3
- package/THIRD-PARTY-NOTICES.md +6 -0
- package/dist/clay.standalone.js +15041 -10657
- package/package.json +9 -4
- package/src/core/conflict-download.js +91 -0
- package/src/core/conflict-notice.js +720 -0
- package/src/core/conflict-presentation.js +301 -0
- package/src/core/save-conflict-notice.js +2 -192
- package/src/core/save-core.js +35 -2
- package/src/core/save-feedback.js +6 -0
- package/src/core/save.js +157 -3
- package/src/core/snapshot.js +21 -0
- package/src/core/stale-host-notice.js +21 -24
- package/src/core/unsaved-warning.js +64 -16
- package/src/lib/content-dom.js +4 -2
- package/src/lib/dirty-gate.js +37 -1
- package/src/lib/unsaved-state.js +55 -0
- package/src/loader-logic.js +26 -15
- package/src/loader.js +10 -3
- package/src/plugins/ai-edit.js +489 -179
- package/src/plugins/cms.js +12 -0
- package/src/plugins/indicator.js +35 -16
- package/src/plugins/quickcrop.js +103 -0
- package/src/plugins/richclay.js +21 -0
- package/src/plugins/source.js +13 -6
- package/src/plugins/wire.js +4 -0
- package/src/sync/conflict-revert.js +1138 -0
- package/src/sync/conflicts.js +159 -0
- package/src/sync/live-sync.js +130 -109
- package/src/sync/presence.js +42 -36
- package/src/sync/section-notice.js +53 -28
- package/src/ui/bevel-controls.js +277 -0
- package/src/ui/bevel-dialog.js +112 -0
- package/src/ui/bevel.js +247 -0
- package/src/ui/dialogs.js +63 -86
- package/src/ui/index.js +22 -11
- package/src/ui/modal.js +80 -387
- package/src/ui/skins/cms-toggle.js +15 -0
- package/src/ui/skins/cms.js +4 -0
- package/src/ui/skins/quickcrop.js +4 -0
- package/src/ui/skins/richclay.js +4 -0
- package/src/ui/toast.js +116 -401
- package/src/ui/vendor-skin.js +65 -0
- package/src/vendor/hyper-morph.vendor.js +4 -4
- package/src/vendor/quickcrop.vendor.js +14 -8
- 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
|
-
|
|
2
|
-
import
|
|
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";
|
package/src/core/save-core.js
CHANGED
|
@@ -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
|
-
|
|
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 };
|