@panphora/clayjs 1.5.3 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +5 -3
  2. package/THIRD-PARTY-NOTICES.md +6 -0
  3. package/dist/clay.standalone.js +14965 -10714
  4. package/package.json +9 -4
  5. package/src/core/conflict-download.js +91 -0
  6. package/src/core/conflict-notice.js +720 -0
  7. package/src/core/conflict-presentation.js +301 -0
  8. package/src/core/save-conflict-notice.js +2 -192
  9. package/src/core/save-feedback.js +6 -0
  10. package/src/core/save.js +53 -1
  11. package/src/core/snapshot.js +21 -0
  12. package/src/core/stale-host-notice.js +21 -24
  13. package/src/core/unsaved-warning.js +35 -10
  14. package/src/lib/content-dom.js +4 -2
  15. package/src/lib/dirty-gate.js +9 -0
  16. package/src/lib/unsaved-state.js +55 -0
  17. package/src/loader-logic.js +26 -15
  18. package/src/loader.js +4 -3
  19. package/src/plugins/ai-edit.js +489 -179
  20. package/src/plugins/cms.js +12 -0
  21. package/src/plugins/indicator.js +35 -16
  22. package/src/plugins/quickcrop.js +103 -0
  23. package/src/plugins/richclay.js +21 -0
  24. package/src/plugins/source.js +13 -6
  25. package/src/plugins/wire.js +4 -0
  26. package/src/sync/conflict-revert.js +1138 -0
  27. package/src/sync/conflicts.js +159 -0
  28. package/src/sync/live-sync.js +130 -109
  29. package/src/sync/presence.js +42 -36
  30. package/src/sync/section-notice.js +53 -28
  31. package/src/ui/bevel-controls.js +277 -0
  32. package/src/ui/bevel-dialog.js +112 -0
  33. package/src/ui/bevel.js +247 -0
  34. package/src/ui/dialogs.js +63 -86
  35. package/src/ui/index.js +22 -11
  36. package/src/ui/modal.js +80 -387
  37. package/src/ui/skins/cms-toggle.js +15 -0
  38. package/src/ui/skins/cms.js +4 -0
  39. package/src/ui/skins/quickcrop.js +4 -0
  40. package/src/ui/skins/richclay.js +4 -0
  41. package/src/ui/toast.js +116 -401
  42. package/src/ui/vendor-skin.js +65 -0
  43. package/src/vendor/hyper-morph.vendor.js +4 -4
  44. package/src/vendor/quickcrop.vendor.js +14 -8
  45. package/src/vendor/richclay.vendor.js +22 -41
@@ -19,17 +19,16 @@ import { set, make } from "../lib/hostile-css.js";
19
19
  // One line, no choice to make: nothing on this page can fix it, so offering a button
20
20
  // would be a lie. Dismissable, because after you have read it, it is only in the way.
21
21
 
22
- const BG = "var(--clay-notice-bg,#222)";
23
- const INK = "var(--clay-notice-ink,#fff)";
24
- const EDGE = "var(--clay-notice-edge,rgba(255,255,255,.28))";
25
- const FONT = "14px/1.45 system-ui,-apple-system,'Segoe UI',sans-serif";
26
-
27
22
  const MESSAGE =
28
23
  "This page can't be edited: the app serving it is out of date. " +
29
24
  "Update HTML Clay to 1.9.0 or newer.";
30
25
 
31
26
  let root = null;
32
27
 
28
+ // Bevel is fetched only when the warning is actually shown. This module loads on every
29
+ // page, view mode included, and almost none of them ever show it.
30
+ let ui = null;
31
+
33
32
  // A phone keyboard shrinks the visual viewport but leaves fixed elements pinned to the
34
33
  // layout viewport, so a bottom-anchored bar parks itself behind the keyboard. Same fix
35
34
  // as the conflict notice.
@@ -48,32 +47,24 @@ function dismiss() {
48
47
  }
