@dodlhuat/basix 1.2.0 → 1.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.
Files changed (93) hide show
  1. package/README.md +56 -1
  2. package/css/accordion.scss +86 -87
  3. package/css/alert.scss +137 -137
  4. package/css/button.scss +48 -0
  5. package/css/calendar.scss +957 -0
  6. package/css/card.scss +65 -65
  7. package/css/chart.scss +270 -157
  8. package/css/chat-bubbles.scss +134 -68
  9. package/css/chips.scss +109 -19
  10. package/css/colors.scss +32 -32
  11. package/css/datepicker.scss +336 -336
  12. package/css/defaults.scss +90 -90
  13. package/css/docs.scss +529 -0
  14. package/css/editor.scss +36 -0
  15. package/css/file-uploader.scss +1 -1
  16. package/css/flyout-menu.scss +361 -361
  17. package/css/form.scss +0 -15
  18. package/css/gallery.scss +65 -6
  19. package/css/grid.scss +41 -40
  20. package/css/group-picker.scss +345 -0
  21. package/css/guitar-chords.css +250 -250
  22. package/css/icons.scss +330 -330
  23. package/css/parameters.scss +3 -3
  24. package/css/placeholder.scss +33 -33
  25. package/css/popover.scss +206 -0
  26. package/css/progress.scss +76 -32
  27. package/css/properties.scss +51 -36
  28. package/css/push-menu.scss +302 -174
  29. package/css/reset.scss +39 -39
  30. package/css/scrollbar.scss +62 -5
  31. package/css/sidebar-nav.scss +92 -0
  32. package/css/spinner.scss +65 -65
  33. package/css/stepper.scss +48 -12
  34. package/css/style.css +3159 -254
  35. package/css/style.css.map +1 -1
  36. package/css/style.min.css +1 -1
  37. package/css/style.scss +51 -45
  38. package/css/table.scss +199 -199
  39. package/css/tabs.scss +154 -123
  40. package/css/timeline.scss +83 -38
  41. package/css/timepicker.scss +100 -5
  42. package/css/toast.scss +81 -81
  43. package/css/virtual-dropdown.scss +35 -29
  44. package/js/calendar.js +532 -0
  45. package/js/calendar.ts +706 -0
  46. package/js/chart.js +573 -257
  47. package/js/chart.ts +692 -0
  48. package/js/code-viewer.js +10 -10
  49. package/js/code-viewer.ts +188 -188
  50. package/js/datepicker.ts +627 -627
  51. package/js/docs-nav.js +204 -0
  52. package/js/dropdown.ts +179 -179
  53. package/js/editor.js +50 -6
  54. package/js/editor.ts +483 -444
  55. package/js/file-uploader.js +1 -0
  56. package/js/file-uploader.ts +1 -0
  57. package/js/flyout-menu.js +14 -14
  58. package/js/flyout-menu.ts +249 -249
  59. package/js/form-builder.js +106 -106
  60. package/js/gallery.js +14 -8
  61. package/js/gallery.ts +245 -236
  62. package/js/group-picker.js +342 -0
  63. package/js/group-picker.ts +447 -0
  64. package/js/guitar-chords.js +268 -268
  65. package/js/lazy-loader.js +121 -121
  66. package/js/modal.ts +166 -166
  67. package/js/popover.js +163 -0
  68. package/js/popover.ts +219 -0
  69. package/js/position.js +108 -0
  70. package/js/position.ts +111 -0
  71. package/js/push-menu.js +113 -0
  72. package/js/push-menu.ts +284 -145
  73. package/js/request.js +50 -50
  74. package/js/scroll.ts +47 -47
  75. package/js/scrollbar.js +13 -0
  76. package/js/scrollbar.ts +324 -307
  77. package/js/select.ts +216 -216
  78. package/js/sidebar-nav.js +41 -0
  79. package/js/sidebar-nav.ts +66 -0
  80. package/js/table.ts +452 -452
  81. package/js/tabs.ts +279 -279
  82. package/js/theme.js +17 -6
  83. package/js/theme.ts +234 -224
  84. package/js/toast.ts +137 -137
  85. package/js/tooltip.js +6 -60
  86. package/js/tooltip.ts +184 -251
  87. package/js/tsconfig.json +18 -18
  88. package/js/utils.ts +83 -83
  89. package/js/virtual-dropdown.js +25 -25
  90. package/js/virtual-dropdown.ts +365 -365
  91. package/package.json +39 -39
  92. package/js/index.js +0 -816
  93. package/js/index.ts +0 -987
