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,230 @@
|
|
|
1
|
+
const behaviorCollector = (() => {
|
|
2
|
+
let lastMouseMove = 0;
|
|
3
|
+
let lastScroll = 0;
|
|
4
|
+
const THROTTLE_MS = 50;
|
|
5
|
+
|
|
6
|
+
const data = {
|
|
7
|
+
mousePositions: [],
|
|
8
|
+
scrollEvents: [],
|
|
9
|
+
keyboardEvents: [],
|
|
10
|
+
startTime: Date.now(),
|
|
11
|
+
webdriver: navigator.webdriver,
|
|
12
|
+
interactions: {
|
|
13
|
+
clicks: [],
|
|
14
|
+
touches: [],
|
|
15
|
+
focusEvents: [],
|
|
16
|
+
blurEvents: [],
|
|
17
|
+
tabSwitches: [],
|
|
18
|
+
keysSummary: new Set()
|
|
19
|
+
},
|
|
20
|
+
navigation: {
|
|
21
|
+
scrollCount: 0,
|
|
22
|
+
maxScrollDepth: 0,
|
|
23
|
+
lastScrollPosition: 0,
|
|
24
|
+
scrollDirectionChanges: 0
|
|
25
|
+
},
|
|
26
|
+
mouseMetrics: {
|
|
27
|
+
totalDistance: 0,
|
|
28
|
+
lastPosition: null
|
|
29
|
+
},
|
|
30
|
+
timing: {
|
|
31
|
+
firstInteraction: null,
|
|
32
|
+
lastInteraction: null
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
function updateTiming() {
|
|
37
|
+
const now = Date.now();
|
|
38
|
+
if (!data.timing.firstInteraction) {
|
|
39
|
+
data.timing.firstInteraction = now;
|
|
40
|
+
}
|
|
41
|
+
data.timing.lastInteraction = now;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function setupListeners() {
|
|
45
|
+
document.addEventListener('mousemove', (e) => {
|
|
46
|
+
const now = Date.now();
|
|
47
|
+
if (now - lastMouseMove < THROTTLE_MS) return;
|
|
48
|
+
lastMouseMove = now;
|
|
49
|
+
|
|
50
|
+
const position = {
|
|
51
|
+
x: Math.round(e.clientX),
|
|
52
|
+
y: Math.round(e.clientY),
|
|
53
|
+
timestamp: now,
|
|
54
|
+
isTrusted: e.isTrusted
|
|
55
|
+
};
|
|
56
|
+
data.mousePositions.push(position);
|
|
57
|
+
|
|
58
|
+
if (data.mouseMetrics.lastPosition) {
|
|
59
|
+
const distance = Math.sqrt(
|
|
60
|
+
Math.pow(position.x - data.mouseMetrics.lastPosition.x, 2) +
|
|
61
|
+
Math.pow(position.y - data.mouseMetrics.lastPosition.y, 2)
|
|
62
|
+
);
|
|
63
|
+
data.mouseMetrics.totalDistance += distance;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
data.mouseMetrics.lastPosition = position;
|
|
67
|
+
updateTiming();
|
|
68
|
+
|
|
69
|
+
if (data.mousePositions.length > 100) {
|
|
70
|
+
data.mousePositions.shift();
|
|
71
|
+
}
|
|
72
|
+
}, {
|
|
73
|
+
passive: true
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
window.addEventListener('scroll', (e) => {
|
|
77
|
+
const now = Date.now();
|
|
78
|
+
if (now - lastScroll < THROTTLE_MS) return;
|
|
79
|
+
lastScroll = now;
|
|
80
|
+
|
|
81
|
+
const position = Math.round(window.scrollY);
|
|
82
|
+
data.navigation.scrollCount++;
|
|
83
|
+
|
|
84
|
+
const scrollEvent = {
|
|
85
|
+
position: position,
|
|
86
|
+
timestamp: now,
|
|
87
|
+
direction: position > data.navigation.lastScrollPosition ? 'down' : 'up',
|
|
88
|
+
isTrusted: e.isTrusted
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
data.scrollEvents.push(scrollEvent);
|
|
92
|
+
|
|
93
|
+
if (position > data.navigation.maxScrollDepth) {
|
|
94
|
+
data.navigation.maxScrollDepth = position;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (data.scrollEvents.length > 1) {
|
|
98
|
+
const lastEvent = data.scrollEvents[data.scrollEvents.length - 2];
|
|
99
|
+
if (lastEvent.direction !== scrollEvent.direction) {
|
|
100
|
+
data.navigation.scrollDirectionChanges++;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
data.navigation.lastScrollPosition = position;
|
|
105
|
+
updateTiming();
|
|
106
|
+
|
|
107
|
+
if (data.scrollEvents.length > 50) {
|
|
108
|
+
data.scrollEvents.shift();
|
|
109
|
+
}
|
|
110
|
+
}, {
|
|
111
|
+
passive: true
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// Capture blur events
|
|
115
|
+
document.addEventListener('blur', (e) => {
|
|
116
|
+
data.interactions.blurEvents.push({
|
|
117
|
+
target: e.target.tagName,
|
|
118
|
+
timestamp: Date.now(),
|
|
119
|
+
isTrusted: e.isTrusted
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
if (data.interactions.blurEvents.length > 20) {
|
|
123
|
+
data.interactions.blurEvents.shift();
|
|
124
|
+
}
|
|
125
|
+
}, {
|
|
126
|
+
capture: true
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
document.addEventListener('keydown', (e) => {
|
|
130
|
+
data.keyboardEvents.push({
|
|
131
|
+
key: e.key,
|
|
132
|
+
timestamp: Date.now(),
|
|
133
|
+
modifiers: {
|
|
134
|
+
ctrl: e.ctrlKey,
|
|
135
|
+
alt: e.altKey,
|
|
136
|
+
shift: e.shiftKey
|
|
137
|
+
},
|
|
138
|
+
isTrusted: e.isTrusted
|
|
139
|
+
});
|
|
140
|
+
data.interactions.keysSummary.add(e.key);
|
|
141
|
+
updateTiming();
|
|
142
|
+
|
|
143
|
+
if (data.keyboardEvents.length > 50) {
|
|
144
|
+
data.keyboardEvents.shift();
|
|
145
|
+
}
|
|
146
|
+
}, {
|
|
147
|
+
passive: true
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
document.addEventListener('click', (e) => {
|
|
151
|
+
data.interactions.clicks.push({
|
|
152
|
+
x: Math.round(e.clientX),
|
|
153
|
+
y: Math.round(e.clientY),
|
|
154
|
+
timestamp: Date.now(),
|
|
155
|
+
target: e.target.tagName,
|
|
156
|
+
isTrusted: e.isTrusted
|
|
157
|
+
});
|
|
158
|
+
updateTiming();
|
|
159
|
+
|
|
160
|
+
if (data.interactions.clicks.length > 20) {
|
|
161
|
+
data.interactions.clicks.shift();
|
|
162
|
+
}
|
|
163
|
+
}, {
|
|
164
|
+
passive: true
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
document.addEventListener('touchstart', (e) => {
|
|
168
|
+
data.interactions.touches.push({
|
|
169
|
+
touchCount: e.touches.length,
|
|
170
|
+
timestamp: Date.now(),
|
|
171
|
+
isTrusted: e.isTrusted
|
|
172
|
+
});
|
|
173
|
+
updateTiming();
|
|
174
|
+
|
|
175
|
+
if (data.interactions.touches.length > 20) {
|
|
176
|
+
data.interactions.touches.shift();
|
|
177
|
+
}
|
|
178
|
+
}, {
|
|
179
|
+
passive: true
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
document.addEventListener('focus', (e) => {
|
|
183
|
+
data.interactions.focusEvents.push({
|
|
184
|
+
target: e.target.tagName,
|
|
185
|
+
timestamp: Date.now(),
|
|
186
|
+
isTrusted: e.isTrusted
|
|
187
|
+
});
|
|
188
|
+
updateTiming();
|
|
189
|
+
|
|
190
|
+
if (data.interactions.focusEvents.length > 20) {
|
|
191
|
+
data.interactions.focusEvents.shift();
|
|
192
|
+
}
|
|
193
|
+
}, {
|
|
194
|
+
passive: true,
|
|
195
|
+
capture: true
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
document.addEventListener('visibilitychange', () => {
|
|
199
|
+
data.interactions.tabSwitches.push({
|
|
200
|
+
state: document.visibilityState,
|
|
201
|
+
timestamp: Date.now()
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
if (data.interactions.tabSwitches.length > 20) {
|
|
205
|
+
data.interactions.tabSwitches.shift();
|
|
206
|
+
}
|
|
207
|
+
}, {
|
|
208
|
+
passive: true
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return {
|
|
213
|
+
init: setupListeners,
|
|
214
|
+
getData: () => ({
|
|
215
|
+
...data,
|
|
216
|
+
timeSpent: Date.now() - data.startTime,
|
|
217
|
+
interactions: {
|
|
218
|
+
...data.interactions,
|
|
219
|
+
keysSummary: Array.from(data.interactions.keysSummary)
|
|
220
|
+
}
|
|
221
|
+
})
|
|
222
|
+
};
|
|
223
|
+
})();
|
|
224
|
+
|
|
225
|
+
export default behaviorCollector;
|
|
226
|
+
|
|
227
|
+
// Auto-initialize - start collecting behavior data
|
|
228
|
+
export function init() {
|
|
229
|
+
behaviorCollector.init();
|
|
230
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import behaviorCollector from "./behaviorCollector.js";
|
|
2
|
+
import getDataFromForm from "../dom-utilities/getDataFromForm.js";
|
|
3
|
+
import toast from "../ui/toast.js";
|
|
4
|
+
|
|
5
|
+
export default function sendMessage(eventOrObj, successMessage = "Successfully sent", callback) {
|
|
6
|
+
let form;
|
|
7
|
+
let data;
|
|
8
|
+
|
|
9
|
+
if (eventOrObj instanceof Event) {
|
|
10
|
+
eventOrObj.preventDefault();
|
|
11
|
+
form = eventOrObj.target.closest('form');
|
|
12
|
+
|
|
13
|
+
if (!form) {
|
|
14
|
+
toast('No form found for this element', 'error');
|
|
15
|
+
return Promise.reject('No form found');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
data = getDataFromForm(form);
|
|
19
|
+
} else {
|
|
20
|
+
data = eventOrObj;
|
|
21
|
+
if (this?.closest) {
|
|
22
|
+
form = this.closest('form');
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
data.behaviorData = behaviorCollector.getData();
|
|
27
|
+
|
|
28
|
+
return fetch("/message", {
|
|
29
|
+
method: 'POST',
|
|
30
|
+
headers: { 'Content-Type': 'application/json' },
|
|
31
|
+
body: JSON.stringify(data)
|
|
32
|
+
})
|
|
33
|
+
.then(res => res.ok ? res.json() : Promise.reject(`Request failed: ${res.status}`))
|
|
34
|
+
.then(result => {
|
|
35
|
+
toast(successMessage);
|
|
36
|
+
|
|
37
|
+
if (form?.reset) {
|
|
38
|
+
form.reset();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (callback) callback(result);
|
|
42
|
+
return result;
|
|
43
|
+
})
|
|
44
|
+
.catch(error => {
|
|
45
|
+
toast(`Failed to send message: ${error}`);
|
|
46
|
+
throw error;
|
|
47
|
+
});
|
|
48
|
+
}
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
import toast from "../ui/toast.js";
|
|
2
|
+
import debounce from "../utilities/debounce.js";
|
|
3
|
+
import copyToClipboard from "../string-utilities/copy-to-clipboard.js";
|
|
4
|
+
|
|
5
|
+
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
|
6
|
+
|
|
7
|
+
export function uploadFile(eventOrFile, callback = () => {}, extraData = {}) {
|
|
8
|
+
// handle event
|
|
9
|
+
if (eventOrFile instanceof Event) {
|
|
10
|
+
eventOrFile.preventDefault();
|
|
11
|
+
const fileInput = eventOrFile.target;
|
|
12
|
+
const file = fileInput.files[0];
|
|
13
|
+
return uploadFileFromObject(file, res => {
|
|
14
|
+
callback(res);
|
|
15
|
+
fileInput.value = ""; // Reset fileInput after upload
|
|
16
|
+
}, extraData);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// handle raw file object
|
|
20
|
+
if (eventOrFile instanceof File) {
|
|
21
|
+
return uploadFileFromObject(eventOrFile, callback, extraData);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return Promise.reject(new Error('uploadFile requires either an Event or File object'));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function createFile(eventOrData) {
|
|
28
|
+
// handle file as event
|
|
29
|
+
if (eventOrData instanceof Event) {
|
|
30
|
+
eventOrData.preventDefault();
|
|
31
|
+
const formElem = eventOrData.target;
|
|
32
|
+
const fileNameInput = formElem.querySelector('[name="file_name"]');
|
|
33
|
+
const fileBodyInput = formElem.querySelector('[name="file_body"]');
|
|
34
|
+
|
|
35
|
+
const fileName = fileNameInput.value.trim();
|
|
36
|
+
const fileBody = fileBodyInput.value;
|
|
37
|
+
|
|
38
|
+
if (!isValidFileName(fileName)) {
|
|
39
|
+
toast('Invalid filename', 'error');
|
|
40
|
+
return Promise.reject(new Error('Invalid filename'));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return createFileFromData(fileName, fileBody, () => {
|
|
44
|
+
resetFormElements(formElem);
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// handle file as base64 data
|
|
49
|
+
if (eventOrData && typeof eventOrData === 'object' && 'fileName' in eventOrData && 'fileBody' in eventOrData) {
|
|
50
|
+
return createFileFromData(eventOrData.fileName, eventOrData.fileBody);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// handle file as base64 data
|
|
54
|
+
if (arguments.length === 2) {
|
|
55
|
+
const [fileName, fileBody] = arguments;
|
|
56
|
+
return createFileFromData(fileName, fileBody);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return Promise.reject(new Error('createFile requires either an Event, {fileName, fileBody} object, or (fileName, fileBody) parameters'));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function uploadFileBasic (eventOrFile, {
|
|
63
|
+
onProgress = (percent) => {},
|
|
64
|
+
onComplete = (url) => {},
|
|
65
|
+
onError = (error) => {}
|
|
66
|
+
} = {}) {
|
|
67
|
+
function handleUpload(file) {
|
|
68
|
+
if (file.size > MAX_FILE_SIZE) {
|
|
69
|
+
onError(new Error('File too large'));
|
|
70
|
+
return Promise.reject(new Error('File too large'));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return new Promise((resolve, reject) => {
|
|
74
|
+
const reader = new FileReader();
|
|
75
|
+
|
|
76
|
+
reader.onload = async function () {
|
|
77
|
+
try {
|
|
78
|
+
const fileBody = reader.result.split(",")[1];
|
|
79
|
+
const fileName = file.name;
|
|
80
|
+
const result = await uploadFileData(fileName, fileBody, (percentComplete, res) => {
|
|
81
|
+
if (percentComplete === -1) {
|
|
82
|
+
onError(new Error('Upload cancelled'));
|
|
83
|
+
} else if (percentComplete < 100) {
|
|
84
|
+
onProgress(percentComplete);
|
|
85
|
+
} else if (res) {
|
|
86
|
+
onComplete(res);
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
resolve(result);
|
|
90
|
+
} catch (error) {
|
|
91
|
+
onError(error);
|
|
92
|
+
reject(error);
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
reader.onerror = () => {
|
|
97
|
+
const error = new Error('Failed to read file');
|
|
98
|
+
onError(error);
|
|
99
|
+
reject(error);
|
|
100
|
+
};
|
|
101
|
+
reader.readAsDataURL(file);
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (eventOrFile instanceof Event) {
|
|
106
|
+
eventOrFile.preventDefault();
|
|
107
|
+
const file = eventOrFile.target.files[0];
|
|
108
|
+
return handleUpload(file);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (eventOrFile instanceof File) {
|
|
112
|
+
return handleUpload(eventOrFile);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const error = new Error('uploadFile requires either an Event or File object');
|
|
116
|
+
onError(error);
|
|
117
|
+
return Promise.reject(error);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function uploadFileFromObject(file, onComplete = () => {}, extraData = {}) {
|
|
121
|
+
if (file.size > MAX_FILE_SIZE) {
|
|
122
|
+
toast(`Maximum file size: ${MAX_FILE_SIZE / 1024 / 1024}MB`, 'error');
|
|
123
|
+
return Promise.reject(new Error('File too large'));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return new Promise((resolve, reject) => {
|
|
127
|
+
const reader = new FileReader();
|
|
128
|
+
|
|
129
|
+
reader.onload = async function () {
|
|
130
|
+
try {
|
|
131
|
+
const fileBody = reader.result.split(",")[1];
|
|
132
|
+
const fileName = file.name;
|
|
133
|
+
const result = await uploadFileData(fileName, fileBody, function progressCallback(percentComplete, res) {
|
|
134
|
+
if (percentComplete === -1) {
|
|
135
|
+
toast('Upload cancelled', 'error');
|
|
136
|
+
} else if (percentComplete < 100) {
|
|
137
|
+
toast(`${percentComplete}% uploaded`);
|
|
138
|
+
} else if (res) {
|
|
139
|
+
const urls = res.urls ? res.urls.join("\n") : "";
|
|
140
|
+
if (urls) {
|
|
141
|
+
copyToClipboard(urls);
|
|
142
|
+
toast(res.msg, res.msgType);
|
|
143
|
+
onComplete(res);
|
|
144
|
+
} else {
|
|
145
|
+
toast(res.msg, res.msgType);
|
|
146
|
+
onComplete(res);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}, extraData);
|
|
150
|
+
resolve(result);
|
|
151
|
+
} catch (error) {
|
|
152
|
+
const {msg, msgType} = error?.response ? JSON.parse(error.response) : {msg: 'Upload failed', msgType: 'error'};
|
|
153
|
+
toast(msg, msgType);
|
|
154
|
+
reject(error);
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
reader.onerror = () => {
|
|
159
|
+
toast('Failed to read file', 'error');
|
|
160
|
+
reject(new Error('Failed to read file'));
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
reader.readAsDataURL(file);
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function createFileFromData(fileName, fileBody, onComplete = () => {}) {
|
|
168
|
+
if (!isValidFileName(fileName)) {
|
|
169
|
+
return Promise.reject(new Error('Invalid filename'));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const { adjustedFileName, base64FileBody } = processFileContents(fileName, fileBody);
|
|
173
|
+
return uploadFileData(adjustedFileName, base64FileBody, function progressCallback(percentComplete, res) {
|
|
174
|
+
if (percentComplete === -1) {
|
|
175
|
+
toast('Upload cancelled', 'error');
|
|
176
|
+
} else if (percentComplete < 100) {
|
|
177
|
+
toast(`${percentComplete}% uploaded`);
|
|
178
|
+
} else {
|
|
179
|
+
const urls = res.urls.join("\n")
|
|
180
|
+
copyToClipboard(urls);
|
|
181
|
+
toast(res.msg, res.msgType);
|
|
182
|
+
onComplete(res);
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function uploadFileData(fileName, fileBody, progressCallback, extraData = {}) {
|
|
188
|
+
const xhr = new XMLHttpRequest();
|
|
189
|
+
xhr.open("POST", "/upload", true);
|
|
190
|
+
xhr.setRequestHeader("Content-Type", "application/json");
|
|
191
|
+
|
|
192
|
+
let lastReportedProgress = 0;
|
|
193
|
+
const debouncedProgressCallback = debounce(function (event) {
|
|
194
|
+
if (event.lengthComputable) {
|
|
195
|
+
const percentComplete = Math.floor((event.loaded / event.total) * 100);
|
|
196
|
+
if (
|
|
197
|
+
(percentComplete >= 10 && percentComplete < 50 && lastReportedProgress < 10) ||
|
|
198
|
+
(percentComplete >= 50 && percentComplete < 80 && lastReportedProgress < 50) ||
|
|
199
|
+
(percentComplete >= 80 && percentComplete < 100 && lastReportedProgress < 80)
|
|
200
|
+
) {
|
|
201
|
+
progressCallback(percentComplete);
|
|
202
|
+
lastReportedProgress = percentComplete;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}, 200);
|
|
206
|
+
|
|
207
|
+
xhr.upload.onprogress = debouncedProgressCallback;
|
|
208
|
+
|
|
209
|
+
const upload = new Promise((resolve, reject) => {
|
|
210
|
+
xhr.onreadystatechange = function () {
|
|
211
|
+
if (xhr.readyState === XMLHttpRequest.DONE) {
|
|
212
|
+
if (xhr.status === 200) {
|
|
213
|
+
const response = JSON.parse(xhr.responseText);
|
|
214
|
+
progressCallback(100, response);
|
|
215
|
+
resolve(response);
|
|
216
|
+
} else {
|
|
217
|
+
let errorMessage = 'Upload failed';
|
|
218
|
+
let errorResponse = {};
|
|
219
|
+
let msgType = 'error';
|
|
220
|
+
|
|
221
|
+
try {
|
|
222
|
+
errorResponse = JSON.parse(xhr.responseText);
|
|
223
|
+
errorMessage = errorResponse.msg || errorMessage;
|
|
224
|
+
msgType = errorResponse.msgType || 'error';
|
|
225
|
+
} catch (e) {
|
|
226
|
+
// If response isn't valid JSON, use default message
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Show toast with the error message
|
|
230
|
+
toast(errorMessage, msgType);
|
|
231
|
+
|
|
232
|
+
const error = new Error(errorMessage);
|
|
233
|
+
error.status = xhr.status;
|
|
234
|
+
error.response = xhr.responseText;
|
|
235
|
+
reject(error);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
xhr.onerror = () => {
|
|
241
|
+
reject(new Error('Network error occurred'));
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
xhr.ontimeout = () => {
|
|
245
|
+
reject(new Error('Upload timed out'));
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
const data = JSON.stringify({
|
|
249
|
+
fileName,
|
|
250
|
+
fileBody,
|
|
251
|
+
...extraData
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
xhr.send(data);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
upload.abort = () => {
|
|
258
|
+
xhr.abort();
|
|
259
|
+
progressCallback(-1, { msg: 'Upload cancelled' });
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
return upload;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function processFileContents(fileName, fileBody) {
|
|
266
|
+
const contentTypeInfo = detectContentType(fileBody);
|
|
267
|
+
|
|
268
|
+
const adjustedFileName = adjustFileExtension(fileName, contentTypeInfo.extension);
|
|
269
|
+
const base64FileBody = base64EncodeUnicode(fileBody);
|
|
270
|
+
|
|
271
|
+
return { adjustedFileName, base64FileBody };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function isValidFileName(name) {
|
|
275
|
+
if (!name || typeof name !== 'string') return false;
|
|
276
|
+
const invalidChars = /[<>:"/\\|?*\x00-\x1F]/g;
|
|
277
|
+
return name.length > 0 && name.length <= 255 && !invalidChars.test(name);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function adjustFileExtension(fileName, extension) {
|
|
281
|
+
const dotIndex = fileName.lastIndexOf(".");
|
|
282
|
+
if (dotIndex !== -1) {
|
|
283
|
+
fileName = fileName.substring(0, dotIndex) + extension;
|
|
284
|
+
} else {
|
|
285
|
+
fileName += extension;
|
|
286
|
+
}
|
|
287
|
+
return fileName;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function base64EncodeUnicode(str) {
|
|
291
|
+
return btoa(unescape(encodeURIComponent(str)));
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function resetFormElements(formElem) {
|
|
295
|
+
const inputs = formElem.querySelectorAll('input[name], textarea[name]');
|
|
296
|
+
inputs.forEach((input) => {
|
|
297
|
+
if (input.hasAttribute("data-default-value")) {
|
|
298
|
+
input.value = input.getAttribute("data-default-value");
|
|
299
|
+
} else {
|
|
300
|
+
input.value = "";
|
|
301
|
+
}
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function detectContentType(content) {
|
|
306
|
+
const patterns = [
|
|
307
|
+
// HTML and SVG patterns
|
|
308
|
+
{ type: "html", mime: "text/html", regex: /<html/i },
|
|
309
|
+
{ type: "svg", mime: "image/svg+xml", regex: /<svg/i },
|
|
310
|
+
// CSS pattern moved before JSON
|
|
311
|
+
{
|
|
312
|
+
type: "css",
|
|
313
|
+
mime: "text/css",
|
|
314
|
+
regex: /^\s*(\/\*[\s\S]*?\*\/\s*)?(@[a-z\-]+\s+)?([.#]?[a-zA-Z][\w-]*\s*,?\s*)+\s*\{/im,
|
|
315
|
+
},
|
|
316
|
+
// JavaScript pattern
|
|
317
|
+
{
|
|
318
|
+
type: "js",
|
|
319
|
+
mime: "application/javascript",
|
|
320
|
+
regex: /^\s*(\/\*[\s\S]*?\*\/\s*)?(\/\/.*\s*)*(import|export|var|let|const|function|class)\s+/im,
|
|
321
|
+
},
|
|
322
|
+
// CSV pattern
|
|
323
|
+
{ type: "csv", mime: "text/csv", regex: /^[^,\n]+(,[^,\n]+)+/ },
|
|
324
|
+
// Markdown pattern
|
|
325
|
+
{
|
|
326
|
+
type: "md",
|
|
327
|
+
mime: "text/markdown",
|
|
328
|
+
regex: /#\S+\s+\S+|```[\s\S]*```/
|
|
329
|
+
},
|
|
330
|
+
];
|
|
331
|
+
|
|
332
|
+
for (const { type, mime, regex } of patterns) {
|
|
333
|
+
if (regex.test(content)) {
|
|
334
|
+
return { type, mime, extension: `.${type}` };
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Try to parse as JSON
|
|
339
|
+
try {
|
|
340
|
+
JSON.parse(content);
|
|
341
|
+
return { type: "json", mime: "application/json", extension: ".json" };
|
|
342
|
+
} catch (e) {
|
|
343
|
+
// Not JSON
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Default to plain text
|
|
347
|
+
return { type: "txt", mime: "text/plain", extension: ".txt" };
|
|
348
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
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 disableContentEditableBeforeSave () {
|
|
6
|
+
beforeSave(docElem => {
|
|
7
|
+
docElem.querySelectorAll('[edit-mode-contenteditable]').forEach(resource => {
|
|
8
|
+
const originalValue = resource.getAttribute("contenteditable");
|
|
9
|
+
resource.setAttribute("inert-contenteditable", originalValue);
|
|
10
|
+
resource.removeAttribute("contenteditable");
|
|
11
|
+
});
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function enableContentEditableForAdminOnPageLoad () {
|
|
16
|
+
if (!isEditMode) return;
|
|
17
|
+
|
|
18
|
+
onDomReady(() => {
|
|
19
|
+
document.querySelectorAll('[edit-mode-contenteditable]').forEach(resource => {
|
|
20
|
+
let originalValue = resource.getAttribute("inert-contenteditable");
|
|
21
|
+
|
|
22
|
+
if (!["false", "plaintext-only"].includes(originalValue)) {
|
|
23
|
+
originalValue = "true";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
resource.setAttribute("contenteditable", originalValue);
|
|
27
|
+
resource.removeAttribute("inert-contenteditable");
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Auto-initialize
|
|
33
|
+
export function init() {
|
|
34
|
+
disableContentEditableBeforeSave();
|
|
35
|
+
enableContentEditableForAdminOnPageLoad();
|
|
36
|
+
}
|