hyperclayjs 1.8.0 → 1.10.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.
@@ -31,16 +31,15 @@
31
31
  */
32
32
 
33
33
  import Mutation from "../utilities/mutation.js";
34
+ import insertStyles from "../dom-utilities/insertStyleTag.js";
35
+
36
+ const STYLE_NAME = 'option-visibility';
34
37
 
35
38
  const optionVisibility = {
36
39
  debug: false,
37
40
  _started: false,
38
- _styleElement: null,
39
41
  _unsubscribe: null,
40
42
 
41
- LAYER_NAME: 'option-visibility',
42
- STYLE_CLASS: 'option-visibility-layer-styles',
43
-
44
43
  log(...args) {
45
44
  if (this.debug) console.log('[OptionVisibility:Layer]', ...args);
46
45
  },
@@ -104,7 +103,7 @@ const optionVisibility = {
104
103
  return `[option\\:${safeName}="${safeValue}"]{display:none!important}[${safeName}="${safeValue}"] [option\\:${safeName}="${safeValue}"]{display:revert-layer!important}`;
105
104
  }).join('');
106
105
 
107
- return `@layer ${this.LAYER_NAME}{${rules}}`;
106
+ return `@layer ${STYLE_NAME}{${rules}}`;
108
107
  },
109
108
 
110
109
  /**
@@ -119,33 +118,8 @@ const optionVisibility = {
119
118
  try {
120
119
  const attributes = this.findOptionAttributes();
121
120
  const css = this.generateCSS(attributes);
122
-
123
- // Remove style element if no attributes
124
- if (!css) {
125
- if (this._styleElement) {
126
- this._styleElement.remove();
127
- this._styleElement = null;
128
- this.log('Removed empty style element');
129
- }
130
- return;
131
- }
132
-
133
- // Skip if unchanged
134
- if (this._styleElement?.textContent === css) {
135
- this.log('Styles unchanged');
136
- return;
137
- }
138
-
139
- // Create or update
140
- if (!this._styleElement) {
141
- this._styleElement = document.createElement('style');
142
- this._styleElement.className = this.STYLE_CLASS;
143
- document.head.appendChild(this._styleElement);
144
- }
145
-
146
- this._styleElement.textContent = css;
121
+ insertStyles(STYLE_NAME, css);
147
122
  this.log(`Generated ${attributes.length} rules`);
148
-
149
123
  } catch (error) {
150
124
  console.error('[OptionVisibility:Layer] Error generating rules:', error);
151
125
  }
@@ -190,10 +164,8 @@ const optionVisibility = {
190
164
  this._unsubscribe = null;
191
165
  }
192
166
 
193
- if (this._styleElement) {
194
- this._styleElement.remove();
195
- this._styleElement = null;
196
- }
167
+ const style = document.querySelector(`style[data-name="${STYLE_NAME}"]`);
168
+ if (style) style.remove();
197
169
 
198
170
  this.log('Stopped');
199
171
  }
@@ -1,64 +1,63 @@
1
1
  /**
2
- * Core save functionality for Hyperclay
2
+ * savePageCore.js — Network save functionality
3
3
  *
4
- * This is the minimal save system - just the basic save function you can call yourself.
5
- * No toast notifications, no auto-save, no keyboard shortcuts.
4
+ * This module handles sending page contents to the server.
5
+ * It uses snapshot.js for capturing the DOM state.
6
6
  *
7
- * Use this if you want full control over save behavior and notifications.
8
- * For the full save system with conveniences, use savePage.js instead.
7
+ * For full save system with state management, use savePage.js instead.
9
8
  */
10
9
 
11
10
  import cookie from "../utilities/cookie.js";
12
11
  import { isEditMode } from "./isAdminOfCurrentResource.js";
12
+ import {
13
+ captureForSave,
14
+ isCodeMirrorPage,
15
+ getCodeMirrorContents,
16
+ beforeSave,
17
+ getPageContents,
18
+ onSnapshot,
19
+ onPrepareForSave
20
+ } from "./snapshot.js";
21
+
22
+ // =============================================================================
23
+ // STATE
24
+ // =============================================================================
13
25
 
14
- let beforeSaveCallbacks = [];
15
26
  let saveInProgress = false;