package/js/editor.ts CHANGED
@@ -1,445 +1,484 @@
1
- interface EditorOptions {
2
- /** Hides the entire side panel (code/preview) permanently. Safe to use
3
- * without #code, #preview, or #sidePanel in the DOM. */
4
- simple?: boolean;
5
- }
6
-
7
- class Editor {
8
- private readonly editable: HTMLElement;
9
- private readonly code: HTMLTextAreaElement | null;
10
- private readonly preview: HTMLElement | null;
11
- private readonly sidePanel: HTMLElement | null;
12
- private readonly wordCount: HTMLElement | null;
13
- private undoStack: string[] = [];
14
- private redoStack: string[] = [];
15
-
16
- constructor(options: EditorOptions = {}) {
17
- const editable = document.getElementById('editable');
18
-
19
- if (!editable) {
20
- throw new Error('Editor: #editable element not found');
21
- }
22
-
23
- this.editable = editable;
24
- this.wordCount = document.getElementById('wordCount');
25
-
26
- if (options.simple) {
27
- this.code = null;
28
- this.preview = null;
29
- this.sidePanel = document.getElementById('sidePanel');
30
- this.sidePanel?.classList.add('hidden');
31
- } else {
32
- const code = document.getElementById('code') as HTMLTextAreaElement;
33
- const preview = document.getElementById('preview');
34
- const sidePanel = document.getElementById('sidePanel');
35
-
36
- if (!code || !preview || !sidePanel) {
37
- throw new Error('Editor: #code, #preview and #sidePanel are required unless simple: true');
38
- }
39
-
40
- this.code = code;
41
- this.preview = preview;
42
- this.sidePanel = sidePanel;
43
- this.sidePanel.classList.add('hidden');
44
- }
45
-
46
- this.bindToolbar();
47
- this.bindActions();
48
- this.bindKeyboard();
49
- this.bindEditable();
50
- this.bindTabs();
51
- this.syncViews();
52
- this.saveState();
53
- }
54
-
55
- private bindToolbar(): void {
56
- document.querySelectorAll<HTMLElement>('[data-cmd]').forEach(btn => {
57
- btn.addEventListener('click', () => {
58
- const cmd = btn.dataset.cmd!;
59
- const val = btn.dataset.value ?? null;
60
- this.exec(cmd, val);
61
- this.editable.focus();
62
- });
63
- });
64
- }
65
-
66
- private bindActions(): void {
67
- document.getElementById('linkBtn')?.addEventListener('click', () => {
68
- const url = prompt('Enter URL:', 'https://');
69
- if (url) this.exec('createLink', url);
70
- });
71
-
72
- const imageFile = document.getElementById('imageFile') as HTMLInputElement;
73
- document.getElementById('imageBtn')?.addEventListener('click', () => imageFile.click());
74
- imageFile?.addEventListener('change', () => {
75
- const file = imageFile.files?.[0];
76
- if (!file) return;
77
- const reader = new FileReader();
78
- reader.onload = () => {
79
- if (typeof reader.result === 'string') {
80
- this.insertImage(reader.result);
81
- }
82
- };
83
- reader.readAsDataURL(file);
84
- imageFile.value = '';
85
- });
86
-
87
- document.getElementById('cleanBtn')?.addEventListener('click', () => {
88
- const sel = window.getSelection();
89
- if (!sel || sel.rangeCount === 0) return;
90
- const range = sel.getRangeAt(0);
91
- const text = range.toString();
92
- range.deleteContents();
93
- range.insertNode(document.createTextNode(text));
94
- this.onContentChange();
95
- });
96
-
97
- document.getElementById('undoBtn')?.addEventListener('click', () => this.undo());
98
- document.getElementById('redoBtn')?.addEventListener('click', () => this.redo());
99
-
100
- document.getElementById('toggleCodeBtn')?.addEventListener('click', () => {
101
- this.sidePanel?.classList.toggle('hidden');
102
- this.syncViews();
103
- });
104
-
105
- // Code action buttons — matched by position within .code-actions
106
- if (this.code) {
107
- const code = this.code;
108
- const codeActions = document.querySelectorAll<HTMLButtonElement>('.code-actions button');
109
- codeActions[0]?.addEventListener('click', () => {
110
- this.editable.innerHTML = this.sanitizeHTML(code.value);
111
- this.onContentChange();
112
- });
113
- codeActions[1]?.addEventListener('click', () => {
114
- code.value = this.sanitizeHTML(code.value);
115
- this.editable.innerHTML = code.value;
116
- this.onContentChange();
117
- });
118
- codeActions[2]?.addEventListener('click', () => {
119
- code.value = code.value
120
- .replace(/\n/g, '')
121
- .replace(/>\s+</g, '><')
122
- .trim();
123
- });
124
- }
125
-
126
- const saveBtn = document.getElementById('saveBtn');
127
- saveBtn?.addEventListener('click', () => this.downloadHTML());
128
-
129
- document.getElementById('clearBtn')?.addEventListener('click', () => {
130
- if (confirm('Clear all content?')) {
131
- this.editable.innerHTML = '';
132
- this.onContentChange();
133
- }
134
- });
135
- }
136
-
137
- private bindKeyboard(): void {
138
- const saveBtn = document.getElementById('saveBtn');
139
-
140
- window.addEventListener('keydown', (e: KeyboardEvent) => {
141
- const mod = e.ctrlKey || e.metaKey;
142
- if (!mod) return;
143
-
144
- const key = e.key.toLowerCase();
145
-
146
- if (key === 'b') { e.preventDefault(); this.exec('bold'); }
147
- else if (key === 'i') { e.preventDefault(); this.exec('italic'); }
148
- else if (key === 'u') { e.preventDefault(); this.exec('underline'); }
149
- else if (key === 'k') {
150
- e.preventDefault();
151
- const url = prompt('Enter URL:', 'https://');
152
- if (url) this.exec('createLink', url);
153
- }
154
- else if (key === 's') { e.preventDefault(); saveBtn?.click(); }
155
- else if (key === 'z' && !e.shiftKey) { e.preventDefault(); this.undo(); }
156
- else if (key === 'y' || (key === 'z' && e.shiftKey)) { e.preventDefault(); this.redo(); }
157
- });
158
- }
159
-
160
- private bindEditable(): void {
161
- this.editable.addEventListener('input', () => this.onContentChange());
162
-
163
- this.editable.addEventListener('paste', (e: ClipboardEvent) => {
164
- e.preventDefault();
165
- const text = e.clipboardData?.getData('text/plain') ?? '';
166
- this.insertText(text);
167
- });
168
-
169
- this.editable.addEventListener('keyup', () => this.refreshActiveState());
170
- this.editable.addEventListener('mouseup', () => this.refreshActiveState());
171
- }
172
-
173
- private bindTabs(): void {
174
- document.querySelectorAll<HTMLElement>('.side-tab[data-tab]').forEach(tab => {
175
- tab.addEventListener('click', () => {
176
- const targetId = tab.dataset.tab!;
177
-
178
- document.querySelectorAll('.side-tab').forEach(t => t.classList.remove('active'));
179
- document.querySelectorAll('.side-panel').forEach(p => p.classList.remove('active'));
180
-
181
- tab.classList.add('active');
182
- document.getElementById(targetId)?.classList.add('active');
183
- });
184
- });
185
- }
186
-
187
- private onContentChange(): void {
188
- this.saveState();
189
- this.syncViews();
190
- }
191
-
192
- private syncViews(): void {
193
- if (this.code) this.code.value = this.editable.innerHTML.trim();
194
- if (this.preview) this.preview.innerHTML = this.editable.innerHTML;
195
- this.updateWordCount();
196
- }
197
-
198
- private updateWordCount(): void {
199
- if (!this.wordCount) return;
200
- const text = this.editable.innerText || '';
201
- const words = text.trim().split(/\s+/).filter(w => w.length > 0);
202
- const count = words.length;
203
- this.wordCount.textContent = `${count} word${count !== 1 ? 's' : ''}`;
204
- }
205
-
206
- private saveState(): void {
207
- this.undoStack.push(this.editable.innerHTML);
208
- if (this.undoStack.length > 100) this.undoStack.shift();
209
- this.redoStack = [];
210
- }
211
-
212
- private undo(): void {
213
- if (this.undoStack.length <= 1) return;
214
- this.redoStack.push(this.undoStack.pop()!);
215
- this.editable.innerHTML = this.undoStack[this.undoStack.length - 1];
216
- this.syncViews();
217
- }
218
-
219
- private redo(): void {
220
- if (this.redoStack.length === 0) return;
221
- const state = this.redoStack.pop()!;
222
- this.undoStack.push(state);
223
- this.editable.innerHTML = state;
224
- this.syncViews();
225
- }
226
-
227
- private exec(command: string, value: string | null = null): void {
228
- switch (command) {
229
- case 'bold': this.toggleInlineStyle('strong'); break;
230
- case 'italic': this.toggleInlineStyle('em'); break;
231
- case 'underline': this.toggleInlineStyle('u'); break;
232
- case 'strikeThrough': this.toggleInlineStyle('s'); break;
233
- case 'createLink': if (value) this.createLink(value); break;
234
- case 'formatBlock': if (value) this.formatBlock(value); break;
235
- case 'insertUnorderedList': this.insertList('ul'); break;
236
- case 'insertOrderedList': this.insertList('ol'); break;
237
- }
238
- }
239
-
240
- private insertText(text: string): void {
241
- const sel = window.getSelection();
242
- if (!sel || sel.rangeCount === 0) return;
243
-
244
- const range = sel.getRangeAt(0);
245
- range.deleteContents();
246
- range.insertNode(document.createTextNode(text));
247
- range.collapse(false);
248
- sel.removeAllRanges();
249
- sel.addRange(range);
250
-
251
- this.onContentChange();
252
- }
253
-
254
- private insertImage(dataUrl: string): void {
255
- const sel = window.getSelection();
256
- if (!sel || sel.rangeCount === 0) return;
257
-
258
- const range = sel.getRangeAt(0);
259
- const img = document.createElement('img');
260
- img.src = dataUrl;
261
- img.style.maxWidth = '100%';
262
- range.deleteContents();
263
- range.insertNode(img);
264
-
265
- range.setStartAfter(img);
266
- range.collapse(true);
267
- sel.removeAllRanges();
268
- sel.addRange(range);
269
-
270
- this.onContentChange();
271
- }
272
-
273
- private toggleInlineStyle(tagName: string): void {
274
- const sel = window.getSelection();
275
- if (!sel || sel.rangeCount === 0) return;
276
-
277
- const range = sel.getRangeAt(0);
278
- const container = range.commonAncestorContainer;
279
- let current: HTMLElement | null = container.nodeType === Node.TEXT_NODE
280
- ? container.parentElement
281
- : container as HTMLElement;
282
-
283
- let wrapper: HTMLElement | null = null;
284
- while (current && current !== this.editable) {
285
- if (current.tagName === tagName.toUpperCase()) {
286
- wrapper = current;
287
- break;
288
- }
289
- current = current.parentElement;
290
- }
291
-
292
- if (wrapper) {
293
- const parent = wrapper.parentNode;
294
- while (wrapper.firstChild) {
295
- parent?.insertBefore(wrapper.firstChild, wrapper);
296
- }
297
- parent?.removeChild(wrapper);
298
- } else {
299
- const contents = range.extractContents();
300
- const el = document.createElement(tagName);
301
- el.appendChild(contents);
302
- range.insertNode(el);
303
- range.selectNodeContents(el);
304
- sel.removeAllRanges();
305
- sel.addRange(range);
306
- }
307
-
308
- this.onContentChange();
309
- }
310
-
311
- private createLink(url: string): void {
312
- const sel = window.getSelection();
313
- if (!sel || sel.rangeCount === 0) return;
314
-
315
- const range = sel.getRangeAt(0);
316
- const contents = range.extractContents();
317
- const link = document.createElement('a');
318
- link.href = url;
319
- link.appendChild(contents);
320
- range.insertNode(link);
321
-
322
- this.onContentChange();
323
- }
324
-
325
- private formatBlock(tag: string): void {
326
- const sel = window.getSelection();
327
- if (!sel || sel.rangeCount === 0) return;
328
-
329
- const range = sel.getRangeAt(0);
330
- const container = range.commonAncestorContainer;
331
- let blockElement: HTMLElement | null = container.nodeType === Node.TEXT_NODE
332
- ? container.parentElement
333
- : container as HTMLElement;
334
-
335
- while (blockElement && blockElement !== this.editable && blockElement.parentElement !== this.editable) {
336
- blockElement = blockElement.parentElement;
337
- }
338
-
339
- if (blockElement && blockElement !== this.editable) {
340
- const newBlock = document.createElement(tag);
341
- newBlock.innerHTML = blockElement.innerHTML;
342
- blockElement.parentNode?.replaceChild(newBlock, blockElement);
343
- this.onContentChange();
344
- }
345
- }
346
-
347
- private insertList(listTag: string): void {
348
- const sel = window.getSelection();
349
- if (!sel || sel.rangeCount === 0) return;
350
-
351
- const range = sel.getRangeAt(0);
352
- const text = range.toString();
353
-
354
- const list = document.createElement(listTag);
355
- const lines = text ? text.split('\n').filter(l => l.trim()) : [''];
356
-
357
- for (const line of lines) {
358
- const li = document.createElement('li');
359
- li.textContent = line.trim() || '\u200B';
360
- list.appendChild(li);
361
- }
362
-
363
- range.deleteContents();
364
- range.insertNode(list);
365
-
366
- const lastLi = list.lastElementChild;
367
- if (lastLi) {
368
- range.setStart(lastLi, lastLi.childNodes.length);
369
- range.collapse(true);
370
- sel.removeAllRanges();
371
- sel.addRange(range);
372
- }
373
-
374
- this.onContentChange();
375
- }
376
-
377
- private sanitizeHTML(html: string): string {
378
- const parser = new DOMParser();
379
- const doc = parser.parseFromString(html, 'text/html');
380
-
381
- doc.querySelectorAll('script, style, iframe, object, embed').forEach(el => el.remove());
382
-
383
- doc.querySelectorAll('*').forEach(el => {
384
- for (const attr of Array.from(el.attributes)) {
385
- if (attr.name.startsWith('on') || attr.value.trim().toLowerCase().startsWith('javascript:')) {
386
- el.removeAttribute(attr.name);
387
- }
388
- }
389
- });
390
-
391
- return doc.body.innerHTML;
392
- }
393
-
394
- private downloadHTML(): void {
395
- const content = this.sanitizeHTML(this.editable.innerHTML);
396
- const html = `<!doctype html>
397
- <html lang="en">
398
- <head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Export</title></head>
399
- <body>
400
- ${content}
401
- </body>
402
- </html>`;
403
- const blob = new Blob([html], { type: 'text/html' });
404
- const a = document.createElement('a');
405
- a.href = URL.createObjectURL(blob);
406
- a.download = 'document.html';
407
- a.click();
408
- URL.revokeObjectURL(a.href);
409
- }
410
-
411
- private refreshActiveState(): void {
412
- const sel = window.getSelection();
413
- if (!sel || sel.rangeCount === 0) return;
414
-
415
- const range = sel.getRangeAt(0);
416
- const container = range.commonAncestorContainer;
417
- const element = container.nodeType === Node.TEXT_NODE
418
- ? container.parentElement
419
- : container as HTMLElement;
420
-
421
- document.querySelectorAll<HTMLElement>('[data-cmd]').forEach(btn => {
422
- const cmd = btn.dataset.cmd;
423
- let active = false;
424
-
425
- let current: HTMLElement | null = element;
426
- while (current && current !== this.editable) {
427
- const tag = current.tagName?.toLowerCase();
428
- if (
429
- (cmd === 'bold' && (tag === 'strong' || tag === 'b')) ||
430
- (cmd === 'italic' && (tag === 'em' || tag === 'i')) ||
431
- (cmd === 'underline' && tag === 'u') ||
432
- (cmd === 'strikeThrough' && tag === 's')
433
- ) {
434
- active = true;
435
- break;
436
- }
437
- current = current.parentElement;
438
- }
439
-
440
- btn.classList.toggle('active', active);
441
- });
442
- }
443
- }
444
-
1
+ interface EditorOptions {
2
+ /** Hides the entire side panel (code/preview) permanently. Safe to use
3
+ * without #code, #preview, or #sidePanel in the DOM. */
4
+ simple?: boolean;
5
+ }
6
+
7
+ class Editor {
8
+ private readonly editable: HTMLElement;
9
+ private readonly code: HTMLTextAreaElement | null;
10
+ private readonly preview: HTMLElement | null;
11
+ private readonly sidePanel: HTMLElement | null;
12
+ private readonly wordCount: HTMLElement | null;
13
+ private undoStack: string[] = [];
14
+ private redoStack: string[] = [];
15
+
16
+ constructor(options: EditorOptions = {}) {
17
+ const editable = document.getElementById('editable');
18
+
19
+ if (!editable) {
20
+ throw new Error('Editor: #editable element not found');
21
+ }
22
+
23
+ this.editable = editable;
24
+ this.wordCount = document.getElementById('wordCount');
25
+
26
+ if (options.simple) {
27
+ this.code = null;
28
+ this.preview = null;
29
+ this.sidePanel = document.getElementById('sidePanel');
30
+ this.sidePanel?.classList.add('hidden');
31
+ } else {
32
+ const code = document.getElementById('code') as HTMLTextAreaElement;
33
+ const preview = document.getElementById('preview');
34
+ const sidePanel = document.getElementById('sidePanel');
35
+
36
+ if (!code || !preview || !sidePanel) {
37
+ throw new Error('Editor: #code, #preview and #sidePanel are required unless simple: true');
38
+ }
39
+
40
+ this.code = code;
41
+ this.preview = preview;
42
+ this.sidePanel = sidePanel;
43
+ this.sidePanel.classList.add('hidden');
44
+ }
45
+
46
+ this.bindToolbar();
47
+ this.bindActions();
48
+ this.bindKeyboard();
49
+ this.bindEditable();
50
+ this.bindTabs();
51
+ this.syncViews();
52
+ this.saveState();
53
+ }
54
+
55
+ private bindToolbar(): void {
56
+ document.querySelectorAll<HTMLElement>('[data-cmd]').forEach(btn => {
57
+ btn.addEventListener('click', () => {
58
+ const cmd = btn.dataset.cmd!;
59
+ const val = btn.dataset.value ?? null;
60
+ this.exec(cmd, val);
61
+ this.editable.focus();
62
+ });
63
+ });
64
+ }
65
+
66
+ private bindActions(): void {
67
+ document.getElementById('linkBtn')?.addEventListener('click', () => {
68
+ const url = prompt('Enter URL:', 'https://');
69
+ if (url) this.exec('createLink', url);
70
+ });
71
+
72
+ const imageFile = document.getElementById('imageFile') as HTMLInputElement;
73
+ document.getElementById('imageBtn')?.addEventListener('click', () => imageFile.click());
74
+ imageFile?.addEventListener('change', () => {
75
+ const file = imageFile.files?.[0];
76
+ if (!file) return;
77
+ const reader = new FileReader();
78
+ reader.onload = () => {
79
+ if (typeof reader.result === 'string') {
80
+ this.insertImage(reader.result);
81
+ }
82
+ };
83
+ reader.readAsDataURL(file);
84
+ imageFile.value = '';
85
+ });
86
+
87
+ document.getElementById('cleanBtn')?.addEventListener('click', () => {
88
+ const sel = window.getSelection();
89
+ if (!sel || sel.rangeCount === 0) return;
90
+ const range = sel.getRangeAt(0);
91
+ const text = range.toString();
92
+ range.deleteContents();
93
+ range.insertNode(document.createTextNode(text));
94
+ this.onContentChange();
95
+ });
96
+
97
+ document.getElementById('undoBtn')?.addEventListener('click', () => this.undo());
98
+ document.getElementById('redoBtn')?.addEventListener('click', () => this.redo());
99
+
100
+ document.getElementById('toggleCodeBtn')?.addEventListener('click', () => {
101
+ this.sidePanel?.classList.toggle('hidden');
102
+ this.syncViews();
103
+ });
104
+
105
+ // Code action buttons — matched by position within .code-actions
106
+ if (this.code) {
107
+ const code = this.code;
108
+ const codeActions = document.querySelectorAll<HTMLButtonElement>('.code-actions button');
109
+ codeActions[0]?.addEventListener('click', () => {
110
+ this.editable.innerHTML = this.sanitizeHTML(code.value);
111
+ this.onContentChange();
112
+ });
113
+ codeActions[1]?.addEventListener('click', () => {
114
+ code.value = this.sanitizeHTML(code.value);
115
+ this.editable.innerHTML = code.value;
116
+ this.onContentChange();
117
+ });
118
+ codeActions[2]?.addEventListener('click', () => {
119
+ code.value = code.value
120
+ .replace(/\n/g, '')
121
+ .replace(/>\s+</g, '><')
122
+ .trim();
123
+ });
124
+ }
125
+
126
+ const saveBtn = document.getElementById('saveBtn');
127
+ saveBtn?.addEventListener('click', () => this.downloadHTML());
128
+
129
+ document.getElementById('clearBtn')?.addEventListener('click', () => {
130
+ if (confirm('Clear all content?')) {
131
+ this.editable.innerHTML = '';
132
+ this.onContentChange();
133
+ }
134
+ });
135
+ }
136
+
137
+ private bindKeyboard(): void {
138
+ const saveBtn = document.getElementById('saveBtn');
139
+
140
+ window.addEventListener('keydown', (e: KeyboardEvent) => {
141
+ const mod = e.ctrlKey || e.metaKey;
142
+ if (!mod) return;
143
+
144
+ const key = e.key.toLowerCase();
145
+
146
+ if (key === 'b') { e.preventDefault(); this.exec('bold'); }
147
+ else if (key === 'i') { e.preventDefault(); this.exec('italic'); }
148
+ else if (key === 'u') { e.preventDefault(); this.exec('underline'); }
149
+ else if (key === 'k') {
150
+ e.preventDefault();
151
+ const url = prompt('Enter URL:', 'https://');
152
+ if (url) this.exec('createLink', url);
153
+ }
154
+ else if (key === 's') { e.preventDefault(); saveBtn?.click(); }
155
+ else if (key === 'z' && !e.shiftKey) { e.preventDefault(); this.undo(); }
156
+ else if (key === 'y' || (key === 'z' && e.shiftKey)) { e.preventDefault(); this.redo(); }
157
+ });
158
+ }
159
+
160
+ private bindEditable(): void {
161
+ this.editable.addEventListener('input', () => this.onContentChange());
162
+
163
+ this.editable.addEventListener('paste', (e: ClipboardEvent) => {
164
+ e.preventDefault();
165
+ const text = e.clipboardData?.getData('text/plain') ?? '';
166
+ this.insertText(text);
167
+ });
168
+
169
+ this.editable.addEventListener('keyup', () => this.refreshActiveState());
170
+ this.editable.addEventListener('mouseup', () => this.refreshActiveState());
171
+ }
172
+
173
+ private bindTabs(): void {
174
+ document.querySelectorAll<HTMLElement>('.side-tab[data-tab]').forEach(tab => {
175
+ tab.addEventListener('click', () => {
176
+ const targetId = tab.dataset.tab!;
177
+
178
+ document.querySelectorAll('.side-tab').forEach(t => t.classList.remove('active'));
179
+ document.querySelectorAll('.side-panel').forEach(p => p.classList.remove('active'));
180
+
181
+ tab.classList.add('active');
182
+ document.getElementById(targetId)?.classList.add('active');
183
+ });
184
+ });
185
+ }
186
+
187
+ private onContentChange(): void {
188
+ this.saveState();
189
+ this.syncViews();
190
+ }
191
+
192
+ private syncViews(): void {
193
+ if (this.code) this.code.value = this.editable.innerHTML.trim();
194
+ if (this.preview) this.preview.innerHTML = this.editable.innerHTML;
195
+ this.updateWordCount();
196
+ }
197
+
198
+ private updateWordCount(): void {
199
+ if (!this.wordCount) return;
200
+ const text = this.editable.innerText || '';
201
+ const words = text.trim().split(/\s+/).filter(w => w.length > 0);
202
+ const count = words.length;
203
+ this.wordCount.textContent = `${count} word${count !== 1 ? 's' : ''}`;
204
+ }
205
+
206
+ private saveState(): void {
207
+ this.undoStack.push(this.editable.innerHTML);
208
+ if (this.undoStack.length > 100) this.undoStack.shift();
209
+ this.redoStack = [];
210
+ }
211
+
212
+ private undo(): void {
213
+ if (this.undoStack.length <= 1) return;
214
+ this.redoStack.push(this.undoStack.pop()!);
215
+ this.editable.innerHTML = this.undoStack[this.undoStack.length - 1];
216
+ this.syncViews();
217
+ }
218
+
219
+ private redo(): void {
220
+ if (this.redoStack.length === 0) return;
221
+ const state = this.redoStack.pop()!;
222
+ this.undoStack.push(state);
223
+ this.editable.innerHTML = state;
224
+ this.syncViews();
225
+ }
226
+
227
+ private exec(command: string, value: string | null = null): void {
228
+ switch (command) {
229
+ case 'bold': this.toggleInlineStyle('strong'); break;
230
+ case 'italic': this.toggleInlineStyle('em'); break;
231
+ case 'underline': this.toggleInlineStyle('u'); break;
232
+ case 'strikeThrough': this.toggleInlineStyle('s'); break;
233
+ case 'createLink': if (value) this.createLink(value); break;
234
+ case 'formatBlock': if (value) this.formatBlock(value); break;
235
+ case 'insertUnorderedList': this.insertList('ul'); break;
236
+ case 'insertOrderedList': this.insertList('ol'); break;
237
+ case 'justifyLeft':
238
+ case 'justifyCenter':
239
+ case 'justifyRight': this.setAlignment(command); break;
240
+ case 'foreColor': if (value) this.setForeColor(value); break;
241
+ }
242
+ }
243
+
244
+ private insertText(text: string): void {
245
+ const sel = window.getSelection();
246
+ if (!sel || sel.rangeCount === 0) return;
247
+
248
+ const range = sel.getRangeAt(0);
249
+ range.deleteContents();
250
+ range.insertNode(document.createTextNode(text));
251
+ range.collapse(false);
252
+ sel.removeAllRanges();
253
+ sel.addRange(range);
254
+
255
+ this.onContentChange();
256
+ }
257
+
258
+ private insertImage(dataUrl: string): void {
259
+ const sel = window.getSelection();
260
+ if (!sel || sel.rangeCount === 0) return;
261
+
262
+ const range = sel.getRangeAt(0);
263
+ const img = document.createElement('img');
264
+ img.src = dataUrl;
265
+ img.style.maxWidth = '100%';
266
+ range.deleteContents();
267
+ range.insertNode(img);
268
+
269
+ range.setStartAfter(img);
270
+ range.collapse(true);
271
+ sel.removeAllRanges();
272
+ sel.addRange(range);
273
+
274
+ this.onContentChange();
275
+ }
276
+
277
+ private toggleInlineStyle(tagName: string): void {
278
+ const sel = window.getSelection();
279
+ if (!sel || sel.rangeCount === 0) return;
280
+
281
+ const range = sel.getRangeAt(0);
282
+ const container = range.commonAncestorContainer;
283
+ let current: HTMLElement | null = container.nodeType === Node.TEXT_NODE
284
+ ? container.parentElement
285
+ : container as HTMLElement;
286
+
287
+ let wrapper: HTMLElement | null = null;
288
+ while (current && current !== this.editable) {
289
+ if (current.tagName === tagName.toUpperCase()) {
290
+ wrapper = current;
291
+ break;
292
+ }
293
+ current = current.parentElement;
294
+ }
295
+
296
+ if (wrapper) {
297
+ const parent = wrapper.parentNode;
298
+ while (wrapper.firstChild) {
299
+ parent?.insertBefore(wrapper.firstChild, wrapper);
300
+ }
301
+ parent?.removeChild(wrapper);
302
+ } else {
303
+ const contents = range.extractContents();
304
+ const el = document.createElement(tagName);
305
+ el.appendChild(contents);
306
+ range.insertNode(el);
307
+ range.selectNodeContents(el);
308
+ sel.removeAllRanges();
309
+ sel.addRange(range);
310
+ }
311
+
312
+ this.onContentChange();
313
+ }
314
+
315
+ private createLink(url: string): void {
316
+ const sel = window.getSelection();
317
+ if (!sel || sel.rangeCount === 0) return;
318
+
319
+ const range = sel.getRangeAt(0);
320
+ const contents = range.extractContents();
321
+ const link = document.createElement('a');
322
+ link.href = url;
323
+ link.appendChild(contents);
324
+ range.insertNode(link);
325
+
326
+ this.onContentChange();
327
+ }
328
+
329
+ private formatBlock(tag: string): void {
330
+ const sel = window.getSelection();
331
+ if (!sel || sel.rangeCount === 0) return;
332
+
333
+ const range = sel.getRangeAt(0);
334
+ const container = range.commonAncestorContainer;
335
+ let blockElement: HTMLElement | null = container.nodeType === Node.TEXT_NODE
336
+ ? container.parentElement
337
+ : container as HTMLElement;
338
+
339
+ while (blockElement && blockElement !== this.editable && blockElement.parentElement !== this.editable) {
340
+ blockElement = blockElement.parentElement;
341
+ }
342
+
343
+ if (blockElement && blockElement !== this.editable) {
344
+ const newBlock = document.createElement(tag);
345
+ newBlock.innerHTML = blockElement.innerHTML;
346
+ blockElement.parentNode?.replaceChild(newBlock, blockElement);
347
+ this.onContentChange();
348
+ }
349
+ }
350
+
351
+ private insertList(listTag: string): void {
352
+ const sel = window.getSelection();
353
+ if (!sel || sel.rangeCount === 0) return;
354
+
355
+ const range = sel.getRangeAt(0);
356
+ const text = range.toString();
357
+
358
+ const list = document.createElement(listTag);
359
+ const lines = text ? text.split('\n').filter(l => l.trim()) : [''];
360
+
361
+ for (const line of lines) {
362
+ const li = document.createElement('li');
363
+ li.textContent = line.trim() || '\u200B';
364
+ list.appendChild(li);
365
+ }
366
+
367
+ range.deleteContents();
368
+ range.insertNode(list);
369
+
370
+ const lastLi = list.lastElementChild;
371
+ if (lastLi) {
372
+ range.setStart(lastLi, lastLi.childNodes.length);
373
+ range.collapse(true);
374
+ sel.removeAllRanges();
375
+ sel.addRange(range);
376
+ }
377
+
378
+ this.onContentChange();
379
+ }
380
+
381
+ private setAlignment(cmd: string): void {
382
+ const align: Record<string, string> = {
383
+ justifyLeft: 'left', justifyCenter: 'center', justifyRight: 'right',
384
+ };
385
+ const sel = window.getSelection();
386
+ if (!sel || sel.rangeCount === 0) return;
387
+ const container = sel.getRangeAt(0).commonAncestorContainer;
388
+ let block: HTMLElement | null = container.nodeType === Node.TEXT_NODE
389
+ ? container.parentElement
390
+ : container as HTMLElement;
391
+ while (block && block !== this.editable && block.parentElement !== this.editable) {
392
+ block = block.parentElement;
393
+ }
394
+ if (block && block !== this.editable) {
395
+ block.style.textAlign = align[cmd];
396
+ this.onContentChange();
397
+ }
398
+ }
399
+
400
+ private setForeColor(color: string): void {
401
+ const sel = window.getSelection();
402
+ if (!sel || sel.rangeCount === 0) return;
403
+ const range = sel.getRangeAt(0);
404
+ if (range.collapsed) return;
405
+ const span = document.createElement('span');
406
+ span.style.color = color;
407
+ span.appendChild(range.extractContents());
408
+ range.insertNode(span);
409
+ range.selectNodeContents(span);
410
+ sel.removeAllRanges();
411
+ sel.addRange(range);
412
+ this.onContentChange();
413
+ }
414
+
415
+ private sanitizeHTML(html: string): string {
416
+ const parser = new DOMParser();
417
+ const doc = parser.parseFromString(html, 'text/html');
418
+
419
+ doc.querySelectorAll('script, style, iframe, object, embed').forEach(el => el.remove());
420
+
421
+ doc.querySelectorAll('*').forEach(el => {
422
+ for (const attr of Array.from(el.attributes)) {
423
+ if (attr.name.startsWith('on') || attr.value.trim().toLowerCase().startsWith('javascript:')) {
424
+ el.removeAttribute(attr.name);
425
+ }
426
+ }
427
+ });
428
+
429
+ return doc.body.innerHTML;
430
+ }
431
+
432
+ private downloadHTML(): void {
433
+ const content = this.sanitizeHTML(this.editable.innerHTML);
434
+ const html = `<!doctype html>
435
+ <html lang="en">
436
+ <head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Export</title></head>
437
+ <body>
438
+ ${content}
439
+ </body>
440
+ </html>`;
441
+ const blob = new Blob([html], { type: 'text/html' });
442
+ const a = document.createElement('a');
443
+ a.href = URL.createObjectURL(blob);
444
+ a.download = 'document.html';
445
+ a.click();
446
+ URL.revokeObjectURL(a.href);
447
+ }
448
+
449
+ private refreshActiveState(): void {
450
+ const sel = window.getSelection();
451
+ if (!sel || sel.rangeCount === 0) return;
452
+
453
+ const range = sel.getRangeAt(0);
454
+ const container = range.commonAncestorContainer;
455
+ const element = container.nodeType === Node.TEXT_NODE
456
+ ? container.parentElement
457
+ : container as HTMLElement;
458
+
459
+ document.querySelectorAll<HTMLElement>('[data-cmd]').forEach(btn => {
460
+ const cmd = btn.dataset.cmd;
461
+ let active = false;
462
+
463
+ let current: HTMLElement | null = element;
464
+ while (current && current !== this.editable) {
465
+ const tag = current.tagName?.toLowerCase();
466
+ if (
467
+ (cmd === 'bold' && (tag === 'strong' || tag === 'b')) ||
468
+ (cmd === 'italic' && (tag === 'em' || tag === 'i')) ||
469
+ (cmd === 'underline' && tag === 'u') ||
470
+ (cmd === 'strikeThrough' && tag === 's')
471
+ ) {
472
+ active = true;
473
+ break;
474
+ }
475
+ current = current.parentElement;
476
+ }
477
+
478
+ btn.classList.toggle('active', active);
479
+ });
480
+
481
+ }
482
+ }
483
+
445
484
  export { Editor };