hyperclayjs 1.3.1 → 1.5.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 (44) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +79 -45
  3. package/communication/behaviorCollector.js +7 -4
  4. package/communication/sendMessage.js +7 -4
  5. package/communication/uploadFile.js +8 -5
  6. package/core/autosave.js +7 -51
  7. package/core/editmodeSystem.js +8 -5
  8. package/core/enablePersistentFormInputValues.js +7 -4
  9. package/core/exportToWindow.js +14 -0
  10. package/core/optionVisibilityRuleGenerator.js +8 -5
  11. package/core/savePage.js +136 -26
  12. package/core/savePageCore.js +25 -9
  13. package/core/saveToast.js +37 -0
  14. package/custom-attributes/domHelpers.js +7 -4
  15. package/custom-attributes/onaftersave.js +41 -0
  16. package/custom-attributes/sortable.js +23 -16
  17. package/dom-utilities/All.js +9 -6
  18. package/dom-utilities/getDataFromForm.js +8 -5
  19. package/dom-utilities/insertStyleTag.js +8 -5
  20. package/dom-utilities/onDomReady.js +7 -4
  21. package/dom-utilities/onLoad.js +7 -4
  22. package/hyperclay.js +96 -31
  23. package/module-dependency-graph.json +135 -136
  24. package/package.json +1 -1
  25. package/string-utilities/copy-to-clipboard.js +7 -4
  26. package/string-utilities/query.js +8 -5
  27. package/string-utilities/slugify.js +8 -5
  28. package/ui/prompts.js +49 -31
  29. package/ui/theModal.js +50 -6
  30. package/ui/toast-hyperclay.js +27 -11
  31. package/ui/toast.js +82 -92
  32. package/utilities/cookie.js +8 -5
  33. package/utilities/debounce.js +7 -4
  34. package/utilities/loadVendorScript.js +57 -0
  35. package/utilities/mutation.js +9 -6
  36. package/utilities/nearest.js +7 -4
  37. package/utilities/throttle.js +7 -4
  38. package/vendor/Sortable.vendor.js +2 -0
  39. package/vendor/idiomorph.min.js +8 -5
  40. package/vendor/tailwind-play.js +16 -162
  41. package/vendor/tailwind-play.vendor.js +169 -0
  42. package/string-utilities/emmet-html.js +0 -60
  43. package/ui/info.js +0 -47
  44. package/vendor/Sortable.js +0 -3351
package/core/savePage.js CHANGED
@@ -1,27 +1,89 @@
1
1
  /**
2
2
  * Save system for Hyperclay
3
3
  *
4
- * Manual save with change detection, toast notifications,
4
+ * Manual save with change detection, state management,
5
5
  * keyboard shortcuts, and save button support.
6
6
  *
7
7
  * For auto-save on DOM changes, also load the 'autosave' module.
8
+ * For toast notifications, also load the 'save-toast' module.
8
9
  *
9
10
  * Built on top of savePageCore.js
10
11
  */
11
12
 
12
- import toast from "../ui/toast.js";
13
+ import throttle from "../utilities/throttle.js";
13
14
  import { isEditMode, isOwner } from "./isAdminOfCurrentResource.js";
14
15
  import {
15
16
  savePage as savePageCore,
16
17
  getPageContents,
17
- replacePageWith as replacePageWithCore
18
+ replacePageWith as replacePageWithCore,
19
+ beforeSave
18
20
  } from "./savePageCore.js";
19
21
 
20
- // Re-export beforeSave from core for backward compatibility
21
- export { beforeSave } from "./savePageCore.js";
22
+ // ============================================
23
+ // SAVE STATE MANAGEMENT
24
+ // ============================================
22
25
 