16
27
  const saveEndpoint = `/save/${cookie.get("currentResource")}`;
17
28
 
18
- /**
19
- * Register a callback to run before saving
20
- * Callbacks receive the cloned document element
21
- *
22
- * @param {Function} cb - Callback function(docElem)
23
- */
24
- export function beforeSave(cb) {
25
- beforeSaveCallbacks.push(cb);
26
- }
29
+ // =============================================================================
30
+ // RE-EXPORTS FROM SNAPSHOT (for backwards compat)
31
+ // =============================================================================
32
+
33
+ export { beforeSave, getPageContents, onSnapshot, onPrepareForSave };
34
+
35
+ // =============================================================================
36
+ // INTERNAL: GET PAGE CONTENTS
37
+ // =============================================================================
27
38
 
28
39
  /**
29
- * Get the current page contents as HTML
30
- * Handles CodeMirror pages, runs [onbeforesave] attributes, removes [save-ignore] elements
40
+ * Get the current page contents as HTML string for saving.
41
+ * Handles both normal pages and CodeMirror editor pages.
42
+ * Emits snapshot-ready event for live-sync (normal pages only).
31
43
  *
32
44
  * @returns {string} HTML string of current page
33
45
  */
34
- export function getPageContents() {
35
- const isCodeMirrorPage = !!document.querySelector('.CodeMirror')?.CodeMirror;
36
-
37
- if (!isCodeMirrorPage) {
38
- let docElem = document.documentElement.cloneNode(true);
39
-
40
- // Run onbeforesave callbacks
41
- docElem.querySelectorAll('[onbeforesave]').forEach(el =>
42
- new Function(el.getAttribute('onbeforesave')).call(el)
43
- );
44
-
45
- // Remove elements marked save-ignore
46
- docElem.querySelectorAll('[save-ignore]').forEach(el =>
47
- el.remove()
48
- );
49
-
50
- // Run registered beforeSave callbacks
51
- beforeSaveCallbacks.forEach(cb => cb(docElem));
52
-
53
- return "<!DOCTYPE html>" + docElem.outerHTML;
54
- } else {
55
- // For CodeMirror pages, get value from editor
56
- return document.querySelector('.CodeMirror').CodeMirror.getValue();
46
+ function getContentsForSave() {
47
+ if (isCodeMirrorPage()) {
48
+ // CodeMirror pages don't emit snapshot-ready - no live-sync for code editors
49
+ return getCodeMirrorContents();
57
50
  }
51
+ // Emit for live-sync when actually saving
52
+ return captureForSave({ emitForSync: true });
58
53
  }
59
54
 
55
+ // =============================================================================
56
+ // SAVE FUNCTIONS
57
+ // =============================================================================
58
+
60
59
  /**
61
- * Save the current page contents to the server
60
+ * Save the current page contents to the server.
62
61
  *
63
62
  * @param {Function} callback - Called with {msg, msgType} on completion
64
63
  * msgType will be 'success' or 'error'
@@ -82,10 +81,10 @@ export function savePage(callback = () => {}) {
82
81
 
83
82
  let currentContents;
84
83
  try {
85
- currentContents = getPageContents();
84
+ currentContents = getContentsForSave();
86
85
  } catch (err) {
87
- console.error('savePage: getPageContents failed', err);
88
- callback({msg: err.message, msgType: "error"});
86
+ console.error('savePage: getContentsForSave failed', err);
87
+ callback({ msg: err.message, msgType: "error" });
89
88
  return;
90
89
  }
91
90
  saveInProgress = true;
@@ -95,15 +94,15 @@ export function savePage(callback = () => {}) {
95
94
  setTimeout(() => {
96
95
  saveInProgress = false;
97
96
  if (typeof callback === 'function') {
98
- callback({msg: "Test mode: save skipped", msgType: "success"});
97
+ callback({ msg: "Test mode: save skipped", msgType: "success" });
99
98
  }
100
99
  }, 0);
101
100
  return;
102
101
  }
103
102
 
104
- // Add timeout - abort if server doesn't respond within 5 seconds
103
+ // Add timeout - abort if server doesn't respond within 12 seconds
105
104
  const controller = new AbortController();
106
- const timeoutId = setTimeout(() => controller.abort(), 5000);
105
+ const timeoutId = setTimeout(() => controller.abort('Save timeout'), 12000);
107
106
 
108
107
  fetch(saveEndpoint, {
109
108
  method: 'POST',
@@ -111,44 +110,43 @@ export function savePage(callback = () => {}) {
111
110
  body: currentContents,
112
111
  signal: controller.signal
113
112
  })
114
- .then(res => {
115
- clearTimeout(timeoutId);
116
- return res.json().then(data => {
117
- if (!res.ok) {
118
- throw new Error(data.msg || data.error || `HTTP ${res.status}: ${res.statusText}`);
113
+ .then(res => {
114
+ clearTimeout(timeoutId);
115
+ return res.json().then(data => {
116
+ if (!res.ok) {
117
+ throw new Error(data.msg || data.error || `HTTP ${res.status}: ${res.statusText}`);
118
+ }
119
+ return data;
120
+ });
121
+ })
122
+ .then(data => {
123
+ if (typeof callback === 'function') {
124
+ callback({ msg: data.msg, msgType: data.msgType || 'success' });
119
125
  }
120
- return data;
121
- });
122
- })
123
- .then(data => {
124
- if (typeof callback === 'function') {
125
- callback({msg: data.msg, msgType: data.msgType || 'success'});
126
- }
127
- })
128
- .catch(err => {
129
- clearTimeout(timeoutId);
130
- console.error('Failed to save page:', err);
126
+ })
127
+ .catch(err => {
128
+ clearTimeout(timeoutId);
129
+ console.error('Failed to save page:', err);
131
130
 
132
- const msg = err.name === 'AbortError'
133
- ? 'Server not responding'
134
- : 'Save failed';
131
+ const msg = err.name === 'AbortError'
132
+ ? 'Server not responding'
133
+ : 'Save failed';
135
134
 
136
- if (typeof callback === 'function') {
137
- callback({msg, msgType: "error"});
138
- }
139
- })
140
- .finally(() => {
141
- clearTimeout(timeoutId);
142
- saveInProgress = false;
143
- });
135
+ if (typeof callback === 'function') {
136
+ callback({ msg, msgType: "error" });
137
+ }
138
+ })
139
+ .finally(() => {
140
+ clearTimeout(timeoutId);
141
+ saveInProgress = false;
142
+ });
144
143
  }
145
144
 
146
145
  /**
147
- * Save specific HTML content to the server
146
+ * Save specific HTML content to the server.
148
147
  *
149
148
  * @param {string} html - HTML string to save
150
149
  * @param {Function} callback - Called with (err, data) on completion
151
- * err will be null on success, Error object on failure
152
150
  *
153
151
  * @example
154
152
  * saveHtml(myHtml, (err, data) => {
@@ -171,7 +169,7 @@ export function saveHtml(html, callback = () => {}) {
171
169
  setTimeout(() => {
172
170
  saveInProgress = false;
173
171
  if (typeof callback === 'function') {
174
- callback(null, {msg: "Test mode: save skipped", msgType: "success"});
172
+ callback(null, { msg: "Test mode: save skipped", msgType: "success" });
175
173
  }
176
174
  }, 0);
177
175
  return;
@@ -182,32 +180,32 @@ export function saveHtml(html, callback = () => {}) {
182
180
  credentials: 'include',
183
181
  body: html
184
182
  })
185
- .then(res => {
186
- return res.json().then(data => {
187
- if (!res.ok) {
188
- throw new Error(data.msg || data.error || `HTTP ${res.status}: ${res.statusText}`);
183
+ .then(res => {
184
+ return res.json().then(data => {
185
+ if (!res.ok) {
186
+ throw new Error(data.msg || data.error || `HTTP ${res.status}: ${res.statusText}`);
187
+ }
188
+ return data;
189
+ });
190
+ })
191
+ .then(data => {
192
+ if (typeof callback === 'function') {
193
+ callback(null, data);
189
194
  }
190
- return data;
195
+ })
196
+ .catch(err => {
197
+ console.error('Failed to save page:', err);
198
+ if (typeof callback === 'function') {
199
+ callback(err);
200
+ }
201
+ })
202
+ .finally(() => {
203
+ saveInProgress = false;
191
204
  });
192
- })
193
- .then(data => {
194
- if (typeof callback === 'function') {
195
- callback(null, data); // Success: no error
196
- }
197
- })
198
- .catch(err => {
199
- console.error('Failed to save page:', err);
200
- if (typeof callback === 'function') {
201
- callback(err); // Pass error
202
- }
203
- })
204
- .finally(() => {
205
- saveInProgress = false;
206
- });
207
205
  }
208
206
 
209
207
  /**
210
- * Fetch HTML from a URL and save it to replace the current page
208
+ * Fetch HTML from a URL and save it to replace the current page.
211
209
  *
212
210
  * @param {string} url - URL to fetch HTML from
213
211
  * @param {Function} callback - Called with (err, data) on completion
@@ -243,7 +241,10 @@ export function replacePageWith(url, callback = () => {}) {
243
241
  });
244
242
  }
245
243
 
246
- // Auto-export to window unless suppressed by loader
244
+ // =============================================================================
245
+ // WINDOW EXPORTS
246
+ // =============================================================================
247
+
247
248
  if (!window.__hyperclayNoAutoExport) {
248
249
  window.hyperclay = window.hyperclay || {};
249
250
  window.hyperclay.savePage = savePage;
@@ -0,0 +1,203 @@
1
+ /**
2
+ * snapshot.js — The source of truth for page state
3
+ *
4
+ * THE SAVE/SYNC PIPELINE:
5
+ *
6
+ * ┌─────────────────────────────────────────────────────────┐
7
+ * │ 1. CLONE document.documentElement.cloneNode() │
8
+ * └─────────────────────────────────────────────────────────┘
9
+ * │
10
+ * ▼
11
+ * ┌─────────────────────────────────────────────────────────┐
12
+ * │ 2. SNAPSHOT HOOKS onSnapshot callbacks │
13
+ * │ (form value sync) │
14
+ * │ │
15
+ * │ ✓ Used by: SAVE and LIVE-SYNC │
16
+ * └─────────────────────────────────────────────────────────┘
17
+ * │
18
+ * ┌───────────────┴───────────────┐
19
+ * ▼ ▼
20
+ * ┌─────────────────────────┐ ┌─────────────────────────┐
21
+ * │ 3a. PREPARE HOOKS │ │ 3b. DONE │
22
+ * │ onPrepareForSave │ │ (live-sync stops here) │
23
+ * │ [onbeforesave] │ │ │
24
+ * │ [save-ignore] │ │ → emits snapshot-ready │
25
+ * │ │ └─────────────────────────┘
26
+ * │ ✓ Used by: SAVE only │
27
+ * └─────────────────────────┘
28
+ * │
29
+ * ▼
30
+ * ┌─────────────────────────┐
31
+ * │ 4. SERIALIZE │
32
+ * │ "<!DOCTYPE html>" │
33
+ * │ + outerHTML │
34
+ * │ │
35
+ * │ → sent to server │
36
+ * └─────────────────────────┘
37
+ */
38
+
39
+ // =============================================================================
40
+ // HOOK REGISTRIES
41
+ // =============================================================================
42
+
43
+ const snapshotHooks = []; // Phase 2: Always run (form sync)
44
+ const prepareForSaveHooks = []; // Phase 3a: Save only (strip admin)
45
+
46
+ /**
47
+ * Register a hook that runs on EVERY snapshot (save AND sync).
48
+ * Use for: syncing form values to the clone.
49
+ *
50
+ * @param {Function} callback - Receives the cloned document element
51
+ */
52
+ export function onSnapshot(callback) {
53
+ snapshotHooks.push(callback);
54
+ }
55
+
56
+ /**
57
+ * Register a hook that runs ONLY when preparing for save.
58
+ * Use for: stripping admin elements, cleanup.
59
+ *
60
+ * @param {Function} callback - Receives the cloned document element
61
+ */
62
+ export function onPrepareForSave(callback) {
63
+ prepareForSaveHooks.push(callback);
64
+ }
65
+
66
+ // Backwards compat alias
67
+ export const beforeSave = onPrepareForSave;
68
+
69
+ // =============================================================================
70
+ // CAPTURE FUNCTIONS
71
+ // =============================================================================
72
+
73
+ /**
74
+ * PHASE 1-2: Clone the DOM and run snapshot hooks.
75
+ *
76
+ * This is the "canonical" state — form values synced, nothing stripped.
77
+ * Used as the base for both saving and syncing.
78
+ *
79
+ * @returns {HTMLElement} Cloned document element with snapshot hooks applied
80
+ */
81
+ export function captureSnapshot() {
82
+ const clone = document.documentElement.cloneNode(true);
83
+
84
+ for (const hook of snapshotHooks) {
85
+ hook(clone);
86
+ }
87
+
88
+ return clone;
89
+ }
90
+
91
+ /**
92
+ * Prepare an already-captured snapshot for saving.
93
+ * Mutates the clone — only call once per snapshot.
94
+ *
95
+ * @param {HTMLElement} clone - A snapshot from captureSnapshot()
96
+ * @returns {string} Full HTML string ready for server
97
+ */
98
+ function prepareCloneForSave(clone) {
99
+ // Run inline [onbeforesave] handlers
100
+ for (const el of clone.querySelectorAll('[onbeforesave]')) {
101
+ new Function(el.getAttribute('onbeforesave')).call(el);
102
+ }
103
+
104
+ // Remove elements that shouldn't be saved
105
+ for (const el of clone.querySelectorAll('[save-ignore]')) {
106
+ el.remove();
107
+ }
108
+
109
+ // Run registered prepare hooks
110
+ for (const hook of prepareForSaveHooks) {
111
+ hook(clone);
112
+ }
113
+
114
+ return "<!DOCTYPE html>" + clone.outerHTML;
115
+ }
116
+
117
+ /**
118
+ * PHASE 1-4: Full pipeline for saving to server.
119
+ *
120
+ * Captures snapshot, emits for live-sync, then prepares for save.
121
+ * This is the main entry point for the save process.
122
+ *
123
+ * @param {Object} options
124
+ * @param {boolean} options.emitForSync - Whether to emit snapshot-ready event (default: true)
125
+ * @returns {string} Full HTML string ready for server
126
+ */
127
+ export function captureForSave({ emitForSync = true } = {}) {
128
+ const clone = captureSnapshot();
129
+
130
+ // Emit for live-sync before stripping admin elements
131
+ // Sends full cloned documentElement so live-sync can extract head and body
132
+ if (emitForSync) {
133
+ document.dispatchEvent(new CustomEvent('hyperclay:snapshot-ready', {
134
+ detail: { documentElement: clone }
135
+ }));
136
+ }
137
+
138
+ return prepareCloneForSave(clone);
139
+ }
140
+
141
+ /**
142
+ * PHASE 1-2 (body only): For live-sync between admin users.
143
+ *
144
+ * Includes admin elements — no stripping.
145
+ * Note: Prefer listening to 'hyperclay:snapshot-ready' event instead,
146
+ * which reuses the save's clone.
147
+ *
148
+ * @returns {string} Body innerHTML with form values synced
149
+ */
150
+ export function captureBodyForSync() {
151
+ const clone = captureSnapshot();
152
+ return clone.querySelector('body').innerHTML;
153
+ }
154
+
155
+ /**
156
+ * Get page contents for change detection.
157
+ * Does NOT emit snapshot-ready event (safe for comparison).
158
+ *
159
+ * For CodeMirror pages, returns editor content directly.
160
+ * For normal pages, returns full HTML via snapshot pipeline.
161
+ */
162
+ export function getPageContents() {
163
+ if (isCodeMirrorPage()) {
164
+ return getCodeMirrorContents();
165
+ }
166
+ return captureForSave({ emitForSync: false });
167
+ }
168
+
169
+ // =============================================================================
170
+ // CODEMIRROR SUPPORT
171
+ // =============================================================================
172
+
173
+ /**
174
+ * Check if this is a CodeMirror editor page.
175
+ * CodeMirror pages bypass the normal snapshot pipeline.
176
+ */
177
+ export function isCodeMirrorPage() {
178
+ return !!document.querySelector('.CodeMirror')?.CodeMirror;
179
+ }
180
+
181
+ /**
182
+ * Get CodeMirror editor contents.
183
+ * @returns {string} Editor contents
184
+ */
185
+ export function getCodeMirrorContents() {
186
+ return document.querySelector('.CodeMirror').CodeMirror.getValue();
187
+ }
188
+
189
+ // =============================================================================
190
+ // WINDOW EXPORTS
191
+ // =============================================================================
192
+
193
+ if (!window.__hyperclayNoAutoExport) {
194
+ window.hyperclay = window.hyperclay || {};
195
+ window.hyperclay.captureSnapshot = captureSnapshot;
196
+ window.hyperclay.captureForSave = captureForSave;
197
+ window.hyperclay.captureBodyForSync = captureBodyForSync;
198
+ window.hyperclay.onSnapshot = onSnapshot;
199
+ window.hyperclay.onPrepareForSave = onPrepareForSave;
200
+ window.hyperclay.beforeSave = beforeSave;
201
+ window.hyperclay.getPageContents = getPageContents;
202
+ window.h = window.hyperclay;
203
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Unsaved Warning Module
3
+ *
4
+ * Warns users before leaving the page if there are unsaved changes.
5
+ * Self-contained: compares current page content to last saved content on beforeunload.
6
+ *
7
+ * Works independently of autosave - no mutation observer needed during editing,
8
+ * just a single comparison when the user tries to leave.
9
+ *
10
+ * Requires the 'save-system' module (automatically included as dependency).
11
+ */
12
+
13
+ import { isOwner, isEditMode } from "./isAdminOfCurrentResource.js";
14
+ import { getPageContents, getLastSavedContents } from "./savePage.js";
15
+
16
+ window.addEventListener('beforeunload', (event) => {
17
+ if (!isOwner || !isEditMode) return;
18
+
19
+ const currentContents = getPageContents();
20
+ const lastSaved = getLastSavedContents();
21
+
22
+ if (currentContents !== lastSaved) {
23
+ event.preventDefault();
24
+ event.returnValue = '';
25
+ }
26
+ });
@@ -38,16 +38,9 @@ function submitAjax(elem) {
38
38
  }
39
39
  method = (method || 'POST').toUpperCase();
40
40
 
41
- // Get data - for buttons, only use form data if button is inside a form
42
- let data = {};
43
- if (isButton && parentForm) {
44
- // Button inside form: use form data
45
- data = getDataFromForm(parentForm);
46
- } else if (!isButton) {
47
- // It's a form element itself
48
- data = getDataFromForm(elem);
49
- }
50
- // For standalone buttons with no form, data remains empty object
41
+ // Get data - from parent form if button is inside one, otherwise from element itself
42
+ const dataSource = (isButton && parentForm) ? parentForm : elem;
43
+ const data = getDataFromForm(dataSource);
51
44
 
52
45
  fetch(url, {
53
46
  method: method,
@@ -1,16 +1,16 @@
1
1
  /**
2
2
  * [onaftersave] Custom Attribute
3
3
  *
4
- * Runs inline JavaScript when save status changes.
5
- * Pairs with the existing [onbeforesave] attribute.
4
+ * Runs inline JavaScript after a successful save.
5
+ * Only fires on 'hyperclay:save-saved' events (not on error/offline).
6
6
  *
7
7
  * Usage:
8
8
  * <span onaftersave="this.innerText = event.detail.msg"></span>
9
- * <div onaftersave="console.log('Status:', event.detail.status)"></div>
9
+ * <link href="styles.css" onaftersave="cacheBust(this)">
10
10
  *
11
11
  * The event.detail object contains:
12
- * - status: 'saving' | 'saved' | 'offline' | 'error'
13
- * - msg: string (e.g., 'Saved' or error message)
12
+ * - status: 'saved'
13
+ * - msg: string (e.g., 'Saved')
14
14
  * - timestamp: number (Date.now())
15
15
  */
16
16
 
@@ -30,10 +30,7 @@ function broadcast(e) {
30
30
  }
31
31
 
32
32
  function init() {
33
- document.addEventListener('hyperclay:save-saving', broadcast);
34
33
  document.addEventListener('hyperclay:save-saved', broadcast);
35
- document.addEventListener('hyperclay:save-offline', broadcast);
36
- document.addEventListener('hyperclay:save-error', broadcast);
37
34
  }
38
35
 
39
36
  init();