@panphora/clayjs 0.1.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/LICENSE +21 -0
- package/README.md +57 -0
- package/clay.js +21 -0
- package/package.json +27 -0
- package/src/attrs/onaftersave.js +38 -0
- package/src/attrs/refetch-on-save.js +36 -0
- package/src/attrs/save-freeze.js +102 -0
- package/src/core/admin-attrs.js +22 -0
- package/src/core/admin-contenteditable.js +47 -0
- package/src/core/admin-inputs.js +48 -0
- package/src/core/admin-onclick.js +50 -0
- package/src/core/admin-resources.js +49 -0
- package/src/core/autosave.js +61 -0
- package/src/core/edit-mode.js +38 -0
- package/src/core/is-edit-mode.js +30 -0
- package/src/core/persist.js +103 -0
- package/src/core/save-core.js +385 -0
- package/src/core/save.js +475 -0
- package/src/core/snapshot.js +282 -0
- package/src/core/unsaved-warning.js +38 -0
- package/src/lib/autosave-debug.js +223 -0
- package/src/lib/cache-bust.js +12 -0
- package/src/lib/cookie.js +37 -0
- package/src/lib/dom-ready.js +9 -0
- package/src/lib/extension-noise.js +63 -0
- package/src/lib/load-vendor-script.js +57 -0
- package/src/lib/mutation.js +719 -0
- package/src/lib/query.js +3 -0
- package/src/lib/region-policy.js +220 -0
- package/src/lib/throttle.js +41 -0
- package/src/lib/user-gesture.js +126 -0
- package/src/loader-logic.js +62 -0
- package/src/loader.js +123 -0
- package/src/plugins/indicator.js +51 -0
- package/src/plugins/sortable.js +119 -0
- package/src/plugins/undo.js +23 -0
- package/src/sync/live-sync.js +752 -0
- package/src/vendor/Sortable.vendor.js +2 -0
- package/src/vendor/control-serialize.vendor.js +88 -0
- package/src/vendor/hyper-morph.vendor.js +22 -0
- package/src/vendor/hyper-undo.vendor.js +11 -0
- package/src/vendor/hypercms.vendor.js +1751 -0
- package/src/vendor/richclay.vendor.js +456 -0
|
@@ -0,0 +1,282 @@
|
|
|
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-remove] │ │ → 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
|
+
import { stripExtensionNoise } from '../lib/extension-noise.js';
|
|
40
|
+
import { STRIP_FROM_SAVE, STRIP_FROM_COMPARISON, SNAPSHOT_REMOVE_SELECTOR } from '../lib/region-policy.js';
|
|
41
|
+
|
|
42
|
+
// =============================================================================
|
|
43
|
+
// HOOK REGISTRIES
|
|
44
|
+
// =============================================================================
|
|
45
|
+
|
|
46
|
+
const snapshotHooks = []; // Phase 2: Always run (form sync)
|
|
47
|
+
const prepareForSaveHooks = []; // Phase 3a: Save only (strip admin)
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Register a hook that runs on EVERY snapshot (save AND sync).
|
|
51
|
+
* Use for: syncing form values to the clone.
|
|
52
|
+
*
|
|
53
|
+
* @param {Function} callback - Receives the cloned document element
|
|
54
|
+
*/
|
|
55
|
+
export function onSnapshot(callback) {
|
|
56
|
+
snapshotHooks.push(callback);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Register a hook that runs ONLY when preparing for save.
|
|
61
|
+
* Use for: stripping admin elements, cleanup.
|
|
62
|
+
*
|
|
63
|
+
* @param {Function} callback - Receives the cloned document element
|
|
64
|
+
*/
|
|
65
|
+
export function onPrepareForSave(callback) {
|
|
66
|
+
prepareForSaveHooks.push(callback);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Backwards compat alias
|
|
70
|
+
export const beforeSave = onPrepareForSave;
|
|
71
|
+
|
|
72
|
+
// =============================================================================
|
|
73
|
+
// CAPTURE FUNCTIONS
|
|
74
|
+
// =============================================================================
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* PHASE 1-2: Clone the DOM and run snapshot hooks.
|
|
78
|
+
*
|
|
79
|
+
* This is the "canonical" state — form values synced, nothing stripped.
|
|
80
|
+
* Used as the base for both saving and syncing.
|
|
81
|
+
*
|
|
82
|
+
* @returns {HTMLElement} Cloned document element with snapshot hooks applied
|
|
83
|
+
*/
|
|
84
|
+
function clonePreventingOnclone(node) {
|
|
85
|
+
const prev = window.__preventOnclone;
|
|
86
|
+
window.__preventOnclone = true;
|
|
87
|
+
try { return node.cloneNode(true); }
|
|
88
|
+
finally { window.__preventOnclone = prev; }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function captureSnapshot() {
|
|
92
|
+
// Force-close any pending undo idle batch BEFORE cloning the DOM, so the
|
|
93
|
+
// snapshot reflects a clean undo boundary. Without this, a save that fires
|
|
94
|
+
// mid-typing would leave the idle batch open across the save boundary, and
|
|
95
|
+
// Cmd+Z after save would restore to a state earlier than the last save.
|
|
96
|
+
// No-op when undo isn't loaded or no batch is pending.
|
|
97
|
+
if (typeof window !== 'undefined' && window.hyperclay && window.hyperclay.undo && window.hyperclay.undo.flush) {
|
|
98
|
+
window.hyperclay.undo.flush();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const clone = clonePreventingOnclone(document.documentElement);
|
|
102
|
+
|
|
103
|
+
for (const hook of snapshotHooks) {
|
|
104
|
+
hook(clone);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
for (const el of clone.querySelectorAll('[onbeforesnapshot]')) {
|
|
108
|
+
new Function(el.getAttribute('onbeforesnapshot')).call(el);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
for (const el of clone.querySelectorAll(SNAPSHOT_REMOVE_SELECTOR)) {
|
|
112
|
+
el.remove();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Browser-extension noise (password-manager menus, Grammarly overlays, and
|
|
116
|
+
// marker attributes on real inputs) is not page content. Drop it from every
|
|
117
|
+
// snapshot so it never reaches a save, a comparison, or a live-sync broadcast.
|
|
118
|
+
stripExtensionNoise(clone);
|
|
119
|
+
|
|
120
|
+
return clone;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Prepare an already-captured snapshot for saving.
|
|
125
|
+
* Mutates the clone — only call once per snapshot.
|
|
126
|
+
*
|
|
127
|
+
* @param {HTMLElement} clone - A snapshot from captureSnapshot()
|
|
128
|
+
* @returns {string} Full HTML string ready for server
|
|
129
|
+
*/
|
|
130
|
+
function prepareCloneForSave(clone) {
|
|
131
|
+
// Run inline [onbeforesave] handlers
|
|
132
|
+
for (const el of clone.querySelectorAll('[onbeforesave]')) {
|
|
133
|
+
new Function(el.getAttribute('onbeforesave')).call(el);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Remove elements that shouldn't be saved ([no-save] / legacy [save-remove])
|
|
137
|
+
for (const el of clone.querySelectorAll(STRIP_FROM_SAVE)) {
|
|
138
|
+
el.remove();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Run registered prepare hooks ([freeze]/[save-freeze] innerHTML restore lives here)
|
|
142
|
+
for (const hook of prepareForSaveHooks) {
|
|
143
|
+
hook(clone);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return "<!DOCTYPE html>" + clone.outerHTML;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Capture snapshot prepared for dirty/change comparison.
|
|
151
|
+
*
|
|
152
|
+
* Like captureForSave but also strips every region whose autosave-trigger is off
|
|
153
|
+
* (no-trigger-autosave, freeze, no-watch, plus the legacy equivalents), so their
|
|
154
|
+
* churn never marks the page dirty.
|
|
155
|
+
*
|
|
156
|
+
* @returns {string} HTML string with all autosave-off regions stripped
|
|
157
|
+
*/
|
|
158
|
+
export function captureForComparison() {
|
|
159
|
+
const clone = captureSnapshot();
|
|
160
|
+
|
|
161
|
+
// Run inline [onbeforesave] handlers
|
|
162
|
+
for (const el of clone.querySelectorAll('[onbeforesave]')) {
|
|
163
|
+
new Function(el.getAttribute('onbeforesave')).call(el);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Strip before hooks (hooks see the "final" state)
|
|
167
|
+
for (const el of clone.querySelectorAll(STRIP_FROM_COMPARISON)) {
|
|
168
|
+
el.remove();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Run registered prepare hooks
|
|
172
|
+
for (const hook of prepareForSaveHooks) {
|
|
173
|
+
hook(clone);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return "<!DOCTYPE html>" + clone.outerHTML;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Single-capture function for both saving and comparison.
|
|
181
|
+
*
|
|
182
|
+
* Clones the DOM once, then clones that clone for comparison.
|
|
183
|
+
* More efficient than calling captureForSave() and captureForComparison() separately.
|
|
184
|
+
*
|
|
185
|
+
* @param {Object} options
|
|
186
|
+
* @param {boolean} options.emitForSync - Whether to emit snapshot-ready event (default: true)
|
|
187
|
+
* @returns {{ forSave: string, forComparison: string }}
|
|
188
|
+
*/
|
|
189
|
+
export function captureForSaveAndComparison({ emitForSync = true } = {}) {
|
|
190
|
+
const clone = captureSnapshot();
|
|
191
|
+
|
|
192
|
+
// Emit for live-sync before any stripping
|
|
193
|
+
if (emitForSync) {
|
|
194
|
+
document.dispatchEvent(new CustomEvent('clay:snapshot-ready', {
|
|
195
|
+
detail: { documentElement: clone }
|
|
196
|
+
}));
|
|
197
|
+
|
|
198
|
+
// Store snapshot HTML for Hyperclay Local platform sync
|
|
199
|
+
// This allows the save system to send both stripped and full versions
|
|
200
|
+
const isHyperclayLocal = window.location.hostname === 'localhost' ||
|
|
201
|
+
window.location.hostname === '127.0.0.1';
|
|
202
|
+
if (isHyperclayLocal) {
|
|
203
|
+
window.__hyperclaySnapshotHtml = '<!DOCTYPE html>' + clone.outerHTML;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Run inline [onbeforesave] handlers
|
|
208
|
+
for (const el of clone.querySelectorAll('[onbeforesave]')) {
|
|
209
|
+
new Function(el.getAttribute('onbeforesave')).call(el);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Clone for comparison before stripping (cheaper than cloning live DOM)
|
|
213
|
+
const compareClone = clonePreventingOnclone(clone);
|
|
214
|
+
|
|
215
|
+
// Save clone: strip [no-save]/[save-remove], then run hooks (freeze restore in hooks)
|
|
216
|
+
for (const el of clone.querySelectorAll(STRIP_FROM_SAVE)) {
|
|
217
|
+
el.remove();
|
|
218
|
+
}
|
|
219
|
+
for (const hook of prepareForSaveHooks) {
|
|
220
|
+
hook(clone);
|
|
221
|
+
}
|
|
222
|
+
const forSave = "<!DOCTYPE html>" + clone.outerHTML;
|
|
223
|
+
|
|
224
|
+
// Compare clone: strip every autosave-off region, then run hooks
|
|
225
|
+
for (const el of compareClone.querySelectorAll(STRIP_FROM_COMPARISON)) {
|
|
226
|
+
el.remove();
|
|
227
|
+
}
|
|
228
|
+
for (const hook of prepareForSaveHooks) {
|
|
229
|
+
hook(compareClone);
|
|
230
|
+
}
|
|
231
|
+
const forComparison = "<!DOCTYPE html>" + compareClone.outerHTML;
|
|
232
|
+
|
|
233
|
+
return { forSave, forComparison };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* PHASE 1-4: Full pipeline for saving to server.
|
|
238
|
+
*
|
|
239
|
+
* Captures snapshot, emits for live-sync, then prepares for save.
|
|
240
|
+
* This is the main entry point for the save process.
|
|
241
|
+
*
|
|
242
|
+
* @param {Object} options
|
|
243
|
+
* @param {boolean} options.emitForSync - Whether to emit snapshot-ready event (default: true)
|
|
244
|
+
* @returns {string} Full HTML string ready for server
|
|
245
|
+
*/
|
|
246
|
+
export function captureForSave({ emitForSync = true } = {}) {
|
|
247
|
+
const clone = captureSnapshot();
|
|
248
|
+
|
|
249
|
+
// Emit for live-sync before stripping admin elements
|
|
250
|
+
// Sends full cloned documentElement so live-sync can extract head and body
|
|
251
|
+
if (emitForSync) {
|
|
252
|
+
document.dispatchEvent(new CustomEvent('clay:snapshot-ready', {
|
|
253
|
+
detail: { documentElement: clone }
|
|
254
|
+
}));
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return prepareCloneForSave(clone);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* PHASE 1-2 (body only): For live-sync between admin users.
|
|
262
|
+
*
|
|
263
|
+
* Includes admin elements — no stripping.
|
|
264
|
+
* Note: Prefer listening to 'clay:snapshot-ready' event instead,
|
|
265
|
+
* which reuses the save's clone.
|
|
266
|
+
*
|
|
267
|
+
* @returns {string} Body innerHTML with form values synced
|
|
268
|
+
*/
|
|
269
|
+
export function captureBodyForSync() {
|
|
270
|
+
const clone = captureSnapshot();
|
|
271
|
+
return clone.querySelector('body').innerHTML;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Get page contents for change detection.
|
|
276
|
+
* Does NOT emit snapshot-ready event (safe for comparison).
|
|
277
|
+
*
|
|
278
|
+
* Returns full HTML via snapshot pipeline.
|
|
279
|
+
*/
|
|
280
|
+
export function getPageContents() {
|
|
281
|
+
return captureForSave({ emitForSync: false });
|
|
282
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
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
|
+
* Both current and stored content have [save-remove] and [save-ignore] stripped,
|
|
11
|
+
* so comparison is direct with no parsing needed.
|
|
12
|
+
*
|
|
13
|
+
* Requires the 'save-system' module (automatically included as dependency).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { isOwner, isEditMode } from "./is-edit-mode.js";
|
|
17
|
+
import { captureForComparison } from "./snapshot.js";
|
|
18
|
+
import { getLastSavedContents } from "./save.js";
|
|
19
|
+
import { logUnloadDiffSync, preloadIfEnabled } from "../lib/autosave-debug.js";
|
|
20
|
+
|
|
21
|
+
// Pre-load diff library if debug mode is on (so it's ready for unload)
|
|
22
|
+
preloadIfEnabled();
|
|
23
|
+
|
|
24
|
+
window.addEventListener('beforeunload', (event) => {
|
|
25
|
+
if (!isOwner || !isEditMode) return;
|
|
26
|
+
|
|
27
|
+
// Compare directly - both are already stripped
|
|
28
|
+
const currentForCompare = captureForComparison();
|
|
29
|
+
const lastSaved = getLastSavedContents();
|
|
30
|
+
|
|
31
|
+
if (currentForCompare !== lastSaved) {
|
|
32
|
+
// Debug: log what's different before showing the warning
|
|
33
|
+
logUnloadDiffSync(currentForCompare, lastSaved);
|
|
34
|
+
|
|
35
|
+
event.preventDefault();
|
|
36
|
+
event.returnValue = '';
|
|
37
|
+
}
|
|
38
|
+
});
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Autosave Debug Utility
|
|
3
|
+
*
|
|
4
|
+
* Provides conditional logging for the autosave system.
|
|
5
|
+
* Enable by setting localStorage.setItem('clay:debug:autosave', 'true')
|
|
6
|
+
*
|
|
7
|
+
* When enabled, dynamically imports a diff library to show exactly
|
|
8
|
+
* what changed between DOM states.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const DEBUG_KEY = 'clay:debug:autosave';
|
|
12
|
+
|
|
13
|
+
let diffModule = null;
|
|
14
|
+
let diffLoadPromise = null;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Check if autosave debug mode is enabled
|
|
18
|
+
*/
|
|
19
|
+
export function isDebugEnabled() {
|
|
20
|
+
try {
|
|
21
|
+
return localStorage.getItem(DEBUG_KEY) === 'true';
|
|
22
|
+
} catch {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Lazily load the diff library only when needed
|
|
29
|
+
* Uses esm.sh for zero-install dynamic import
|
|
30
|
+
*/
|
|
31
|
+
async function loadDiff() {
|
|
32
|
+
if (diffModule) return diffModule;
|
|
33
|
+
if (diffLoadPromise) return diffLoadPromise;
|
|
34
|
+
|
|
35
|
+
diffLoadPromise = import('https://esm.sh/diff@5.2.0')
|
|
36
|
+
.then(mod => {
|
|
37
|
+
diffModule = mod;
|
|
38
|
+
return mod;
|
|
39
|
+
})
|
|
40
|
+
.catch(err => {
|
|
41
|
+
console.warn('[autosave-debug] Failed to load diff library:', err);
|
|
42
|
+
return null;
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
return diffLoadPromise;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Log a brief one-liner for save operations
|
|
50
|
+
*/
|
|
51
|
+
export function logSaveCheck(label, matches) {
|
|
52
|
+
if (!isDebugEnabled()) return;
|
|
53
|
+
|
|
54
|
+
const status = matches ? '✓ matches' : '✗ differs';
|
|
55
|
+
console.log(`[autosave] ${label}: ${status}`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Log when baseline is captured/updated
|
|
60
|
+
*/
|
|
61
|
+
export function logBaseline(event, details = '') {
|
|
62
|
+
if (!isDebugEnabled()) return;
|
|
63
|
+
|
|
64
|
+
console.log(`[autosave] baseline ${event}${details ? `: ${details}` : ''}`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Log and diff two HTML strings (async, for unload warning)
|
|
69
|
+
* Returns a promise that resolves when logging is complete
|
|
70
|
+
*/
|
|
71
|
+
export async function logUnloadDiff(currentContents, lastSaved) {
|
|
72
|
+
if (!isDebugEnabled()) return;
|
|
73
|
+
|
|
74
|
+
console.group('[autosave] Unload warning triggered - content differs from last save');
|
|
75
|
+
console.log('Current length:', currentContents.length);
|
|
76
|
+
console.log('Last saved length:', lastSaved.length);
|
|
77
|
+
console.log('Difference:', currentContents.length - lastSaved.length, 'characters');
|
|
78
|
+
|
|
79
|
+
const diff = await loadDiff();
|
|
80
|
+
|
|
81
|
+
if (diff) {
|
|
82
|
+
const changes = diff.diffLines(lastSaved, currentContents);
|
|
83
|
+
|
|
84
|
+
console.log('\n--- DIFF (last saved → current) ---');
|
|
85
|
+
|
|
86
|
+
let hasChanges = false;
|
|
87
|
+
changes.forEach(part => {
|
|
88
|
+
if (part.added || part.removed) {
|
|
89
|
+
hasChanges = true;
|
|
90
|
+
const prefix = part.added ? '+++ ' : '--- ';
|
|
91
|
+
const lines = part.value.split('\n').filter(l => l.length > 0);
|
|
92
|
+
|
|
93
|
+
// Truncate very long diffs
|
|
94
|
+
const maxLines = 20;
|
|
95
|
+
const displayLines = lines.slice(0, maxLines);
|
|
96
|
+
|
|
97
|
+
displayLines.forEach(line => {
|
|
98
|
+
// Truncate very long lines
|
|
99
|
+
const displayLine = line.length > 200
|
|
100
|
+
? line.substring(0, 200) + '...[truncated]'
|
|
101
|
+
: line;
|
|
102
|
+
console.log(prefix + displayLine);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
if (lines.length > maxLines) {
|
|
106
|
+
console.log(`... and ${lines.length - maxLines} more lines`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
if (!hasChanges) {
|
|
112
|
+
console.log('(No line-level differences found - may be whitespace or inline changes)');
|
|
113
|
+
// Fall back to character diff for small differences
|
|
114
|
+
if (Math.abs(currentContents.length - lastSaved.length) < 500) {
|
|
115
|
+
const charChanges = diff.diffChars(lastSaved, currentContents);
|
|
116
|
+
console.log('\n--- CHARACTER DIFF ---');
|
|
117
|
+
charChanges.forEach(part => {
|
|
118
|
+
if (part.added || part.removed) {
|
|
119
|
+
const prefix = part.added ? '+++ ' : '--- ';
|
|
120
|
+
const value = part.value.length > 100
|
|
121
|
+
? part.value.substring(0, 100) + '...[truncated]'
|
|
122
|
+
: part.value;
|
|
123
|
+
console.log(prefix + JSON.stringify(value));
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
} else {
|
|
129
|
+
console.log('(diff library not available - showing raw comparison)');
|
|
130
|
+
console.log('First 500 chars of current:', currentContents.substring(0, 500));
|
|
131
|
+
console.log('First 500 chars of last saved:', lastSaved.substring(0, 500));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
console.groupEnd();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Synchronous version for beforeunload (can't await in event handler)
|
|
139
|
+
* Logs what it can immediately, diff loads async but may not complete before unload
|
|
140
|
+
*/
|
|
141
|
+
export function logUnloadDiffSync(currentContents, lastSaved) {
|
|
142
|
+
if (!isDebugEnabled()) return;
|
|
143
|
+
|
|
144
|
+
console.group('[autosave] Unload warning triggered - content differs from last save');
|
|
145
|
+
console.log('Current length:', currentContents.length);
|
|
146
|
+
console.log('Last saved length:', lastSaved.length);
|
|
147
|
+
console.log('Difference:', currentContents.length - lastSaved.length, 'characters');
|
|
148
|
+
|
|
149
|
+
// If diff is already loaded, use it synchronously
|
|
150
|
+
if (diffModule) {
|
|
151
|
+
const changes = diffModule.diffLines(lastSaved, currentContents);
|
|
152
|
+
|
|
153
|
+
console.log('\n--- DIFF (last saved → current) ---');
|
|
154
|
+
|
|
155
|
+
let hasChanges = false;
|
|
156
|
+
changes.forEach(part => {
|
|
157
|
+
if (part.added || part.removed) {
|
|
158
|
+
hasChanges = true;
|
|
159
|
+
const prefix = part.added ? '+++ ' : '--- ';
|
|
160
|
+
const lines = part.value.split('\n').filter(l => l.length > 0);
|
|
161
|
+
|
|
162
|
+
const maxLines = 20;
|
|
163
|
+
const displayLines = lines.slice(0, maxLines);
|
|
164
|
+
|
|
165
|
+
displayLines.forEach(line => {
|
|
166
|
+
const displayLine = line.length > 200
|
|
167
|
+
? line.substring(0, 200) + '...[truncated]'
|
|
168
|
+
: line;
|
|
169
|
+
console.log(prefix + displayLine);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
if (lines.length > maxLines) {
|
|
173
|
+
console.log(`... and ${lines.length - maxLines} more lines`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
if (!hasChanges) {
|
|
179
|
+
console.log('(No line-level differences - checking character diff)');
|
|
180
|
+
if (Math.abs(currentContents.length - lastSaved.length) < 500) {
|
|
181
|
+
const charChanges = diffModule.diffChars(lastSaved, currentContents);
|
|
182
|
+
charChanges.forEach(part => {
|
|
183
|
+
if (part.added || part.removed) {
|
|
184
|
+
const prefix = part.added ? '+++ ' : '--- ';
|
|
185
|
+
const value = part.value.length > 100
|
|
186
|
+
? part.value.substring(0, 100) + '...[truncated]'
|
|
187
|
+
: part.value;
|
|
188
|
+
console.log(prefix + JSON.stringify(value));
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
} else {
|
|
194
|
+
// Kick off async load for next time, show basic info now
|
|
195
|
+
loadDiff();
|
|
196
|
+
console.log('(diff library loading - run localStorage.setItem("clay:debug:autosave", "true") and reload for full diff)');
|
|
197
|
+
|
|
198
|
+
// Find first difference position
|
|
199
|
+
let diffPos = 0;
|
|
200
|
+
const minLen = Math.min(currentContents.length, lastSaved.length);
|
|
201
|
+
while (diffPos < minLen && currentContents[diffPos] === lastSaved[diffPos]) {
|
|
202
|
+
diffPos++;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
console.log('First difference at position:', diffPos);
|
|
206
|
+
console.log('Context around difference:');
|
|
207
|
+
console.log(' Last saved:', JSON.stringify(lastSaved.substring(Math.max(0, diffPos - 50), diffPos + 100)));
|
|
208
|
+
console.log(' Current:', JSON.stringify(currentContents.substring(Math.max(0, diffPos - 50), diffPos + 100)));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
console.groupEnd();
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Pre-load the diff library if debug is enabled
|
|
216
|
+
* Call this early to ensure diff is ready for unload events
|
|
217
|
+
*/
|
|
218
|
+
export function preloadIfEnabled() {
|
|
219
|
+
if (isDebugEnabled()) {
|
|
220
|
+
loadDiff();
|
|
221
|
+
console.log('[autosave-debug] Debug mode enabled - diff library loading');
|
|
222
|
+
}
|
|
223
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// cacheBust.js
|
|
2
|
+
// Cache-bust an element's href or src attribute by adding/updating a version query param
|
|
3
|
+
|
|
4
|
+
function cacheBust(el) {
|
|
5
|
+
const attr = el.hasAttribute('href') ? 'href' : 'src';
|
|
6
|
+
const currentValue = el.getAttribute(attr);
|
|
7
|
+
const url = new URL(currentValue, location.href);
|
|
8
|
+
url.searchParams.set('v', Date.now());
|
|
9
|
+
el.setAttribute(attr, url.href);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export default cacheBust;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// e.g. Cookie.get("isAdminOfCurrentResource")
|
|
2
|
+
function get (cookieName) {
|
|
3
|
+
const cookies = document.cookie.split('; ');
|
|
4
|
+
const cookie = cookies.find(row => row.startsWith(`${cookieName}=`));
|
|
5
|
+
if (!cookie) return null;
|
|
6
|
+
const cookieValue = cookie.slice(cookieName.length + 1);
|
|
7
|
+
try {
|
|
8
|
+
return JSON.parse(decodeURIComponent(cookieValue));
|
|
9
|
+
} catch (err) {
|
|
10
|
+
return decodeURIComponent(cookieValue);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// e.g. Cookie.remove("isAdminOfCurrentResource")
|
|
15
|
+
function remove(name) {
|
|
16
|
+
// Clear from current path
|
|
17
|
+
document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/;`
|
|
18
|
+
|
|
19
|
+
// Clear from current domain
|
|
20
|
+
document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; domain=${window.location.hostname};`
|
|
21
|
+
|
|
22
|
+
// Clear from apex domain (e.g., .hyperclay.com or .localhyperclay.com)
|
|
23
|
+
const hostname = window.location.hostname;
|
|
24
|
+
if (hostname.includes('.')) {
|
|
25
|
+
// Get the last two parts for the apex domain (handles .com, .co.uk, etc)
|
|
26
|
+
const parts = hostname.split('.');
|
|
27
|
+
const apexDomain = '.' + parts.slice(-2).join('.');
|
|
28
|
+
document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; domain=${apexDomain};`
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const cookie = {
|
|
33
|
+
get,
|
|
34
|
+
remove
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export default cookie;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* extension-noise.js — browser-extension DOM noise the page should never own.
|
|
3
|
+
*
|
|
4
|
+
* Extensions touch the live page in two distinct ways, handled differently so a
|
|
5
|
+
* save never loses real content:
|
|
6
|
+
* 1. Standalone elements they own (password-manager menus, Grammarly overlays,
|
|
7
|
+
* translate banners, extension iframes/scripts) — remove them entirely.
|
|
8
|
+
* 2. Marker attributes they stamp onto YOUR real inputs/forms — strip just the
|
|
9
|
+
* attribute and keep the element.
|
|
10
|
+
*
|
|
11
|
+
* The selectors are not stable public APIs; extend both lists from real captures.
|
|
12
|
+
* Keep this file byte-identical to the copy in the other package (hyperclayjs and
|
|
13
|
+
* hyper-undo each carry one) — their save/undo observers must agree, or one will
|
|
14
|
+
* record/save what the other ignores.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
// Standalone elements browser extensions inject into the page — remove them entirely.
|
|
18
|
+
export const EXTENSION_NODE_SELECTORS = [
|
|
19
|
+
'[src^="chrome-extension://"]', '[href^="chrome-extension://"]',
|
|
20
|
+
'[src^="moz-extension://"]', '[href^="moz-extension://"]',
|
|
21
|
+
'[src^="safari-web-extension://"]', '[href^="safari-web-extension://"]',
|
|
22
|
+
'[id="1p-menu-live-region"]',
|
|
23
|
+
'com-1password-button', 'com-1password-menu', 'com-1password-notification',
|
|
24
|
+
'grammarly-extension', 'grammarly-desktop-integration',
|
|
25
|
+
'lt-div', 'lt-card', 'lt-toolbar', 'lt-highlighter',
|
|
26
|
+
'[data-lastpass-root]', '[data-lastpass-icon-root]', '[data-dashlane-shadowhost]',
|
|
27
|
+
'[data-klarna-extension]', '[class*="klarna-extension"]',
|
|
28
|
+
'.skiptranslate', '#goog-gt-tt', '.goog-te-banner-frame', '.goog-te-balloon-frame',
|
|
29
|
+
'style.darkreader', 'style[data-darkreader-mode]', 'style[data-darkreader-scheme]',
|
|
30
|
+
'#loom-companion-mv3', 'loom-container',
|
|
31
|
+
'[ext-id]',
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
// Attribute-name prefixes extensions stamp onto YOUR real elements — strip the attribute, keep the element.
|
|
35
|
+
export const EXTENSION_ATTR_PREFIXES = [
|
|
36
|
+
'data-grammarly', 'data-lt', 'data-1p', 'data-com-onepassword', 'data-lastpass', 'data-lp', 'data-kwimpala',
|
|
37
|
+
'data-bitwarden', 'data-bw', 'data-dashlane', 'data-np', 'data-protonpass', 'data-proton-pass',
|
|
38
|
+
'data-keeper', 'data-rf', 'data-roboform', 'data-honey', 'data-rakuten', 'data-capital-one',
|
|
39
|
+
'data-wikibuy', 'data-klarna', 'data-darkreader', 'data-evernote', 'data-pocket', 'data-pin',
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
// Pre-joined once so hot paths call matches()/closest()/querySelectorAll() without re-joining.
|
|
43
|
+
export const EXTENSION_NODE_SELECTOR = EXTENSION_NODE_SELECTORS.join(',')
|
|
44
|
+
|
|
45
|
+
// One compiled test for "is this an extension marker attribute". The (?:-|$) boundary means a
|
|
46
|
+
// prefix like `data-lt` matches `data-lt` / `data-lt-foo` but never an app attribute like `data-ltr`.
|
|
47
|
+
export const EXTENSION_ATTR_PATTERN = new RegExp('^(?:' + EXTENSION_ATTR_PREFIXES.join('|') + ')(?:-|$)')
|
|
48
|
+
|
|
49
|
+
// Remove extension-injected elements and strip extension marker attributes from a (cloned) subtree, in place.
|
|
50
|
+
// Guarded so a malformed denylist entry degrades to "noise not stripped" rather than breaking the save.
|
|
51
|
+
export function stripExtensionNoise(root) {
|
|
52
|
+
if (!root || !root.querySelectorAll) return
|
|
53
|
+
try {
|
|
54
|
+
for (const el of root.querySelectorAll(EXTENSION_NODE_SELECTOR)) el.remove()
|
|
55
|
+
for (const el of root.querySelectorAll('*')) {
|
|
56
|
+
for (const attr of [...el.attributes]) {
|
|
57
|
+
if (EXTENSION_ATTR_PATTERN.test(attr.name.toLowerCase())) el.removeAttribute(attr.name)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
} catch (e) {
|
|
61
|
+
if (typeof console !== 'undefined') console.warn('[clay] stripExtensionNoise skipped:', e)
|
|
62
|
+
}
|
|
63
|
+
}
|