49
48
 
50
49
  function build() {
51
- root = make("div", [
50
+ root = ui.bevelSurface("div", [
52
51
  "position:fixed", "left:50%", "transform:translateX(-50%)",
53
- "z-index:2147483001", "display:flex", "align-items:center", "gap:10px",
54
- "max-width:calc(100vw - 24px)", "flex-wrap:wrap", "justify-content:center",
55
- "padding:9px 12px", "border-radius:10px",
56
- `background:${BG}`, `color:${INK}`, `border:1px solid ${EDGE}`,
57
- "box-shadow:0 6px 24px rgba(0,0,0,.32),0 1px 2px rgba(0,0,0,.24)",
58
- `font:${FONT}`, "text-align:left",
52
+ "z-index:2147483001", "display:flex", "align-items:center", "gap:10px 12px",
53
+ "width:max-content", "max-width:calc(100vw - 24px)", "flex-wrap:wrap",
54
+ "padding:8px 8px 8px 14px", "text-align:left", `color-scheme:${ui.pageScheme()}`,
59
55
  ]);
60
56
  // Three markers, and each is load-bearing on a page that CAN save: this element is
61
57
  // injected, so it is in no document on disk and must never reach one, never wake the
62
58
  // watcher, and never ride out in a snapshot to somebody else's browser.
63
- root.setAttribute("clay", "no-save no-watch no-snapshot");
59
+ root.setAttribute("clay", ui.RUNTIME_ONLY);
64
60
  root.setAttribute("data-clay-stale-host", "");
65
61
  root.setAttribute("role", "alert");
66
62
 
67
- root.append(make("span", ["margin-right:2px"], MESSAGE));
68
-
69
- const close = make("button", [
70
- "all:initial", "box-sizing:border-box", "cursor:pointer", `font:${FONT}`,
71
- "color:" + INK, "opacity:.72", "padding:2px 6px", "border-radius:6px", "flex:none",
72
- ], "Dismiss");
73
- close.type = "button";
63
+ const dot = make("span", ["all:initial", "color-scheme:inherit", "display:inline-block", "flex:none", "width:8px", "height:8px", "border-radius:50%", `background:${ui.TOKENS.ox}`]);
64
+ dot.setAttribute("clay", ui.RUNTIME_ONLY);
65
+ const close = ui.bevelButton("Dismiss", { small: true, variant: "quiet", onClick: dismiss });
74
66
  close.setAttribute("aria-label", "Dismiss this message");
75
- close.addEventListener("click", dismiss);
76
- root.append(close);
67
+ root.append(dot, ui.bevelText("span", [], MESSAGE), close);
77
68
 
78
69
  document.body.appendChild(root);
79
70
  place();
@@ -81,7 +72,7 @@ function build() {
81
72
  window.visualViewport?.addEventListener("scroll", place);
82
73
  }
83
74
 
84
- function init() {
75
+ async function init() {
85
76
  if (!servedStaleToken()) return;
86
77
  // ?editmode=true outranks the stale-host check by design: that is a person at the
87
78
  // keyboard asking for editing on this load, and is-edit-mode.js gives it to them.
@@ -89,7 +80,13 @@ function init() {
89
80
  // told its reader the opposite. The message is only true while editing is actually
90
81
  // off, so it is shown only then.
91
82
  if (isEditMode) return;
83
+ const [controls, bevel] = await Promise.all([import("../ui/bevel-controls.js"), import("../ui/bevel.js")]);
84
+ ui = { ...controls, TOKENS: bevel.TOKENS };
92
85
  build();
93
86
  }
94
87
 
95
- onDomReady(init);
88
+ let shown = Promise.resolve();
89
+ onDomReady(() => { shown = init(); });
90
+
91
+ // Settles once the warning is on the page, or straight away when there is none to show.
92
+ export const whenShown = () => shown;
@@ -19,6 +19,20 @@ import { isEditMode } from "./is-edit-mode.js";
19
19
  import { captureForDirtyCheck } from "./snapshot.js";
20
20
  import { getLastSavedDirty } from "./save.js";
21
21
  import { logUnloadDiffSync, preloadIfEnabled } from "../lib/autosave-debug.js";
22
+ import { hasUnsavedState } from "../lib/unsaved-state.js";
23
+
24
+ // One beforeunload let through, for the reload a person confirmed with two presses
25
+ // in the conflict notice. Nothing else is switched off: the next close still warns.
26
+ let discardPermit = false;
27
+ export function reloadAfterDiscard({ isCurrent, reload }) {
28
+ if (!isCurrent()) return false;
29
+ discardPermit = true;
30
+ // Browsers fire beforeunload for a reload on their own schedule; a permit still
31
+ // unused a second later means the navigation did not happen.
32
+ setTimeout(() => { discardPermit = false; }, 1000);
33
+ reload();
34
+ return true;
35
+ }
22
36
 
23
37
  // Pre-load diff library if debug mode is on (so it's ready for unload)
24
38
  preloadIfEnabled();
@@ -31,20 +45,31 @@ preloadIfEnabled();
31
45
  // the person editing it deserves the warning.
32
46
  window.addEventListener('beforeunload', (event) => {
33
47
  if (!isEditMode) return;
34
- // The demo plugin saves into this browser's own storage, so leaving the page
35
- // loses nothing a prompt could protect.
36
- if (window.clay?.demo) return;
37
48
 
38
- // The DIRTY domain, not the autosave domain. An edit inside a
39
- // no-trigger-autosave region never starts a save by itself, which is exactly
40
- // why closing the tab on one has to warn: nothing else is going to write it.
41
- const currentForCompare = captureForDirtyCheck();
42
- const lastSaved = getLastSavedDirty();
49
+ if (discardPermit) {
50
+ discardPermit = false;
51
+ return;
52
+ }
53
+
54
+ // Work outside the DOM first: it needs no capture, and a capture that throws
55
+ // must not hide it. The demo plugin does not save it either, so it warns there too.
56
+ const held = hasUnsavedState();
43
57
 
44
- if (currentForCompare !== lastSaved) {
58
+ // The demo plugin saves the page's bytes into this browser's own storage, so
59
+ // leaving loses none of them.
60
+ let bytesDiffer = false;
61
+ if (!held && !window.clay?.demo) {
62
+ // The DIRTY domain, not the autosave domain. An edit inside a
63
+ // no-trigger-autosave region never starts a save by itself, which is exactly
64
+ // why closing the tab on one has to warn: nothing else is going to write it.
65
+ const currentForCompare = captureForDirtyCheck();
66
+ const lastSaved = getLastSavedDirty();
67
+ bytesDiffer = currentForCompare !== lastSaved;
45
68
  // Debug: log what's different before showing the warning
46
- logUnloadDiffSync(currentForCompare, lastSaved);
69
+ if (bytesDiffer) logUnloadDiffSync(currentForCompare, lastSaved);
70
+ }
47
71
 
72
+ if (held || bytesDiffer) {
48
73
  event.preventDefault();
49
74
  event.returnValue = '';
50
75
  }
@@ -27,8 +27,10 @@ function matchesWithin(node, selector, boundary, inherit) {
27
27
 
28
28
  function importTree(source, targetDocument, capability, capabilityMatch, exclude, boundary, inherit, maps) {
29
29
  if (source.nodeType === 1 && (
30
- (inherit ? hasCapability(source, capability) : matchesWithin(source, capabilityMatch, boundary, false)) ||
31
- (exclude && matchesWithin(source, exclude, boundary, inherit))
30
+ (inherit
31
+ ? hasCapability(source, capability)
32
+ : matchesWithin(source, capabilityMatch, boundary, false)) ||
33
+ (exclude && (inherit && source !== boundary ? source.matches(exclude) : matchesWithin(source, exclude, boundary, inherit)))
32
34
  )) return null
33
35
  const copy = targetDocument.importNode(source, false)
34
36
  maps.cloneToLive.set(copy, source)
@@ -191,3 +191,12 @@ export function gateClearIfUnchanged(token) {
191
191
  if (typeof document !== 'undefined' && isEditMode) {
192
192
  startDirtyGate();
193
193
  }
194
+
195
+ // Public for editors whose own surface is editor-ui (so neither gate feed sees it):
196
+ // a person's edit made while a live-sync frame waits on a resource would otherwise be
197
+ // recorded as saved. Set here, not in the loader, because the sync plugin is what
198
+ // loads this module.
199
+ if (typeof window !== 'undefined') {
200
+ window.clay = window.clay || {};
201
+ window.clay.markDirty = gateMarkDirty;
202
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * unsaved-state.js: "this tab holds work its saved bytes cannot show".
3
+ *
4
+ * The close warning compares the page's bytes with the last save. Some work is
5
+ * not in those bytes: text a live-sync merge replaced, kept in memory until the
6
+ * person reviews it, or an editor's model ahead of the DOM it writes into. Its
7
+ * owner registers a synchronous check here, and the close warning fires while
8
+ * any check says pending. Checks run fresh each time, so a forgotten state can
9
+ * never outlive what it describes, and a save never clears one.
10
+ */
11
+ const sources = new Map();
12
+ let nextKey = 0;
13
+
14
+ export function registerUnsavedState({ id = '', isPending }) {
15
+ const key = ++nextKey;
16
+ sources.set(key, { id, isPending });
17
+ let disposed = false;
18
+ return {
19
+ changed() {
20
+ if (disposed) return;
21
+ document.dispatchEvent(new CustomEvent('clay:unsaved-state-changed', {
22
+ detail: { pending: pendingIds() },
23
+ }));
24
+ },
25
+ dispose() {
26
+ if (disposed) return;
27
+ disposed = true;
28
+ sources.delete(key);
29
+ },
30
+ };
31
+ }
32
+
33
+ // A check that throws counts as pending: unknown is not clean.
34
+ function isPending(source) {
35
+ try {
36
+ return !!source.isPending();
37
+ } catch (err) {
38
+ console.error(`[clay] unsaved-state check "${source.id}" threw`, err);
39
+ return true;
40
+ }
41
+ }
42
+
43
+ export function pendingIds() {
44
+ return [...sources.values()].filter(isPending).map((s) => s.id);
45
+ }
46
+
47
+ export function hasUnsavedState() {
48
+ for (const source of sources.values()) if (isPending(source)) return true;
49
+ return false;
50
+ }
51
+
52
+ if (typeof window !== 'undefined') {
53
+ window.clay = window.clay || {};
54
+ window.clay.registerUnsavedState = registerUnsavedState;
55
+ }
@@ -16,7 +16,7 @@ export const CORE_WAVES = {
16
16
  // transform registered after that made an untouched page read as unsaved.
17
17
  "core/persist.js", "core/admin-attrs.js",
18
18
  "core/save-core.js", "core/save.js",
19
- "core/save-conflict-notice.js",
19
+ "core/conflict-notice.js",
20
20
  "core/unsaved-warning.js", "core/autosave.js",
21
21
  "attrs/save-freeze.js", "attrs/onaftersave.js", "attrs/refetch-on-save.js",
22
22
  "lib/cache-bust.js",
@@ -24,20 +24,21 @@ export const CORE_WAVES = {
24
24
  };
25
25
 
26
26
  export const PLUGIN_PATHS = {
27
- richclay: { path: "vendor/richclay.vendor.js", editOnly: true, default: true },
27
+ richclay: { path: "plugins/richclay.js", editOnly: true, default: true },
28
28
  indicator: { path: "plugins/indicator.js", editOnly: true, default: false },
29
29
  sync: { path: "sync/live-sync.js", editOnly: false, default: false },
30
30
  sortable: { path: "plugins/sortable.js", editOnly: true, default: false },
31
31
  undo: { path: "plugins/undo.js", editOnly: true, default: false },
32
- cms: { path: "vendor/hypercms.vendor.js", editOnly: false, default: false },
33
- quickcrop: { path: "vendor/quickcrop.vendor.js", editOnly: false, default: false },
32
+ cms: { path: "plugins/cms.js", editOnly: false, default: false },
33
+ quickcrop: { path: "plugins/quickcrop.js", editOnly: false, default: false },
34
34
  // editOnly, because a file picker only ever appears in edit mode: the cms
35
35
  // injects its own editing toggle there and clayjs's edit-mode signal is a
36
36
  // superset of the cms's, so the plugin is present exactly when it can be used.
37
37
  upload: { path: "plugins/upload.js", editOnly: true, default: false },
38
38
  wire: { path: "plugins/wire.js", editOnly: false, default: false },
39
- // Edit mode only: the AI comment box is an editing gesture, and the plugin is
40
- // dormant anyway on any host that does not list a ready `ai-edit` helper.
39
+ // Edit mode only: the AI comment box is an editing gesture. Opt in with
40
+ // `plugins=ai-edit`; it stays dormant on any host that does not list a ready
41
+ // `ai-edit` helper.
41
42
  "ai-edit": { path: "plugins/ai-edit.js", editOnly: true, default: false },
42
43
  demo: { path: "plugins/demo.js", editOnly: false, default: false },
43
44
  // Saves the file's own bytes back instead of a fresh serialization of the DOM.
@@ -68,7 +69,7 @@ export const MODULES = {
68
69
  "core/snapshot.js": () => import("./core/snapshot.js"),
69
70
  "core/save-core.js": () => import("./core/save-core.js"),
70
71
  "core/save.js": () => import("./core/save.js"),
71
- "core/save-conflict-notice.js": () => import("./core/save-conflict-notice.js"),
72
+ "core/conflict-notice.js": () => import("./core/conflict-notice.js"),
72
73
  "core/unsaved-warning.js": () => import("./core/unsaved-warning.js"),
73
74
  "core/persist.js": () => import("./core/persist.js"),
74
75
  "core/admin-attrs.js": () => import("./core/admin-attrs.js"),
@@ -78,13 +79,13 @@ export const MODULES = {
78
79
  "attrs/refetch-on-save.js": () => import("./attrs/refetch-on-save.js"),
79
80
  "lib/cache-bust.js": () => import("./lib/cache-bust.js"),
80
81
  "vendor/hyper-morph.vendor.js": () => import("./vendor/hyper-morph.vendor.js"),
81
- "vendor/richclay.vendor.js": () => import("./vendor/richclay.vendor.js"),
82
+ "plugins/richclay.js": () => import("./plugins/richclay.js"),
82
83
  "plugins/indicator.js": () => import("./plugins/indicator.js"),
83
84
  "sync/live-sync.js": () => import("./sync/live-sync.js"),
84
85
  "plugins/sortable.js": () => import("./plugins/sortable.js"),
85
86
  "plugins/undo.js": () => import("./plugins/undo.js"),
86
- "vendor/hypercms.vendor.js": () => import("./vendor/hypercms.vendor.js"),
87
- "vendor/quickcrop.vendor.js": () => import("./vendor/quickcrop.vendor.js"),
87
+ "plugins/cms.js": () => import("./plugins/cms.js"),
88
+ "plugins/quickcrop.js": () => import("./plugins/quickcrop.js"),
88
89
  "plugins/upload.js": () => import("./plugins/upload.js"),
89
90
  "plugins/wire.js": () => import("./plugins/wire.js"),
90
91
  "plugins/ai-edit.js": () => import("./plugins/ai-edit.js"),
@@ -114,6 +115,8 @@ const PLUGIN_ORDER = ["richclay", "indicator", "sortable", "undo", "quickcrop",
114
115
  // enables. Reverting it is reverting this line.
115
116
  // ai-edit reads `clay.wire.helpers()` and sends through `clay.wire.send`, so a page
116
117
  // that asks for it has to have the wire. The wire loads first in the order above.
118
+ // It does not bring undo: hyper-undo takes Cmd+Z for the whole window, text fields
119
+ // included, which a page that never asked for it should not get by default.
117
120
  const IMPLIES = { cms: ["quickcrop", "upload"], "ai-edit": ["wire"] };
118
121
 
119
122
  function parseCsv(params, key, enabled, apply) {
@@ -134,17 +137,25 @@ export function resolveModules(params, isEditMode) {
134
137
  const core = [...CORE_WAVES.always];
135
138
  if (isEditMode) core.push(...CORE_WAVES.editOnly);
136
139
 
137
- const enabled = new Set();
140
+ const explicit = new Set();
141
+ parseCsv(params, "plugins", explicit, (set, name) => set.add(name));
142
+ const excluded = new Set();
143
+ parseCsv(params, "exclude", excluded, (set, name) => set.add(name));
144
+
145
+ const enabled = new Set(explicit);
138
146
  for (const [name, spec] of Object.entries(PLUGIN_PATHS)) {
139
147
  if (spec.default) enabled.add(name);
140
148
  }
141
- parseCsv(params, "plugins", enabled, (set, name) => set.add(name));
142
- // Between the two: exclude still wins, so `plugins=cms&exclude=quickcrop` opts
143
- // back out of the cropper.
149
+ // ai-edit cannot run without the wire, so excluding the wire excludes it too.
150
+ if (excluded.has("wire")) enabled.delete("ai-edit");
151
+ // A default-on plugin that this mode drops brings nothing with it. An explicitly
152
+ // asked for plugin still brings what it implies.
144
153
  for (const name of [...enabled]) {
154
+ if (excluded.has(name)) continue;
155
+ if (!explicit.has(name) && PLUGIN_PATHS[name].editOnly && !isEditMode) continue;
145
156
  for (const implied of IMPLIES[name] || []) enabled.add(implied);
146
157
  }
147
- parseCsv(params, "exclude", enabled, (set, name) => set.delete(name));
158
+ for (const name of excluded) enabled.delete(name);
148
159
 
149
160
  const plugins = [];
150
161
  for (const name of PLUGIN_ORDER) {
package/src/loader.js CHANGED
@@ -108,7 +108,8 @@ function attachPluginMember(path, mod) {
108
108
  clay.undo = mod.undo || mod.default;
109
109
  } else if (path === "sync/live-sync.js") {
110
110
  clay.morph = mod.morph;
111
- } else if (path === "vendor/hypercms.vendor.js") {
111
+ clay.conflicts = mod.conflicts;
112
+ } else if (path === "plugins/cms.js") {
112
113
  clay.cms = mod.cms || mod.default;
113
114
  } else if (path === "plugins/upload.js") {
114
115
  clay.upload = mod.upload || mod.default;
@@ -118,9 +119,9 @@ function attachPluginMember(path, mod) {
118
119
  clay.aiEdit = mod.aiEdit || mod.default;
119
120
  } else if (path === "plugins/demo.js") {
120
121
  clay.demo = mod.demo;
121
- } else if (path === "vendor/richclay.vendor.js") {
122
+ } else if (path === "plugins/richclay.js") {
122
123
  clay.RichClay = mod.RichClay || mod.default;
123
- } else if (path === "vendor/quickcrop.vendor.js") {
124
+ } else if (path === "plugins/quickcrop.js") {
124
125
  clay.quickcrop = mod.quickcrop || mod.default;
125
126
  } else if (path === "plugins/source.js") {
126
127
  clay.source = mod.source || mod.default;