23
- // Re-export getPageContents for autosave module
24
- export { getPageContents } from "./savePageCore.js";
26
+ let savingTimeout = null;
27
+
28
+ /**
29
+ * Sets the save status on <html> and dispatches an event.
30
+ *
31
+ * @param {string} state - One of: 'saving', 'saved', 'offline', 'error'
32
+ * @param {string} msg - Optional message (e.g., error details)
33
+ */
34
+ function setSaveState(state, msg = '') {
35
+ if (savingTimeout) {
36
+ clearTimeout(savingTimeout);
37
+ savingTimeout = null;
38
+ }
39
+
40
+ document.documentElement.setAttribute('savestatus', state);
41
+
42
+ const event = new CustomEvent(`hyperclay:save-${state}`, {
43
+ detail: { msg, timestamp: Date.now() }
44
+ });
45
+ document.dispatchEvent(event);
46
+ }
47
+
48
+ /**
49
+ * Sets DOM state to 'offline' immediately, but does NOT fire an event.
50
+ * Used for instant UI feedback before we know the final state.
51
+ */
52
+ function setOfflineStateQuiet() {
53
+ if (savingTimeout) {
54
+ clearTimeout(savingTimeout);
55
+ savingTimeout = null;
56
+ }
57
+ document.documentElement.setAttribute('savestatus', 'offline');
58
+ }
59
+
60
+ /**
61
+ * Starts a debounced 'saving' state.
62
+ * Only shows 'saving' if the save takes longer than 500ms.
63
+ * This prevents UI flicker on fast saves.
64
+ */
65
+ function setSavingState() {
66
+ savingTimeout = setTimeout(() => {
67
+ setSaveState('saving');
68
+ }, 500);
69
+ }
70
+
71
+ // ============================================
72
+ // OFFLINE DETECTION
73
+ // ============================================
74
+
75
+ window.addEventListener('offline', () => {
76
+ setOfflineStateQuiet();
77
+ });
78
+
79
+ window.addEventListener('online', () => {
80
+ if (document.documentElement.getAttribute('savestatus') === 'offline') {
81
+ savePage();
82
+ }
83
+ });
84
+
85
+ // Re-export from core for backward compatibility
86
+ export { beforeSave, getPageContents };
25
87
 
26
88
  let unsavedChanges = false;
27
89
  let lastSavedContents = '';
@@ -42,7 +104,7 @@ document.addEventListener('DOMContentLoaded', () => {
42
104
  });
43
105
 
44
106
  /**
45
- * Save the current page with change detection and toast notifications
107
+ * Save the current page with change detection and state management
46
108
  *
47
109
  * @param {Function} callback - Optional callback for custom handling
48
110
  */
@@ -51,6 +113,13 @@ export function savePage(callback = () => {}) {
51
113
  return;
52
114
  }
53
115
 
116
+ // Check if offline - set DOM state immediately for UI feedback
117
+ // but still try the fetch (navigator.onLine can be wrong)
118
+ const wasOffline = !navigator.onLine;
119
+ if (wasOffline) {
120
+ setOfflineStateQuiet();
121
+ }
122
+
54
123
  const currentContents = getPageContents();
55
124
 
56
125
  // Track whether there are unsaved changes
@@ -61,11 +130,22 @@ export function savePage(callback = () => {}) {
61
130
  return;
62
131
  }
63
132
 
