@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.
- package/README.md +5 -3
- package/THIRD-PARTY-NOTICES.md +6 -0
- package/dist/clay.standalone.js +14965 -10714
- 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-feedback.js +6 -0
- package/src/core/save.js +53 -1
- package/src/core/snapshot.js +21 -0
- package/src/core/stale-host-notice.js +21 -24
- package/src/core/unsaved-warning.js +35 -10
- package/src/lib/content-dom.js +4 -2
- package/src/lib/dirty-gate.js +9 -0
- package/src/lib/unsaved-state.js +55 -0
- package/src/loader-logic.js +26 -15
- package/src/loader.js +4 -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";
|
|
@@ -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.
|
package/src/core/snapshot.js
CHANGED
|
@@ -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
|
+
}
|