@mahe_pkm/buzl-html-editor 0.1.0 → 0.2.1

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.
@@ -0,0 +1,286 @@
1
+ (() => {
2
+ 'use strict';
3
+
4
+ const runtime = document.currentScript
5
+ || document.querySelector('script[data-buzl-live-edit-runtime]');
6
+ if (!runtime || !runtime.dataset.buzlPageToken) return;
7
+
8
+ const pageToken = runtime.dataset.buzlPageToken;
9
+ const pagePath = runtime.dataset.buzlPagePath || window.location.pathname;
10
+ const editableSelector = '[data-buzl-public-edit-key]';
11
+ const originals = new Map();
12
+ const histories = new Map();
13
+ const dirtyKeys = new Set();
14
+ let editMode = false;
15
+ let menuOpen = false;
16
+ let activeKey = null;
17
+ let lastChangedKey = null;
18
+ let saving = false;
19
+
20
+ const host = document.createElement('div');
21
+ host.setAttribute('data-buzl-public-editor-ui', 'true');
22
+ const shadow = host.attachShadow({ mode: 'open' });
23
+ const stylesheet = document.createElement('link');
24
+ stylesheet.rel = 'stylesheet';
25
+ stylesheet.href = '/__buzl/live-edit.css';
26
+ shadow.appendChild(stylesheet);
27
+
28
+ const toolbar = document.createElement('div');
29
+ toolbar.className = 'buzl-toolbar';
30
+ toolbar.setAttribute('role', 'toolbar');
31
+ toolbar.setAttribute('aria-label', 'Buzl page editor');
32
+ toolbar.innerHTML = `
33
+ <button class="buzl-launcher" type="button" data-menu-toggle aria-label="Open Buzl editor" title="Open Buzl editor" aria-expanded="false" aria-controls="buzl-editor-menu"><span aria-hidden="true">✎</span></button>
34
+ <div class="buzl-menu" id="buzl-editor-menu" data-menu>
35
+ <span class="buzl-status" data-status title="${escapeAttribute(pagePath)}">Ready to edit this page</span>
36
+ <button class="buzl-primary" type="button" data-toggle aria-pressed="false">Edit Text</button>
37
+ <button type="button" data-save disabled>Save</button>
38
+ <button type="button" data-undo disabled>Undo</button>
39
+ <button type="button" data-cancel disabled>Cancel</button>
40
+ <a href="/admin/" data-advanced>Advanced Editor</a>
41
+ </div>
42
+ `;
43
+ shadow.appendChild(toolbar);
44
+ document.documentElement.appendChild(host);
45
+
46
+ const toggleButton = shadow.querySelector('[data-toggle]');
47
+ const menuButton = shadow.querySelector('[data-menu-toggle]');
48
+ const saveButton = shadow.querySelector('[data-save]');
49
+ const undoButton = shadow.querySelector('[data-undo]');
50
+ const cancelButton = shadow.querySelector('[data-cancel]');
51
+ const statusLabel = shadow.querySelector('[data-status]');
52
+
53
+ function escapeAttribute(value) {
54
+ return String(value)
55
+ .replace(/&/g, '&amp;')
56
+ .replace(/"/g, '&quot;')
57
+ .replace(/</g, '&lt;')
58
+ .replace(/>/g, '&gt;');
59
+ }
60
+
61
+ function setStatus(message, tone = '') {
62
+ statusLabel.textContent = message;
63
+ if (tone) statusLabel.dataset.tone = tone;
64
+ else delete statusLabel.dataset.tone;
65
+ }
66
+
67
+ function visibleEditableElements() {
68
+ return Array.from(document.querySelectorAll(editableSelector)).filter((element) => {
69
+ if (!element.textContent || !element.textContent.trim()) return false;
70
+ if (element.closest('[data-buzl-public-editor-ui], [aria-hidden="true"], svg, script, style, noscript, input, textarea, select')) {
71
+ return false;
72
+ }
73
+ const styles = window.getComputedStyle(element);
74
+ const box = element.getBoundingClientRect();
75
+ return styles.display !== 'none'
76
+ && styles.visibility !== 'hidden'
77
+ && box.width > 0
78
+ && box.height > 0;
79
+ });
80
+ }
81
+
82
+ function ensureOriginal(element) {
83
+ const key = element.dataset.buzlPublicEditKey;
84
+ if (!originals.has(key)) originals.set(key, element.innerHTML);
85
+ if (!histories.has(key)) histories.set(key, []);
86
+ return key;
87
+ }
88
+
89
+ function changedElements() {
90
+ return Array.from(document.querySelectorAll(editableSelector)).filter((element) => (
91
+ dirtyKeys.has(element.dataset.buzlPublicEditKey)
92
+ ));
93
+ }
94
+
95
+ function updateControls() {
96
+ const changed = changedElements();
97
+ const dirty = changed.length > 0;
98
+ toggleButton.textContent = editMode ? 'Editing On' : 'Edit Text';
99
+ const keepMenuOpen = menuOpen || editMode || dirty;
100
+ toolbar.dataset.expanded = String(keepMenuOpen);
101
+ menuButton.setAttribute('aria-expanded', String(keepMenuOpen));
102
+ menuButton.setAttribute('aria-label', editMode ? 'Buzl editing menu' : 'Open Buzl editor');
103
+ menuButton.title = editMode ? 'Buzl editing menu' : 'Open Buzl editor';
104
+ toggleButton.setAttribute('aria-pressed', String(editMode));
105
+ saveButton.disabled = !dirty || saving;
106
+ cancelButton.disabled = !dirty || saving;
107
+ const history = activeKey ? histories.get(activeKey) : histories.get(lastChangedKey);
108
+ undoButton.disabled = !history || history.length === 0 || saving;
109
+ if (dirty && !saving) setStatus(`${changed.length} unsaved change${changed.length === 1 ? '' : 's'}`, 'warning');
110
+ if (!dirty && !saving) setStatus(editMode ? 'Click highlighted text to edit' : 'Ready to edit this page');
111
+ }
112
+
113
+ function setMenuOpen(nextOpen) {
114
+ if (!nextOpen && (editMode || changedElements().length)) return;
115
+ menuOpen = Boolean(nextOpen);
116
+ updateControls();
117
+ }
118
+
119
+ function setEditMode(nextMode) {
120
+ editMode = Boolean(nextMode);
121
+ document.body.classList.toggle('buzl-public-editing', editMode);
122
+ for (const element of document.querySelectorAll(editableSelector)) {
123
+ if (editMode) {
124
+ element.setAttribute('contenteditable', 'true');
125
+ element.setAttribute('spellcheck', 'true');
126
+ element.setAttribute('data-buzl-public-edit', 'true');
127
+ } else {
128
+ element.removeAttribute('contenteditable');
129
+ element.removeAttribute('spellcheck');
130
+ element.removeAttribute('data-buzl-public-edit');
131
+ }
132
+ }
133
+ updateControls();
134
+ }
135
+
136
+ function restoreOriginals() {
137
+ for (const key of dirtyKeys) {
138
+ const element = document.querySelector(`[data-buzl-public-edit-key="${CSS.escape(key)}"]`);
139
+ if (element && originals.has(key)) element.innerHTML = originals.get(key);
140
+ histories.set(key, []);
141
+ }
142
+ dirtyKeys.clear();
143
+ activeKey = null;
144
+ lastChangedKey = null;
145
+ }
146
+
147
+ async function saveChanges() {
148
+ if (saving) return;
149
+ const changed = changedElements();
150
+ if (!changed.length) return;
151
+
152
+ saving = true;
153
+ updateControls();
154
+ setStatus('Saving…', 'warning');
155
+ try {
156
+ const response = await fetch('/__buzl/live-edit/save', {
157
+ method: 'POST',
158
+ credentials: 'same-origin',
159
+ headers: { 'Content-Type': 'application/json' },
160
+ body: JSON.stringify({
161
+ pageToken,
162
+ changes: changed.map((element) => ({
163
+ key: element.dataset.buzlPublicEditKey,
164
+ html: element.innerHTML,
165
+ })),
166
+ }),
167
+ });
168
+ const result = await response.json().catch(() => ({}));
169
+ if (!response.ok) throw new Error(result.error || 'Could not save this page');
170
+
171
+ const savedChanges = new Map(
172
+ Array.isArray(result.savedChanges)
173
+ ? result.savedChanges.map((change) => [String(change.key), String(change.html)])
174
+ : [],
175
+ );
176
+ for (const element of changed) {
177
+ const key = ensureOriginal(element);
178
+ if (savedChanges.has(key)) element.innerHTML = savedChanges.get(key);
179
+ originals.set(key, element.innerHTML);
180
+ histories.set(key, []);
181
+ dirtyKeys.delete(key);
182
+ }
183
+ activeKey = null;
184
+ lastChangedKey = null;
185
+ const successMessage = `Saved ${result.savedCount || changed.length} change${changed.length === 1 ? '' : 's'}`;
186
+ setStatus(successMessage, 'success');
187
+ setTimeout(() => {
188
+ if (!changedElements().length) updateControls();
189
+ }, 1800);
190
+ } catch (error) {
191
+ setStatus(error.message || 'Save failed', 'error');
192
+ } finally {
193
+ saving = false;
194
+ updateControls();
195
+ if (!changedElements().length) {
196
+ setStatus('Saved successfully', 'success');
197
+ }
198
+ }
199
+ }
200
+
201
+ menuButton.addEventListener('click', () => setMenuOpen(!menuOpen));
202
+ toggleButton.addEventListener('click', () => {
203
+ menuOpen = true;
204
+ setEditMode(!editMode);
205
+ });
206
+ saveButton.addEventListener('click', saveChanges);
207
+ cancelButton.addEventListener('click', () => {
208
+ if (!changedElements().length || window.confirm('Discard all unsaved text changes on this page?')) {
209
+ restoreOriginals();
210
+ setEditMode(false);
211
+ setStatus('Changes cancelled');
212
+ }
213
+ });
214
+ undoButton.addEventListener('click', () => {
215
+ const key = activeKey || lastChangedKey;
216
+ if (!key) return;
217
+ const element = document.querySelector(`[data-buzl-public-edit-key="${CSS.escape(key)}"]`);
218
+ const history = histories.get(key) || [];
219
+ if (!element || !history.length) return;
220
+ element.innerHTML = history.pop();
221
+ activeKey = key;
222
+ if (element.innerHTML === originals.get(key)) dirtyKeys.delete(key);
223
+ else dirtyKeys.add(key);
224
+ updateControls();
225
+ element.focus();
226
+ });
227
+
228
+ document.addEventListener('focusin', (event) => {
229
+ const element = event.target.closest && event.target.closest('[data-buzl-public-edit="true"]');
230
+ if (element) {
231
+ const key = element.dataset.buzlPublicEditKey;
232
+ if (!dirtyKeys.has(key)) {
233
+ originals.set(key, element.innerHTML);
234
+ histories.set(key, []);
235
+ }
236
+ activeKey = key;
237
+ }
238
+ }, true);
239
+
240
+ document.addEventListener('beforeinput', (event) => {
241
+ const element = event.target.closest && event.target.closest('[data-buzl-public-edit="true"]');
242
+ if (!editMode || !element) return;
243
+ const key = ensureOriginal(element);
244
+ const history = histories.get(key);
245
+ if (history.at(-1) !== element.innerHTML) history.push(element.innerHTML);
246
+ if (history.length > 30) history.shift();
247
+ activeKey = key;
248
+ lastChangedKey = key;
249
+ }, true);
250
+
251
+ document.addEventListener('input', (event) => {
252
+ const element = event.target.closest && event.target.closest('[data-buzl-public-edit="true"]');
253
+ if (!editMode || !element) return;
254
+ lastChangedKey = ensureOriginal(element);
255
+ if (element.innerHTML === originals.get(lastChangedKey)) dirtyKeys.delete(lastChangedKey);
256
+ else dirtyKeys.add(lastChangedKey);
257
+ updateControls();
258
+ }, true);
259
+
260
+ document.addEventListener('click', (event) => {
261
+ if (!editMode) return;
262
+ const editable = event.target.closest && event.target.closest('[data-buzl-public-edit="true"]');
263
+ if (!editable) return;
264
+ if (editable.closest('a, button, summary')) event.preventDefault();
265
+ event.stopPropagation();
266
+ }, true);
267
+
268
+ document.addEventListener('keydown', (event) => {
269
+ if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') {
270
+ event.preventDefault();
271
+ saveChanges();
272
+ }
273
+ if (editMode && event.key === 'Escape' && document.activeElement) {
274
+ document.activeElement.blur();
275
+ }
276
+ if (!editMode && event.key === 'Escape') setMenuOpen(false);
277
+ }, true);
278
+
279
+ window.addEventListener('beforeunload', (event) => {
280
+ if (!changedElements().length) return;
281
+ event.preventDefault();
282
+ event.returnValue = '';
283
+ });
284
+
285
+ updateControls();
286
+ })();