hyperclayjs 1.0.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 +360 -0
- package/README.template.md +276 -0
- package/communication/behaviorCollector.js +230 -0
- package/communication/sendMessage.js +48 -0
- package/communication/uploadFile.js +348 -0
- package/core/adminContenteditable.js +36 -0
- package/core/adminInputs.js +58 -0
- package/core/adminOnClick.js +31 -0
- package/core/adminResources.js +33 -0
- package/core/adminSystem.js +15 -0
- package/core/editmode.js +8 -0
- package/core/editmodeSystem.js +18 -0
- package/core/enablePersistentFormInputValues.js +62 -0
- package/core/isAdminOfCurrentResource.js +13 -0
- package/core/optionVisibilityRuleGenerator.js +160 -0
- package/core/savePage.js +196 -0
- package/core/savePageCore.js +236 -0
- package/core/setPageTypeOnDocumentElement.js +23 -0
- package/custom-attributes/ajaxElements.js +94 -0
- package/custom-attributes/autosize.js +17 -0
- package/custom-attributes/domHelpers.js +175 -0
- package/custom-attributes/events.js +15 -0
- package/custom-attributes/inputHelpers.js +11 -0
- package/custom-attributes/onclickaway.js +27 -0
- package/custom-attributes/onclone.js +35 -0
- package/custom-attributes/onpagemutation.js +20 -0
- package/custom-attributes/onrender.js +30 -0
- package/custom-attributes/preventEnter.js +13 -0
- package/custom-attributes/sortable.js +76 -0
- package/dom-utilities/All.js +412 -0
- package/dom-utilities/getDataFromForm.js +60 -0
- package/dom-utilities/insertStyleTag.js +28 -0
- package/dom-utilities/onDomReady.js +7 -0
- package/dom-utilities/onLoad.js +7 -0
- package/hyperclay.js +465 -0
- package/module-dependency-graph.json +612 -0
- package/package.json +95 -0
- package/string-utilities/copy-to-clipboard.js +35 -0
- package/string-utilities/emmet-html.js +54 -0
- package/string-utilities/query.js +1 -0
- package/string-utilities/slugify.js +21 -0
- package/ui/info.js +39 -0
- package/ui/prompts.js +179 -0
- package/ui/theModal.js +677 -0
- package/ui/toast.js +273 -0
- package/utilities/cookie.js +45 -0
- package/utilities/debounce.js +12 -0
- package/utilities/mutation.js +403 -0
- package/utilities/nearest.js +97 -0
- package/utilities/pipe.js +1 -0
- package/utilities/throttle.js +21 -0
- package/vendor/Sortable.js +3351 -0
- package/vendor/idiomorph.min.js +8 -0
- package/vendor/tailwind-base.css +1471 -0
- package/vendor/tailwind-play.js +169 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { isEditMode, isOwner } from "./isAdminOfCurrentResource.js";
|
|
2
|
+
import onDomReady from "../dom-utilities/onDomReady.js";
|
|
3
|
+
import { beforeSave } from "./savePage.js";
|
|
4
|
+
|
|
5
|
+
export function disableAdminInputsBeforeSave() {
|
|
6
|
+
beforeSave(docElem => {
|
|
7
|
+
docElem.querySelectorAll('[edit-mode-input]').forEach(input => {
|
|
8
|
+
if (supportsReadonly(input)) {
|
|
9
|
+
input.setAttribute('readonly', '');
|
|
10
|
+
} else {
|
|
11
|
+
input.setAttribute('disabled', '');
|
|
12
|
+
}
|
|
13
|
+
});
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function enableAdminInputsOnPageLoad() {
|
|
18
|
+
if (!isEditMode) return;
|
|
19
|
+
|
|
20
|
+
onDomReady(() => {
|
|
21
|
+
document.querySelectorAll('[edit-mode-input]').forEach(input => {
|
|
22
|
+
if (supportsReadonly(input)) {
|
|
23
|
+
input.removeAttribute('readonly');
|
|
24
|
+
} else {
|
|
25
|
+
input.removeAttribute('disabled');
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Input types that support the readonly attribute
|
|
32
|
+
const readonlyTypes = ['text', 'search', 'url', 'tel', 'email', 'password', 'date', 'month', 'week', 'time', 'datetime-local', 'number'];
|
|
33
|
+
|
|
34
|
+
function supportsReadonly(element) {
|
|
35
|
+
// Handle different element types
|
|
36
|
+
const tagName = element.tagName?.toUpperCase();
|
|
37
|
+
|
|
38
|
+
// TEXTAREA supports readonly
|
|
39
|
+
if (tagName === 'TEXTAREA') return true;
|
|
40
|
+
|
|
41
|
+
// SELECT, BUTTON, FIELDSET use disabled
|
|
42
|
+
if (tagName === 'SELECT' || tagName === 'BUTTON' || tagName === 'FIELDSET') return false;
|
|
43
|
+
|
|
44
|
+
// For INPUT elements, check the type
|
|
45
|
+
if (tagName === 'INPUT') {
|
|
46
|
+
const type = element.type || 'text';
|
|
47
|
+
return readonlyTypes.includes(type);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Default to disabled for unknown elements
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Auto-initialize
|
|
55
|
+
export function init() {
|
|
56
|
+
disableAdminInputsBeforeSave();
|
|
57
|
+
enableAdminInputsOnPageLoad();
|
|
58
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { isEditMode, isOwner } from "./isAdminOfCurrentResource.js";
|
|
2
|
+
import onDomReady from "../dom-utilities/onDomReady.js";
|
|
3
|
+
import {beforeSave} from "./savePage.js";
|
|
4
|
+
|
|
5
|
+
export function disableOnClickBeforeSave () {
|
|
6
|
+
beforeSave(docElem => {
|
|
7
|
+
docElem.querySelectorAll('[edit-mode-onclick]').forEach(resource => {
|
|
8
|
+
const originalValue = resource.getAttribute("onclick");
|
|
9
|
+
resource.setAttribute("inert-onclick", originalValue);
|
|
10
|
+
resource.removeAttribute("onclick");
|
|
11
|
+
});
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function enableOnClickForAdminOnPageLoad () {
|
|
16
|
+
if (!isEditMode) return;
|
|
17
|
+
|
|
18
|
+
onDomReady(() => {
|
|
19
|
+
document.querySelectorAll('[edit-mode-onclick]').forEach(resource => {
|
|
20
|
+
const originalValue = resource.getAttribute("inert-onclick");
|
|
21
|
+
resource.setAttribute("onclick", originalValue);
|
|
22
|
+
resource.removeAttribute("inert-onclick");
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Auto-initialize
|
|
28
|
+
export function init() {
|
|
29
|
+
disableOnClickBeforeSave();
|
|
30
|
+
enableOnClickForAdminOnPageLoad();
|
|
31
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { isEditMode, isOwner } from "./isAdminOfCurrentResource.js";
|
|
2
|
+
import onDomReady from "../dom-utilities/onDomReady.js";
|
|
3
|
+
import {beforeSave} from "./savePage.js";
|
|
4
|
+
|
|
5
|
+
export function disableAdminResourcesBeforeSave () {
|
|
6
|
+
beforeSave(docElem => {
|
|
7
|
+
docElem.querySelectorAll('[edit-mode-resource]:is(style, link, script)').forEach(resource => {
|
|
8
|
+
const currentType = resource.getAttribute('type') || 'text/javascript';
|
|
9
|
+
// Only add the inert/ prefix if it's not already there
|
|
10
|
+
if (!currentType.startsWith('inert/')) {
|
|
11
|
+
resource.setAttribute('type', `inert/${currentType}`);
|
|
12
|
+
}
|
|
13
|
+
});
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function enableAdminResourcesOnPageLoad () {
|
|
18
|
+
if (!isEditMode) return;
|
|
19
|
+
|
|
20
|
+
onDomReady(() => {
|
|
21
|
+
document.querySelectorAll('[edit-mode-resource]:is(style, link, script)[type^="inert/"]').forEach(resource => {
|
|
22
|
+
// works for js and css
|
|
23
|
+
resource.type = resource.type.replace(/inert\//g, '');
|
|
24
|
+
resource.replaceWith(resource.cloneNode(true));
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Auto-initialize
|
|
30
|
+
export function init() {
|
|
31
|
+
disableAdminResourcesBeforeSave();
|
|
32
|
+
enableAdminResourcesOnPageLoad();
|
|
33
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Admin system - combines all admin features
|
|
2
|
+
import { init as initContenteditable } from './adminContenteditable.js';
|
|
3
|
+
import { init as initInputs } from './adminInputs.js';
|
|
4
|
+
import { init as initOnClick } from './adminOnClick.js';
|
|
5
|
+
import { init as initResources } from './adminResources.js';
|
|
6
|
+
|
|
7
|
+
function init() {
|
|
8
|
+
initContenteditable();
|
|
9
|
+
initInputs();
|
|
10
|
+
initOnClick();
|
|
11
|
+
initResources();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export { init };
|
|
15
|
+
export default init;
|
package/core/editmode.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { isEditMode, isOwner } from "./isAdminOfCurrentResource.js";
|
|
2
|
+
|
|
3
|
+
export function toggleEditMode() {
|
|
4
|
+
const url = new URL(window.location.href);
|
|
5
|
+
const newMode = isEditMode ? "false" : "true";
|
|
6
|
+
url.searchParams.set('editmode', newMode);
|
|
7
|
+
window.location.href = url.toString();
|
|
8
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Edit mode system - combines editmode toggle with page type setting
|
|
2
|
+
import { toggleEditMode } from './editmode.js';
|
|
3
|
+
import { init as initPageType } from './setPageTypeOnDocumentElement.js';
|
|
4
|
+
|
|
5
|
+
function init() {
|
|
6
|
+
initPageType();
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function exportToWindow() {
|
|
10
|
+
if (!window.hyperclay) {
|
|
11
|
+
window.hyperclay = {};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
window.hyperclay.toggleEditMode = toggleEditMode;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export { init, exportToWindow };
|
|
18
|
+
export default { init, exportToWindow };
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { beforeSave } from './savePage.js';
|
|
2
|
+
|
|
3
|
+
// <input type="checkbox" persist>
|
|
4
|
+
export default function enablePersistentFormInputValues(filterBySelector = "[persist]") {
|
|
5
|
+
const selector = `input${filterBySelector}:not([type="password"]):not([type="hidden"]):not([type="file"]), textarea${filterBySelector}, select${filterBySelector}`;
|
|
6
|
+
|
|
7
|
+
document.addEventListener('input', (event) => {
|
|
8
|
+
const elem = event.target;
|
|
9
|
+
if (elem.matches(selector) && !(elem.type === 'checkbox' || elem.type === 'radio')) {
|
|
10
|
+
if (elem.tagName.toLowerCase() === 'textarea') {
|
|
11
|
+
// Store in value attribute instead of textContent to preserve cursor position
|
|
12
|
+
elem.setAttribute('value', elem.value);
|
|
13
|
+
} else {
|
|
14
|
+
elem.setAttribute('value', elem.value);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
document.addEventListener('change', (event) => {
|
|
20
|
+
const elem = event.target;
|
|
21
|
+
if (elem.matches(selector)) {
|
|
22
|
+
// Handle checkboxes and radios
|
|
23
|
+
if (elem.type === 'checkbox' || elem.type === 'radio') {
|
|
24
|
+
if (elem.checked) {
|
|
25
|
+
elem.setAttribute('checked', '');
|
|
26
|
+
} else {
|
|
27
|
+
elem.removeAttribute('checked');
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
// Handle select elements
|
|
31
|
+
else if (elem.tagName.toLowerCase() === 'select') {
|
|
32
|
+
// Remove selected from all options
|
|
33
|
+
const options = elem.querySelectorAll('option');
|
|
34
|
+
options.forEach(option => option.removeAttribute('selected'));
|
|
35
|
+
|
|
36
|
+
// Add selected to currently selected option(s)
|
|
37
|
+
if (elem.multiple) {
|
|
38
|
+
const selectedOptions = elem.selectedOptions;
|
|
39
|
+
Array.from(selectedOptions).forEach(option => {
|
|
40
|
+
option.setAttribute('selected', '');
|
|
41
|
+
});
|
|
42
|
+
} else if (elem.selectedIndex >= 0) {
|
|
43
|
+
options[elem.selectedIndex].setAttribute('selected', '');
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// Before save, transfer value attribute to textContent for textareas
|
|
50
|
+
beforeSave((doc) => {
|
|
51
|
+
const textareas = doc.querySelectorAll('textarea[value]');
|
|
52
|
+
textareas.forEach(textarea => {
|
|
53
|
+
textarea.textContent = textarea.getAttribute('value');
|
|
54
|
+
textarea.removeAttribute('value');
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Auto-initialize with default selector
|
|
60
|
+
export function init() {
|
|
61
|
+
enablePersistentFormInputValues("[persist]");
|
|
62
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import cookie from "../utilities/cookie.js";
|
|
2
|
+
import query from "../string-utilities/query.js";
|
|
3
|
+
|
|
4
|
+
const isEditMode = query.editmode
|
|
5
|
+
? query.editmode === "true" // takes precedence over cookie
|
|
6
|
+
: Boolean(cookie.get("isAdminOfCurrentResource"));
|
|
7
|
+
|
|
8
|
+
const isOwner = Boolean(cookie.get("isAdminOfCurrentResource"));
|
|
9
|
+
|
|
10
|
+
export {
|
|
11
|
+
isEditMode,
|
|
12
|
+
isOwner
|
|
13
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
*
|
|
3
|
+
* Automatically show/hide elements with "option" attributes based on ancestors' attributes.
|
|
4
|
+
*
|
|
5
|
+
* # Usage:
|
|
6
|
+
* optionVisibilityRuleGenerator.debug = true;
|
|
7
|
+
* optionVisibilityRuleGenerator.start();
|
|
8
|
+
*
|
|
9
|
+
* # HTML Example:
|
|
10
|
+
* <div editmode="true"> <!-- Parent element with matching attribute -->
|
|
11
|
+
* <div option:editmode="true"></div> <!-- This will be visible -->
|
|
12
|
+
* <div option:editmode="false"></div> <!-- This will be hidden -->
|
|
13
|
+
* </div>
|
|
14
|
+
*
|
|
15
|
+
* Elements with `option:` attributes will be:
|
|
16
|
+
* - Visible if any ancestor has matching attribute
|
|
17
|
+
* - Hidden if no ancestor has matching attribute
|
|
18
|
+
*
|
|
19
|
+
*/
|
|
20
|
+
import Mutation from "../utilities/mutation.js";
|
|
21
|
+
|
|
22
|
+
const optionVisibilityRuleGenerator = {
|
|
23
|
+
debug: false,
|
|
24
|
+
styleElement: null,
|
|
25
|
+
|
|
26
|
+
HIDDEN_STYLES: `
|
|
27
|
+
visibility: hidden;
|
|
28
|
+
pointer-events: none;
|
|
29
|
+
width: 0;
|
|
30
|
+
height: 0;
|
|
31
|
+
overflow: hidden;
|
|
32
|
+
`,
|
|
33
|
+
VISIBLE_STYLES: `
|
|
34
|
+
visibility: visible;
|
|
35
|
+
pointer-events: auto;
|
|
36
|
+
width: auto;
|
|
37
|
+
height: auto;
|
|
38
|
+
overflow: visible;
|
|
39
|
+
`,
|
|
40
|
+
|
|
41
|
+
STYLE_CLASS: 'option-visibility-styles',
|
|
42
|
+
|
|
43
|
+
log(message, ...args) {
|
|
44
|
+
if (this.debug) {
|
|
45
|
+
console.log(`[OptionVisibilityRuleGenerator] ${message}`, ...args);
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
|
|
49
|
+
findOptionAttributes() {
|
|
50
|
+
const html = document.documentElement.outerHTML;
|
|
51
|
+
const optionAttributes = new Set(); // Using Set for unique combinations
|
|
52
|
+
const optionRegex = /option:([^\s"']+)=["']([^"']+)["']/g; // regex: "option:" + (anything but space and quote) + equal + quote + (anything but quote) + quote
|
|
53
|
+
|
|
54
|
+
let match;
|
|
55
|
+
while ((match = optionRegex.exec(html)) !== null) {
|
|
56
|
+
// Create a unique key for each name-value pair
|
|
57
|
+
const key = JSON.stringify({name: match[1], value: match[2]});
|
|
58
|
+
optionAttributes.add(key);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Convert back to objects
|
|
62
|
+
return Array.from(optionAttributes).map(key => JSON.parse(key));
|
|
63
|
+
},
|
|
64
|
+
|
|
65
|
+
minifyCSS(css) {
|
|
66
|
+
return css
|
|
67
|
+
.replace(/\s+/g, ' ')
|
|
68
|
+
.replace(/{\s+/g, '{')
|
|
69
|
+
.replace(/\s+}/g, '}')
|
|
70
|
+
.replace(/;\s+/g, ';')
|
|
71
|
+
.replace(/:\s+/g, ':')
|
|
72
|
+
.trim();
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
generateCSSRules(optionAttributes) {
|
|
76
|
+
const rules = [];
|
|
77
|
+
|
|
78
|
+
optionAttributes.forEach(({name, value}) => {
|
|
79
|
+
const escapedValue = value.replace(/["\\]/g, '\\$&');
|
|
80
|
+
|
|
81
|
+
rules.push(`
|
|
82
|
+
[option\\:${name}="${escapedValue}"] {
|
|
83
|
+
${this.HIDDEN_STYLES}
|
|
84
|
+
}
|
|
85
|
+
`);
|
|
86
|
+
|
|
87
|
+
rules.push(`
|
|
88
|
+
[${name}="${escapedValue}"] [option\\:${name}="${escapedValue}"] {
|
|
89
|
+
${this.VISIBLE_STYLES}
|
|
90
|
+
}
|
|
91
|
+
`);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
return this.minifyCSS(rules.join('\n'));
|
|
95
|
+
},
|
|
96
|
+
|
|
97
|
+
generateRules() {
|
|
98
|
+
try {
|
|
99
|
+
this.log('Starting rule generation');
|
|
100
|
+
|
|
101
|
+
const optionAttributes = this.findOptionAttributes();
|
|
102
|
+
this.log('Found option attributes:', optionAttributes);
|
|
103
|
+
|
|
104
|
+
// Early return if no option attributes found
|
|
105
|
+
if (optionAttributes.length === 0) {
|
|
106
|
+
this.log('No option attributes found, skipping style creation');
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const cssRules = this.generateCSSRules(optionAttributes);
|
|
111
|
+
this.log('Generated CSS rules:', cssRules);
|
|
112
|
+
|
|
113
|
+
// Check if we already have these exact rules
|
|
114
|
+
const existingStyleElement = document.head.querySelector(`.${this.STYLE_CLASS}`);
|
|
115
|
+
if (existingStyleElement && existingStyleElement.textContent.trim() === cssRules) {
|
|
116
|
+
this.log('Rules unchanged, skipping update');
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Create new style element
|
|
121
|
+
const newStyleElement = document.createElement('style');
|
|
122
|
+
newStyleElement.className = this.STYLE_CLASS;
|
|
123
|
+
newStyleElement.textContent = cssRules;
|
|
124
|
+
document.head.appendChild(newStyleElement);
|
|
125
|
+
|
|
126
|
+
// Remove all previous style elements
|
|
127
|
+
document.head
|
|
128
|
+
.querySelectorAll(`.${this.STYLE_CLASS}`)
|
|
129
|
+
.forEach(el => {
|
|
130
|
+
if (el !== newStyleElement) {
|
|
131
|
+
el.remove();
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
this.styleElement = newStyleElement;
|
|
136
|
+
|
|
137
|
+
this.log('Rule generation complete');
|
|
138
|
+
} catch (error) {
|
|
139
|
+
console.error('Error generating visibility rules:', error);
|
|
140
|
+
}
|
|
141
|
+
},
|
|
142
|
+
|
|
143
|
+
start() {
|
|
144
|
+
Mutation.onAnyChange({
|
|
145
|
+
selectorFilter: el => [...el.attributes].some(attr => attr.name.startsWith('option:')),
|
|
146
|
+
debounce: 200
|
|
147
|
+
}, () => {
|
|
148
|
+
this.generateRules();
|
|
149
|
+
});
|
|
150
|
+
this.generateRules();
|
|
151
|
+
this.log('Started observing DOM mutations');
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
export default optionVisibilityRuleGenerator;
|
|
156
|
+
|
|
157
|
+
// Auto-initialize
|
|
158
|
+
export function init() {
|
|
159
|
+
optionVisibilityRuleGenerator.start();
|
|
160
|
+
}
|
package/core/savePage.js
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Full save system for Hyperclay
|
|
3
|
+
*
|
|
4
|
+
* This includes all the conveniences: change detection, toast notifications,
|
|
5
|
+
* auto-save, keyboard shortcuts, and more.
|
|
6
|
+
*
|
|
7
|
+
* Built on top of savePageCore.js
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import toast from "../ui/toast.js";
|
|
11
|
+
import throttle from "../utilities/throttle.js";
|
|
12
|
+
import Mutation from "../utilities/mutation.js";
|
|
13
|
+
import { isEditMode, isOwner } from "./isAdminOfCurrentResource.js";
|
|
14
|
+
import {
|
|
15
|
+
savePage as savePageCore,
|
|
16
|
+
getPageContents,
|
|
17
|
+
replacePageWith as replacePageWithCore
|
|
18
|
+
} from "./savePageCore.js";
|
|
19
|
+
|
|
20
|
+
// Re-export beforeSave from core for backward compatibility
|
|
21
|
+
export { beforeSave } from "./savePageCore.js";
|
|
22
|
+
|
|
23
|
+
let unsavedChanges = false;
|
|
24
|
+
let lastSavedContents = '';
|
|
25
|
+
let baselineContents = ''; // State after initial setup, used to prevent autosave from setup mutations
|
|
26
|
+
|
|
27
|
+
// Initialize lastSavedContents on page load to match what's on disk
|
|
28
|
+
// This prevents unnecessary save attempts when content hasn't changed
|
|
29
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
30
|
+
if (isEditMode) {
|
|
31
|
+
// Capture initial state immediately for comparison
|
|
32
|
+
lastSavedContents = getPageContents();
|
|
33
|
+
|
|
34
|
+
// Also capture baseline after setup for autosave detection
|
|
35
|
+
setTimeout(() => {
|
|
36
|
+
baselineContents = getPageContents();
|
|
37
|
+
}, 1500);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Save the current page with change detection and toast notifications
|
|
43
|
+
*
|
|
44
|
+
* @param {Function} callback - Optional callback for custom handling
|
|
45
|
+
*/
|
|
46
|
+
export function savePage(callback = () => {}) {
|
|
47
|
+
if (!isEditMode) {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const currentContents = getPageContents();
|
|
52
|
+
|
|
53
|
+
// Track whether there are unsaved changes
|
|
54
|
+
unsavedChanges = (currentContents !== lastSavedContents);
|
|
55
|
+
|
|
56
|
+
// Skip if content hasn't changed
|
|
57
|
+
if (!unsavedChanges) {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
savePageCore(({msg, msgType}) => {
|
|
62
|
+
// Update tracking on success
|
|
63
|
+
if (msgType !== 'error') {
|
|
64
|
+
lastSavedContents = currentContents;
|
|
65
|
+
unsavedChanges = false;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Call user callback if provided
|
|
69
|
+
if (typeof callback === 'function' && msg) {
|
|
70
|
+
callback({msg, msgType});
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Throttled version of savePage for auto-save
|
|
77
|
+
*/
|
|
78
|
+
const throttledSave = throttle(savePage, 1200);
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Save the page with throttling, for use with auto-save
|
|
82
|
+
* Checks both baseline and last saved content to prevent saves from initial setup
|
|
83
|
+
*
|
|
84
|
+
* @param {Function} callback - Optional callback
|
|
85
|
+
*/
|
|
86
|
+
export function savePageThrottled(callback = () => {}) {
|
|
87
|
+
if (!isEditMode) return;
|
|
88
|
+
|
|
89
|
+
const currentContents = getPageContents();
|
|
90
|
+
// For autosave: check both that content changed from baseline AND from last save
|
|
91
|
+
// This prevents saves from initial setup mutations
|
|
92
|
+
if (currentContents !== baselineContents && currentContents !== lastSavedContents) {
|
|
93
|
+
unsavedChanges = true;
|
|
94
|
+
throttledSave(callback);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Fetch HTML from a URL and save it, then reload
|
|
100
|
+
* Shows toast notifications
|
|
101
|
+
*
|
|
102
|
+
* @param {string} url - URL to fetch from
|
|
103
|
+
*/
|
|
104
|
+
export function replacePageWith(url) {
|
|
105
|
+
if (!isEditMode) {
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
replacePageWithCore(url, (err, data) => {
|
|
110
|
+
if (err) {
|
|
111
|
+
// Show error toast if save failed
|
|
112
|
+
toast(err.message || "Failed to save template", "error");
|
|
113
|
+
} else {
|
|
114
|
+
// Only reload if save was successful
|
|
115
|
+
window.location.reload();
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Initialize keyboard shortcut for save (CMD/CTRL+S)
|
|
122
|
+
*/
|
|
123
|
+
export function initSaveKeyboardShortcut() {
|
|
124
|
+
document.addEventListener("keydown", function(event) {
|
|
125
|
+
let isMac = window.navigator.platform.match("Mac");
|
|
126
|
+
let metaKeyPressed = isMac ? event.metaKey : event.ctrlKey;
|
|
127
|
+
if (metaKeyPressed && event.keyCode == 83) {
|
|
128
|
+
event.preventDefault();
|
|
129
|
+
savePage(({msg, msgType} = {}) => {
|
|
130
|
+
if (msg) toast(msg, msgType);
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Initialize save button handler
|
|
138
|
+
* Looks for elements with [trigger-save] attribute
|
|
139
|
+
*/
|
|
140
|
+
export function initHyperclaySaveButton() {
|
|
141
|
+
document.addEventListener("click", event => {
|
|
142
|
+
if (event.target.closest("[trigger-save]")) {
|
|
143
|
+
savePage(({msg, msgType} = {}) => {
|
|
144
|
+
if (msg) toast(msg, msgType);
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Initialize auto-save on DOM changes
|
|
152
|
+
* Uses debounced mutation observer
|
|
153
|
+
*/
|
|
154
|
+
export function initSavePageOnChange() {
|
|
155
|
+
Mutation.onAnyChange({
|
|
156
|
+
debounce: 3333,
|
|
157
|
+
omitChangeDetails: true
|
|
158
|
+
}, () => {
|
|
159
|
+
savePageThrottled(({msg, msgType} = {}) => {
|
|
160
|
+
if (msg) toast(msg, msgType);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Warn before leaving page with unsaved changes
|
|
167
|
+
*/
|
|
168
|
+
window.addEventListener('beforeunload', (event) => {
|
|
169
|
+
if (unsavedChanges && isOwner) {
|
|
170
|
+
event.preventDefault();
|
|
171
|
+
event.returnValue = '';
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Initialize the full save system
|
|
177
|
+
*/
|
|
178
|
+
export function init() {
|
|
179
|
+
if (!isEditMode) return;
|
|
180
|
+
|
|
181
|
+
initSaveKeyboardShortcut();
|
|
182
|
+
initHyperclaySaveButton();
|
|
183
|
+
initSavePageOnChange();
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Export save functions to window.hyperclay
|
|
188
|
+
*/
|
|
189
|
+
export function exportToWindow() {
|
|
190
|
+
if (!window.hyperclay) {
|
|
191
|
+
window.hyperclay = {};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
window.hyperclay.savePage = savePage;
|
|
195
|
+
window.hyperclay.replacePageWith = replacePageWith;
|
|
196
|
+
}
|