133
+ // Start debounced 'saving' state (only shows if save takes >500ms)
134
+ setSavingState();
135
+
64
136
  savePageCore(({msg, msgType}) => {
65
- // Update tracking on success
66
137
  if (msgType !== 'error') {
138
+ // SUCCESS
67
139
  lastSavedContents = currentContents;
68
140
  unsavedChanges = false;
141
+ setSaveState('saved', msg);
142
+ } else {
143
+ // FAILED - determine if it's offline or server error
144
+ if (!navigator.onLine) {
145
+ setSaveState('offline', msg);
146
+ } else {
147
+ setSaveState('error', msg);
148
+ }
69
149
  }
70
150
 
71
151
  // Call user callback if provided
@@ -77,7 +157,7 @@ export function savePage(callback = () => {}) {
77
157
 
78
158
  /**
79
159
  * Fetch HTML from a URL and save it, then reload
80
- * Shows toast notifications
160
+ * Emits error event if save fails
81
161
  *
82
162
  * @param {string} url - URL to fetch from
83
163
  */
@@ -88,8 +168,8 @@ export function replacePageWith(url) {
88
168
 
89
169
  replacePageWithCore(url, (err, data) => {
90
170
  if (err) {
91
- // Show error toast if save failed
92
- toast(err.message || "Failed to save template", "error");
171
+ // Emit error event (save-toast will show toast if loaded)
172
+ setSaveState('error', err.message || "Failed to save template");
93
173
  } else {
94
174
  // Only reload if save was successful
95
175
  window.location.reload();
@@ -97,6 +177,39 @@ export function replacePageWith(url) {
97
177
  });
98
178
  }
99
179
 
180
+ // Throttled version of savePage for auto-save
181
+ const throttledSave = throttle(savePage, 1200);
182
+
183
+ // Baseline for autosave comparison
184
+ let baselineContents = '';
185
+
186
+ // Capture baseline after setup mutations settle
187
+ document.addEventListener('DOMContentLoaded', () => {
188
+ if (isEditMode) {
189
+ setTimeout(() => {
190
+ baselineContents = getPageContents();
191
+ }, 1500);
192
+ }
193
+ });
194
+
195
+ /**
196
+ * Save the page with throttling, for use with auto-save
197
+ * Checks both baseline and last saved content to prevent saves from initial setup
198
+ *
199
+ * @param {Function} callback - Optional callback
200
+ */
201
+ export function savePageThrottled(callback = () => {}) {
202
+ if (!isEditMode) return;
203
+
204
+ const currentContents = getPageContents();
205
+ // For autosave: check both that content changed from baseline AND from last save
206
+ // This prevents saves from initial setup mutations
207
+ if (currentContents !== baselineContents && currentContents !== lastSavedContents) {
208
+ unsavedChanges = true;
209
+ throttledSave(callback);
210
+ }
211
+ }
212
+
100
213
  /**
101
214
  * Initialize keyboard shortcut for save (CMD/CTRL+S)
102
215
  */
@@ -106,9 +219,7 @@ export function initSaveKeyboardShortcut() {
106
219
  let metaKeyPressed = isMac ? event.metaKey : event.ctrlKey;
107
220
  if (metaKeyPressed && event.keyCode == 83) {
108
221
  event.preventDefault();
109
- savePage(({msg, msgType} = {}) => {
110
- if (msg) toast(msg, msgType);
111
- });
222
+ savePage();
112
223
  }
113
224
  });
114
225
  }
@@ -120,9 +231,7 @@ export function initSaveKeyboardShortcut() {
120
231
  export function initHyperclaySaveButton() {
121
232
  document.addEventListener("click", event => {
122
233
  if (event.target.closest("[trigger-save]")) {
123
- savePage(({msg, msgType} = {}) => {
124
- if (msg) toast(msg, msgType);
125
- });
234
+ savePage();
126
235
  }
127
236
  });
128
237
  }
@@ -138,16 +247,17 @@ export function init() {
138
247
  initHyperclaySaveButton();
139
248
  }
140
249
 
141
- // Self-export to hyperclay only
142
- window.hyperclay = window.hyperclay || {};
143
- window.hyperclay.savePage = savePage;
144
- window.hyperclay.beforeSave = beforeSave;
145
- window.hyperclay.replacePageWith = replacePageWith;
146
- window.hyperclay.initHyperclaySaveButton = initHyperclaySaveButton;
147
- window.hyperclay.initSaveKeyboardShortcut = initSaveKeyboardShortcut;
250
+ // Auto-export to window unless suppressed by loader
251
+ if (!window.__hyperclayNoAutoExport) {
252
+ window.hyperclay = window.hyperclay || {};
253
+ window.hyperclay.savePage = savePage;
254
+ window.hyperclay.savePageThrottled = savePageThrottled;
255
+ window.hyperclay.beforeSave = beforeSave;
256
+ window.hyperclay.replacePageWith = replacePageWith;
257
+ window.h = window.hyperclay;
258
+ }
148
259
 
149
260
  // Auto-init when module is imported
150
261
  init();
151
262
 
152
- export { savePage, replacePageWith, initSaveKeyboardShortcut, initHyperclaySaveButton, init };
153
263
  export default savePage;
@@ -91,12 +91,18 @@ export function savePage(callback = () => {}) {
91
91
  return;
92
92
  }
93
93
 
94
+ // Add timeout - abort if server doesn't respond within 5 seconds
95
+ const controller = new AbortController();
96
+ const timeoutId = setTimeout(() => controller.abort(), 5000);
97
+
94
98
  fetch(saveEndpoint, {
95
99
  method: 'POST',
96
100
  credentials: 'include',
97
- body: currentContents
101
+ body: currentContents,
102
+ signal: controller.signal
98
103
  })
99
104
  .then(res => {
105
+ clearTimeout(timeoutId);
100
106
  return res.json().then(data => {
101
107
  if (!res.ok) {
102
108
  throw new Error(data.msg || data.error || `HTTP ${res.status}: ${res.statusText}`);
@@ -110,12 +116,19 @@ export function savePage(callback = () => {}) {
110
116
  }
111
117
  })
112
118
  .catch(err => {
119
+ clearTimeout(timeoutId);
113
120
  console.error('Failed to save page:', err);
121
+
122
+ const msg = err.name === 'AbortError'
123
+ ? 'Server not responding'
124
+ : (err.message || 'Failed to save');
125
+
114
126
  if (typeof callback === 'function') {
115
- callback({msg: err.message || "Failed to save", msgType: "error"});
127
+ callback({msg, msgType: "error"});
116
128
  }
117
129
  })
118
130
  .finally(() => {
131
+ clearTimeout(timeoutId);
119
132
  saveInProgress = false;
120
133
  });
121
134
  }
@@ -220,10 +233,13 @@ export function replacePageWith(url, callback = () => {}) {
220
233
  });
221
234
  }
222
235
 
223
- // Self-export to hyperclay only
224
- window.hyperclay = window.hyperclay || {};
225
- window.hyperclay.savePage = savePage;
226
- window.hyperclay.saveHtml = saveHtml;
227
- window.hyperclay.replacePageWith = replacePageWith;
228
- window.hyperclay.beforeSave = beforeSave;
229
- window.hyperclay.getPageContents = getPageContents;
236
+ // Auto-export to window unless suppressed by loader
237
+ if (!window.__hyperclayNoAutoExport) {
238
+ window.hyperclay = window.hyperclay || {};
239
+ window.hyperclay.savePage = savePage;
240
+ window.hyperclay.saveHtml = saveHtml;
241
+ window.hyperclay.replacePageWith = replacePageWith;
242
+ window.hyperclay.beforeSave = beforeSave;
243
+ window.hyperclay.getPageContents = getPageContents;
244
+ window.h = window.hyperclay;
245
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Save Toast Module
3
+ *
4
+ * Listens for save lifecycle events and shows toast notifications.
5
+ * This is opt-in - only included if you want toast notifications.
6
+ *
7
+ * Events handled:
8
+ * - hyperclay:save-saved → success toast
9
+ * - hyperclay:save-error → error toast
10
+ * - hyperclay:save-offline → error toast (treated as error for notifications)
11
+ */
12
+
13
+ import toast from "../ui/toast.js";
14
+ import { isEditMode } from "./isAdminOfCurrentResource.js";
15
+
16
+ function init() {
17
+ if (!isEditMode) return;
18
+
19
+ document.addEventListener('hyperclay:save-saved', (e) => {
20
+ const msg = e.detail?.msg || 'Saved';
21
+ toast(msg, 'success');
22
+ });
23
+
24
+ document.addEventListener('hyperclay:save-error', (e) => {
25
+ const msg = e.detail?.msg || 'Failed to save';
26
+ toast(msg, 'error');
27
+ });
28
+
29
+ document.addEventListener('hyperclay:save-offline', (e) => {
30
+ const msg = e.detail?.msg || 'No internet connection';
31
+ toast(msg, 'error');
32
+ });
33
+ }
34
+
35
+ init();
36
+
37
+ export default init;
@@ -172,10 +172,13 @@ function init () {
172
172
 
173
173
  }
174
174
 
175
- // Self-export to window and hyperclay
176
- window.initCustomAttributes = init;
177
- window.hyperclay = window.hyperclay || {};
178
- window.hyperclay.initCustomAttributes = init;
175
+ // Auto-export to window unless suppressed by loader
176
+ if (!window.__hyperclayNoAutoExport) {
177
+ window.initCustomAttributes = init;
178
+ window.hyperclay = window.hyperclay || {};
179
+ window.hyperclay.initCustomAttributes = init;
180
+ window.h = window.hyperclay;
181
+ }
179
182
 
180
183
  // Auto-init when module is imported
181
184
  init();
@@ -0,0 +1,41 @@
1
+ /**
2
+ * [onaftersave] Custom Attribute
3
+ *
4
+ * Runs inline JavaScript when save status changes.
5
+ * Pairs with the existing [onbeforesave] attribute.
6
+ *
7
+ * Usage:
8
+ * <span onaftersave="this.innerText = event.detail.msg"></span>
9
+ * <div onaftersave="console.log('Status:', event.detail.status)"></div>
10
+ *
11
+ * The event.detail object contains:
12
+ * - status: 'saving' | 'saved' | 'offline' | 'error'
13
+ * - msg: string (e.g., 'Saved' or error message)
14
+ * - timestamp: number (Date.now())
15
+ */
16
+
17
+ function broadcast(e) {
18
+ const status = e.type.replace('hyperclay:save-', '');
19
+ const detail = { ...e.detail, status };
20
+
21
+ document.querySelectorAll('[onaftersave]').forEach(el => {
22
+ try {
23
+ const event = new CustomEvent('aftersave', { detail });
24
+ const handler = new Function('event', el.getAttribute('onaftersave'));
25
+ handler.call(el, event);
26
+ } catch (err) {
27
+ console.error('[onaftersave] Error in handler:', err);
28
+ }
29
+ });
30
+ }
31
+
32
+ function init() {
33
+ document.addEventListener('hyperclay:save-saving', broadcast);
34
+ document.addEventListener('hyperclay:save-saved', broadcast);
35
+ document.addEventListener('hyperclay:save-offline', broadcast);
36
+ document.addEventListener('hyperclay:save-error', broadcast);
37
+ }
38
+
39
+ init();
40
+
41
+ export default init;
@@ -8,12 +8,16 @@
8
8
  - add `onsorted` attribute to execute code when items are sorted
9
9
  - e.g. <ul sortable onsorted="console.log('Items reordered!')"></ul>
10
10
 
11
+ This wrapper conditionally loads the full Sortable.js vendor script (~118KB)
12
+ only when in edit mode. The script is injected with save-ignore so it's
13
+ stripped from the page before saving.
14
+
11
15
  */
12
- import { isEditMode, isOwner } from "../core/isAdminOfCurrentResource.js";
16
+ import { isEditMode } from "../core/isAdminOfCurrentResource.js";
13
17
  import Mutation from "../utilities/mutation.js";
14
- import Sortable from '../vendor/Sortable.js';
18
+ import { loadVendorScript, getVendorUrl } from "../utilities/loadVendorScript.js";
15
19
 
16
- function makeSortable (sortableElem) {
20
+ function makeSortable(sortableElem, Sortable) {
17
21
  let options = {};
18
22
 
19
23
  // Check if Sortable instance already exists
@@ -51,11 +55,23 @@ function makeSortable (sortableElem) {
51
55
  Sortable.create(sortableElem, options);
52
56
  }
53
57
 
54
- function init () {
58
+ async function init() {
55
59
  if (!isEditMode) return;
56
60
 
61
+ // Load the vendor script
62
+ const vendorUrl = getVendorUrl(import.meta.url, '../vendor/Sortable.vendor.js');
63
+ const Sortable = await loadVendorScript(vendorUrl, 'Sortable');
64
+
65
+ // Auto-export to window unless suppressed by loader
66
+ if (!window.__hyperclayNoAutoExport) {
67
+ window.Sortable = Sortable;
68
+ window.hyperclay = window.hyperclay || {};
69
+ window.hyperclay.Sortable = Sortable;
70
+ window.h = window.hyperclay;
71
+ }
72
+
57
73
  // Set up sortable on page load
58
- document.querySelectorAll('[sortable]').forEach(makeSortable);
74
+ document.querySelectorAll('[sortable]').forEach(el => makeSortable(el, Sortable));
59
75
 
60
76
  // Set up listener for dynamically added elements
61
77
  Mutation.onAddElement({
@@ -63,22 +79,13 @@ function init () {
63
79
  debounce: 200
64
80
  }, (changes) => {
65
81
  changes.forEach(({ element }) => {
66
- makeSortable(element);
82
+ makeSortable(element, Sortable);
67
83
  });
68
84
  });
69
-
70
- // ❗️re-initializing sortable on parent elements isn't necessary
71
- // sortable.js handles this automatically
72
- // ❌ onElementAdded(newElem => makeSortable(newElem.closest('[sortable]')))
73
85
  }
74
86
 
75
- // Self-export to window and hyperclay
76
- window.Sortable = Sortable;
77
- window.hyperclay = window.hyperclay || {};
78
- window.hyperclay.Sortable = Sortable;
79
-
80
87
  // Auto-init when module is imported
81
88
  init();
82
89
 
83
- export { init, Sortable };
90
+ export { init };
84
91
  export default init;
@@ -403,9 +403,12 @@ const All = new Proxy(function (selectorOrElements, contextSelector) {
403
403
  // Install default plugins
404
404
  All.use(defaultPlugins);
405
405
 
406
- // Self-export to window and hyperclay
407
- window.All = All;
408
- window.hyperclay = window.hyperclay || {};
409
- window.hyperclay.All = All;
410
-
411
- export default All;
406
+ // Auto-export to window unless suppressed by loader
407
+ if (!window.__hyperclayNoAutoExport) {
408
+ window.All = All;
409
+ window.hyperclay = window.hyperclay || {};
410
+ window.hyperclay.All = All;
411
+ window.h = window.hyperclay;
412
+ }
413
+
414
+ export default All;
@@ -59,9 +59,12 @@ function getDataFromForm(container) {
59
59
  return formData;
60
60
  }
61
61
 
62
- // Self-export to window and hyperclay
63
- window.getDataFromForm = getDataFromForm;
64
- window.hyperclay = window.hyperclay || {};
65
- window.hyperclay.getDataFromForm = getDataFromForm;
62
+ // Auto-export to window unless suppressed by loader
63
+ if (!window.__hyperclayNoAutoExport) {
64
+ window.getDataFromForm = getDataFromForm;
65
+ window.hyperclay = window.hyperclay || {};
66
+ window.hyperclay.getDataFromForm = getDataFromForm;
67
+ window.h = window.hyperclay;
68
+ }
66
69
 
67
- export default getDataFromForm;
70
+ export default getDataFromForm;
@@ -27,9 +27,12 @@ function insertStyleTag(href) {
27
27
  document.head.appendChild(link);
28
28
  }
29
29
 
30
- // Self-export to window and hyperclay
31
- window.insertStyleTag = insertStyleTag;
32
- window.hyperclay = window.hyperclay || {};
33
- window.hyperclay.insertStyleTag = insertStyleTag;
30
+ // Auto-export to window unless suppressed by loader
31
+ if (!window.__hyperclayNoAutoExport) {
32
+ window.insertStyleTag = insertStyleTag;
33
+ window.hyperclay = window.hyperclay || {};
34
+ window.hyperclay.insertStyleTag = insertStyleTag;
35
+ window.h = window.hyperclay;
36
+ }
34
37
 
35
- export default insertStyleTag;
38
+ export default insertStyleTag;
@@ -6,8 +6,11 @@ function onDomReady (callback) {
6
6
  }
7
7
  }
8
8
 
9
- // Self-export to hyperclay only
10
- window.hyperclay = window.hyperclay || {};
11
- window.hyperclay.onDomReady = onDomReady;
9
+ // Auto-export to window unless suppressed by loader
10
+ if (!window.__hyperclayNoAutoExport) {
11
+ window.hyperclay = window.hyperclay || {};
12
+ window.hyperclay.onDomReady = onDomReady;
13
+ window.h = window.hyperclay;
14
+ }
12
15
 
13
- export default onDomReady;
16
+ export default onDomReady;
@@ -6,8 +6,11 @@ function onLoad (callback) {
6
6
  }
7
7
  }
8
8
 
9
- // Self-export to hyperclay only
10
- window.hyperclay = window.hyperclay || {};
11
- window.hyperclay.onLoad = onLoad;
9
+ // Auto-export to window unless suppressed by loader
10
+ if (!window.__hyperclayNoAutoExport) {
11
+ window.hyperclay = window.hyperclay || {};
12
+ window.hyperclay.onLoad = onLoad;
13
+ window.h = window.hyperclay;
14
+ }
12
15
 
13
- export default onLoad;
16
+ export default onLoad;