neiki-editor 2.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,3533 @@
1
+ /**
2
+ * NeikiEditor - A Modern WYSIWYG Editor
3
+ * Version: 2.2.1
4
+ *
5
+ * A lightweight, feature-rich text editor with support for:
6
+ * - Rich text formatting (bold, italic, underline, etc.)
7
+ * - Lists (ordered, unordered)
8
+ * - Links and images
9
+ * - Tables
10
+ * - Code blocks
11
+ * - Undo/Redo
12
+ * - Keyboard shortcuts
13
+ */
14
+
15
+ (function (global) {
16
+ 'use strict';
17
+
18
+ // ============================================
19
+ // SECTION 1: CONFIGURATION & CONSTANTS
20
+ // ============================================
21
+
22
+ // ============================================
23
+ // TRANSLATIONS / i18n
24
+ // ============================================
25
+
26
+ const TRANSLATIONS = {
27
+ en: {
28
+ // Toolbar tooltips
29
+ 'toolbar.undo': 'Undo (Ctrl+Z)',
30
+ 'toolbar.redo': 'Redo (Ctrl+Y)',
31
+ 'toolbar.bold': 'Bold (Ctrl+B)',
32
+ 'toolbar.italic': 'Italic (Ctrl+I)',
33
+ 'toolbar.underline': 'Underline (Ctrl+U)',
34
+ 'toolbar.strikethrough': 'Strikethrough',
35
+ 'toolbar.heading': 'Heading',
36
+ 'toolbar.fontSize': 'Font Size',
37
+ 'toolbar.fontFamily': 'Font Family',
38
+ 'toolbar.foreColor': 'Text Color',
39
+ 'toolbar.backColor': 'Background Color',
40
+ 'toolbar.alignLeft': 'Align Left',
41
+ 'toolbar.alignCenter': 'Align Center',
42
+ 'toolbar.alignRight': 'Align Right',
43
+ 'toolbar.alignJustify': 'Justify',
44
+ 'toolbar.bulletList': 'Bullet List',
45
+ 'toolbar.numberedList': 'Numbered List',
46
+ 'toolbar.indent': 'Increase Indent',
47
+ 'toolbar.outdent': 'Decrease Indent',
48
+ 'toolbar.link': 'Insert Link (Ctrl+K)',
49
+ 'toolbar.image': 'Insert Image',
50
+ 'toolbar.table': 'Insert Table',
51
+ 'toolbar.blockquote': 'Blockquote',
52
+ 'toolbar.viewCode': 'View Code (Toggle HTML)',
53
+ 'toolbar.horizontalRule': 'Horizontal Line',
54
+ 'toolbar.subscript': 'Subscript',
55
+ 'toolbar.superscript': 'Superscript',
56
+ 'toolbar.removeFormat': 'Remove Formatting',
57
+ 'toolbar.findReplace': 'Find & Replace',
58
+ 'toolbar.emoji': 'Insert Emoji',
59
+ 'toolbar.specialChars': 'Special Characters',
60
+ 'toolbar.fullscreen': 'Fullscreen',
61
+ 'toolbar.autosave': 'Toggle Autosave',
62
+ 'toolbar.themeToggle': 'Toggle Theme',
63
+ 'toolbar.print': 'Print',
64
+ 'toolbar.insert': 'Insert',
65
+ 'toolbar.moreOptions': 'More options',
66
+ 'toolbar.decreaseFontSize': 'Decrease font size',
67
+ 'toolbar.increaseFontSize': 'Increase font size',
68
+
69
+ // Headings select
70
+ 'heading.paragraph': 'Paragraph',
71
+ 'heading.h1': 'Heading 1',
72
+ 'heading.h2': 'Heading 2',
73
+ 'heading.h3': 'Heading 3',
74
+ 'heading.h4': 'Heading 4',
75
+ 'heading.h5': 'Heading 5',
76
+ 'heading.h6': 'Heading 6',
77
+
78
+ // Font families
79
+ 'font.sansSerif': 'Sans Serif',
80
+ 'font.serif': 'Serif',
81
+ 'font.monospace': 'Monospace',
82
+ 'font.cursive': 'Cursive',
83
+
84
+ // Insert dropdown
85
+ 'insert.link': 'Link',
86
+ 'insert.image': 'Image',
87
+ 'insert.table': 'Table',
88
+ 'insert.emoji': 'Emoji',
89
+ 'insert.symbol': 'Symbol',
90
+
91
+ // More menu
92
+ 'menu.save': 'Save',
93
+ 'menu.preview': 'Preview',
94
+ 'menu.download': 'Download',
95
+ 'menu.print': 'Print',
96
+ 'menu.autosave': 'Autosave',
97
+ 'menu.clearAll': 'Clear all',
98
+ 'menu.toggleTheme': 'Toggle Theme',
99
+ 'menu.fullscreen': 'Fullscreen',
100
+
101
+ // Link modal
102
+ 'modal.insertLink': 'Insert Link',
103
+ 'modal.url': 'URL',
104
+ 'modal.text': 'Text',
105
+ 'modal.linkText': 'Link text',
106
+ 'modal.openInNewTab': 'Open in new tab',
107
+ 'modal.cancel': 'Cancel',
108
+ 'modal.insert': 'Insert',
109
+
110
+ // Image modal
111
+ 'modal.insertImage': 'Insert Image',
112
+ 'modal.uploadImage': 'Upload Image',
113
+ 'modal.convertedToBase64': 'Will be converted to base64',
114
+ 'modal.or': 'OR',
115
+ 'modal.imageUrl': 'Image URL',
116
+ 'modal.altText': 'Alt Text',
117
+ 'modal.describeImage': 'Describe the image',
118
+ 'modal.widthOptional': 'Width (optional)',
119
+ 'modal.invalidImageFile': 'Please select a valid image file.',
120
+
121
+ // Table modal
122
+ 'modal.insertTable': 'Insert Table',
123
+ 'modal.rows': 'Rows',
124
+ 'modal.columns': 'Columns',
125
+ 'modal.includeHeaderRow': 'Include header row',
126
+
127
+ // Find & Replace modal
128
+ 'modal.findReplace': 'Find & Replace',
129
+ 'modal.find': 'Find',
130
+ 'modal.searchText': 'Search text...',
131
+ 'modal.replaceWith': 'Replace with',
132
+ 'modal.replacementText': 'Replacement text...',
133
+ 'modal.useRegex': 'Use Regular Expression',
134
+ 'modal.caseSensitive': 'Case Sensitive',
135
+ 'modal.findNext': 'Find Next',
136
+ 'modal.replace': 'Replace',
137
+ 'modal.replaceAll': 'Replace All',
138
+ 'modal.invalidRegex': 'Invalid regex',
139
+ 'modal.matchesFound': '{count} match(es) found',
140
+ 'modal.matchOf': 'Match {current} of {total}',
141
+ 'modal.matchesRemaining': '{count} match(es) remaining',
142
+ 'modal.replacedOccurrences': 'Replaced {count} occurrence(s)',
143
+
144
+ // Status bar
145
+ 'status.words': 'words',
146
+ 'status.word': 'word',
147
+ 'status.chars': 'chars',
148
+ 'status.char': 'char',
149
+
150
+ // Autosave
151
+ 'autosave.savedLocally': 'Saved locally',
152
+ 'autosave.autosaving': 'Autosaving...',
153
+
154
+ // Preview
155
+ 'preview.title': 'Document Preview',
156
+
157
+ // Clear all
158
+ 'confirm.clearAll': 'Clear all content?',
159
+
160
+ // Placeholder
161
+ 'placeholder': 'Start typing...'
162
+ },
163
+ cs: {
164
+ // Toolbar tooltips
165
+ 'toolbar.undo': 'Zpět (Ctrl+Z)',
166
+ 'toolbar.redo': 'Znovu (Ctrl+Y)',
167
+ 'toolbar.bold': 'Tučné (Ctrl+B)',
168
+ 'toolbar.italic': 'Kurzíva (Ctrl+I)',
169
+ 'toolbar.underline': 'Podtržené (Ctrl+U)',
170
+ 'toolbar.strikethrough': 'Přeškrtnuté',
171
+ 'toolbar.heading': 'Nadpis',
172
+ 'toolbar.fontSize': 'Velikost písma',
173
+ 'toolbar.fontFamily': 'Rodina písma',
174
+ 'toolbar.foreColor': 'Barva textu',
175
+ 'toolbar.backColor': 'Barva pozadí',
176
+ 'toolbar.alignLeft': 'Zarovnat vlevo',
177
+ 'toolbar.alignCenter': 'Zarovnat na střed',
178
+ 'toolbar.alignRight': 'Zarovnat vpravo',
179
+ 'toolbar.alignJustify': 'Do bloku',
180
+ 'toolbar.bulletList': 'Odrážkový seznam',
181
+ 'toolbar.numberedList': 'Číslovaný seznam',
182
+ 'toolbar.indent': 'Zvětšit odsazení',
183
+ 'toolbar.outdent': 'Zmenšit odsazení',
184
+ 'toolbar.link': 'Vložit odkaz (Ctrl+K)',
185
+ 'toolbar.image': 'Vložit obrázek',
186
+ 'toolbar.table': 'Vložit tabulku',
187
+ 'toolbar.blockquote': 'Citace',
188
+ 'toolbar.viewCode': 'Zobrazit kód (HTML)',
189
+ 'toolbar.horizontalRule': 'Vodorovná čára',
190
+ 'toolbar.subscript': 'Dolní index',
191
+ 'toolbar.superscript': 'Horní index',
192
+ 'toolbar.removeFormat': 'Odstranit formátování',
193
+ 'toolbar.findReplace': 'Najít a nahradit',
194
+ 'toolbar.emoji': 'Vložit emoji',
195
+ 'toolbar.specialChars': 'Speciální znaky',
196
+ 'toolbar.fullscreen': 'Celá obrazovka',
197
+ 'toolbar.autosave': 'Auto. ukládání',
198
+ 'toolbar.themeToggle': 'Přepnout motiv',
199
+ 'toolbar.print': 'Tisk',
200
+ 'toolbar.insert': 'Vložit',
201
+ 'toolbar.moreOptions': 'Další možnosti',
202
+ 'toolbar.decreaseFontSize': 'Zmenšit písmo',
203
+ 'toolbar.increaseFontSize': 'Zvětšit písmo',
204
+
205
+ // Headings select
206
+ 'heading.paragraph': 'Odstavec',
207
+ 'heading.h1': 'Nadpis 1',
208
+ 'heading.h2': 'Nadpis 2',
209
+ 'heading.h3': 'Nadpis 3',
210
+ 'heading.h4': 'Nadpis 4',
211
+ 'heading.h5': 'Nadpis 5',
212
+ 'heading.h6': 'Nadpis 6',
213
+
214
+ // Font families
215
+ 'font.sansSerif': 'Sans Serif',
216
+ 'font.serif': 'Serif',
217
+ 'font.monospace': 'Monospace',
218
+ 'font.cursive': 'Cursive',
219
+
220
+ // Insert dropdown
221
+ 'insert.link': 'Odkaz',
222
+ 'insert.image': 'Obrázek',
223
+ 'insert.table': 'Tabulka',
224
+ 'insert.emoji': 'Emoji',
225
+ 'insert.symbol': 'Symbol',
226
+
227
+ // More menu
228
+ 'menu.save': 'Uložit',
229
+ 'menu.preview': 'Náhled',
230
+ 'menu.download': 'Stáhnout',
231
+ 'menu.print': 'Tisk',
232
+ 'menu.autosave': 'Auto. ukládání',
233
+ 'menu.clearAll': 'Vymazat vše',
234
+ 'menu.toggleTheme': 'Přepnout motiv',
235
+ 'menu.fullscreen': 'Celá obrazovka',
236
+
237
+ // Link modal
238
+ 'modal.insertLink': 'Vložit odkaz',
239
+ 'modal.url': 'URL',
240
+ 'modal.text': 'Text',
241
+ 'modal.linkText': 'Text odkazu',
242
+ 'modal.openInNewTab': 'Otevřít v nové záložce',
243
+ 'modal.cancel': 'Zrušit',
244
+ 'modal.insert': 'Vložit',
245
+
246
+ // Image modal
247
+ 'modal.insertImage': 'Vložit obrázek',
248
+ 'modal.uploadImage': 'Nahrát obrázek',
249
+ 'modal.convertedToBase64': 'Bude převeden na base64',
250
+ 'modal.or': 'NEBO',
251
+ 'modal.imageUrl': 'URL obrázku',
252
+ 'modal.altText': 'Alternativní text',
253
+ 'modal.describeImage': 'Popis obrázku',
254
+ 'modal.widthOptional': 'Šířka (volitelné)',
255
+ 'modal.invalidImageFile': 'Vyberte prosím platný soubor obrázku.',
256
+
257
+ // Table modal
258
+ 'modal.insertTable': 'Vložit tabulku',
259
+ 'modal.rows': 'Řádky',
260
+ 'modal.columns': 'Sloupce',
261
+ 'modal.includeHeaderRow': 'Včetně řádku záhlaví',
262
+
263
+ // Find & Replace modal
264
+ 'modal.findReplace': 'Najít a nahradit',
265
+ 'modal.find': 'Najít',
266
+ 'modal.searchText': 'Hledaný text...',
267
+ 'modal.replaceWith': 'Nahradit za',
268
+ 'modal.replacementText': 'Text náhrady...',
269
+ 'modal.useRegex': 'Použít regulární výraz',
270
+ 'modal.caseSensitive': 'Rozlišovat velikost písmen',
271
+ 'modal.findNext': 'Najít další',
272
+ 'modal.replace': 'Nahradit',
273
+ 'modal.replaceAll': 'Nahradit vše',
274
+ 'modal.invalidRegex': 'Neplatný regulární výraz',
275
+ 'modal.matchesFound': 'Nalezeno {count} shod',
276
+ 'modal.matchOf': 'Shoda {current} z {total}',
277
+ 'modal.matchesRemaining': 'Zbývá {count} shod',
278
+ 'modal.replacedOccurrences': 'Nahrazeno {count} výskytů',
279
+
280
+ // Status bar
281
+ 'status.words': 'slov',
282
+ 'status.word': 'slovo',
283
+ 'status.chars': 'znaků',
284
+ 'status.char': 'znak',
285
+
286
+ // Autosave
287
+ 'autosave.savedLocally': 'Uloženo lokálně',
288
+ 'autosave.autosaving': 'Ukládám...',
289
+
290
+ // Preview
291
+ 'preview.title': 'Náhled dokumentu',
292
+
293
+ // Clear all
294
+ 'confirm.clearAll': 'Vymazat veškerý obsah?',
295
+
296
+ // Placeholder
297
+ 'placeholder': 'Začněte psát...'
298
+ }
299
+ };
300
+
301
+ // Current language (will be set per editor instance)
302
+ let _currentLanguage = 'en';
303
+
304
+ // Translation helper function
305
+ function t(key, params = {}) {
306
+ const lang = _currentLanguage || 'en';
307
+ let text = TRANSLATIONS[lang]?.[key] || TRANSLATIONS['en']?.[key] || key;
308
+
309
+ // Replace placeholders like {count}, {current}, {total}
310
+ Object.keys(params).forEach(param => {
311
+ text = text.replace(new RegExp(`\\{${param}\\}`, 'g'), params[param]);
312
+ });
313
+
314
+ return text;
315
+ }
316
+
317
+ const DEFAULT_CONFIG = {
318
+ toolbar: [
319
+ 'viewCode', 'undo', 'redo', 'findReplace', '|',
320
+ 'bold', 'italic', 'underline', 'strikethrough', 'superscript', 'subscript', 'removeFormat', '|',
321
+ 'heading', 'fontFamily', 'fontSize', '|',
322
+ 'foreColor', 'backColor', '|',
323
+ 'alignLeft', 'alignCenter', 'alignRight', 'alignJustify', '|',
324
+ 'indent', 'outdent', '|',
325
+ 'bulletList', 'numberedList', 'blockquote', 'horizontalRule', '|',
326
+ 'insertDropdown', '|',
327
+ 'moreMenu'
328
+ ],
329
+ placeholder: 'Start typing...',
330
+ minHeight: 300,
331
+ maxHeight: null,
332
+ autofocus: false,
333
+ spellcheck: true,
334
+ readonly: false,
335
+ theme: 'light',
336
+ language: 'en',
337
+ plugins: [],
338
+ onChange: null,
339
+ onSave: null,
340
+ onFocus: null,
341
+ onBlur: null,
342
+ onReady: null
343
+ };
344
+
345
+ const TOOLBAR_ITEMS = {
346
+ undo: { icon: 'undo', titleKey: 'toolbar.undo', command: 'undo' },
347
+ redo: { icon: 'redo', titleKey: 'toolbar.redo', command: 'redo' },
348
+ bold: { icon: 'bold', titleKey: 'toolbar.bold', command: 'bold' },
349
+ italic: { icon: 'italic', titleKey: 'toolbar.italic', command: 'italic' },
350
+ underline: { icon: 'underline', titleKey: 'toolbar.underline', command: 'underline' },
351
+ strikethrough: { icon: 'strikethrough', titleKey: 'toolbar.strikethrough', command: 'strikeThrough' },
352
+ heading: { titleKey: 'toolbar.heading', command: 'heading', type: 'select' },
353
+ fontSize: { titleKey: 'toolbar.fontSize', command: 'fontSize', type: 'fontSizeWidget' },
354
+ fontFamily: { titleKey: 'toolbar.fontFamily', command: 'fontFamily', type: 'select' },
355
+ foreColor: { icon: 'text-color', titleKey: 'toolbar.foreColor', command: 'foreColor', picker: 'color' },
356
+ backColor: { icon: 'highlight', titleKey: 'toolbar.backColor', command: 'backColor', picker: 'color' },
357
+ alignLeft: { icon: 'align-left', titleKey: 'toolbar.alignLeft', command: 'justifyLeft' },
358
+ alignCenter: { icon: 'align-center', titleKey: 'toolbar.alignCenter', command: 'justifyCenter' },
359
+ alignRight: { icon: 'align-right', titleKey: 'toolbar.alignRight', command: 'justifyRight' },
360
+ alignJustify: { icon: 'align-justify', titleKey: 'toolbar.alignJustify', command: 'justifyFull' },
361
+ bulletList: { icon: 'list-ul', titleKey: 'toolbar.bulletList', command: 'insertUnorderedList' },
362
+ numberedList: { icon: 'list-ol', titleKey: 'toolbar.numberedList', command: 'insertOrderedList' },
363
+ indent: { icon: 'indent', titleKey: 'toolbar.indent', command: 'indent' },
364
+ outdent: { icon: 'outdent', titleKey: 'toolbar.outdent', command: 'outdent' },
365
+ link: { icon: 'link', titleKey: 'toolbar.link', command: 'createLink', modal: true },
366
+ image: { icon: 'image', titleKey: 'toolbar.image', command: 'insertImage', modal: true },
367
+ table: { icon: 'table', titleKey: 'toolbar.table', command: 'insertTable', modal: true },
368
+ blockquote: { icon: 'quote', titleKey: 'toolbar.blockquote', command: 'formatBlock', value: 'blockquote' },
369
+ viewCode: { icon: 'code', titleKey: 'toolbar.viewCode', command: 'viewCode' },
370
+ horizontalRule: { icon: 'minus', titleKey: 'toolbar.horizontalRule', command: 'insertHorizontalRule' },
371
+ subscript: { icon: 'subscript', titleKey: 'toolbar.subscript', command: 'subscript' },
372
+ superscript: { icon: 'superscript', titleKey: 'toolbar.superscript', command: 'superscript' },
373
+ removeFormat: { icon: 'eraser', titleKey: 'toolbar.removeFormat', command: 'removeFormat' },
374
+ findReplace: { icon: 'search', titleKey: 'toolbar.findReplace', command: 'findReplace', modal: true },
375
+ emoji: { icon: 'emoji', titleKey: 'toolbar.emoji', command: 'emoji', picker: 'emoji' },
376
+ specialChars: { icon: 'specialChars', titleKey: 'toolbar.specialChars', command: 'specialChars', picker: 'specialChars' },
377
+ fullscreen: { icon: 'fullscreen', titleKey: 'toolbar.fullscreen', command: 'fullscreen' },
378
+ autosave: { icon: 'save', titleKey: 'toolbar.autosave', command: 'autosave', toggle: true },
379
+ themeToggle: { icon: 'sun', titleKey: 'toolbar.themeToggle', command: 'themeToggle', toggle: true },
380
+ print: { icon: 'print', titleKey: 'toolbar.print', command: 'print' },
381
+ insertDropdown: { icon: 'plus', titleKey: 'toolbar.insert', type: 'insertDropdown' },
382
+ moreMenu: { icon: 'more', titleKey: 'toolbar.moreOptions', type: 'moreMenu' }
383
+ };
384
+
385
+ const FONT_SIZES = [8, 9, 10, 11, 12, 14, 18, 24, 30, 36, 48, 60, 72, 96];
386
+
387
+ const FONT_FAMILIES = [
388
+ { labelKey: 'font.sansSerif', value: 'Arial, sans-serif' },
389
+ { labelKey: 'font.serif', value: 'Georgia, serif' },
390
+ { labelKey: 'font.monospace', value: 'Consolas, monospace' },
391
+ { labelKey: 'font.cursive', value: 'Comic Sans MS, cursive' }
392
+ ];
393
+
394
+ const HEADINGS = [
395
+ { labelKey: 'heading.paragraph', value: 'p' },
396
+ { labelKey: 'heading.h1', value: 'h1' },
397
+ { labelKey: 'heading.h2', value: 'h2' },
398
+ { labelKey: 'heading.h3', value: 'h3' },
399
+ { labelKey: 'heading.h4', value: 'h4' },
400
+ { labelKey: 'heading.h5', value: 'h5' },
401
+ { labelKey: 'heading.h6', value: 'h6' }
402
+ ];
403
+
404
+ const EMOJIS = [
405
+ '😀', '😃', '😄', '😁', '😅', '😂', '🤣', '😊', '😇', '🙂',
406
+ '😉', '😌', '😍', '🥰', '😘', '😗', '😙', '😚', '😋', '😛',
407
+ '😜', '🤪', '😝', '🤑', '🤗', '🤭', '🤫', '🤔', '🤐', '🤨',
408
+ '😐', '😑', '😶', '😏', '😒', '🙄', '😬', '🤥', '😌', '😔',
409
+ '😪', '🤤', '😴', '😷', '🤒', '🤕', '🤢', '🤮', '🤧', '🥵',
410
+ '👍', '👎', '👌', '✌️', '🤞', '🤟', '🤘', '🤙', '👈', '👉',
411
+ '👆', '👇', '☝️', '👋', '🤚', '🖐️', '✋', '🖖', '👏', '🙌',
412
+ '❤️', '🧡', '💛', '💚', '💙', '💜', '🖤', '🤍', '🤎', '💔',
413
+ '⭐', '🌟', '✨', '💫', '🔥', '💥', '💯', '✅', '❌', '❓'
414
+ ];
415
+
416
+ const SPECIAL_CHARS = [
417
+ { char: '©', name: 'Copyright' },
418
+ { char: '®', name: 'Registered' },
419
+ { char: '™', name: 'Trademark' },
420
+ { char: '€', name: 'Euro' },
421
+ { char: '£', name: 'Pound' },
422
+ { char: '¥', name: 'Yen' },
423
+ { char: '¢', name: 'Cent' },
424
+ { char: '°', name: 'Degree' },
425
+ { char: '±', name: 'Plus-Minus' },
426
+ { char: '×', name: 'Multiply' },
427
+ { char: '÷', name: 'Divide' },
428
+ { char: '≠', name: 'Not Equal' },
429
+ { char: '≤', name: 'Less or Equal' },
430
+ { char: '≥', name: 'Greater or Equal' },
431
+ { char: '∞', name: 'Infinity' },
432
+ { char: '√', name: 'Square Root' },
433
+ { char: 'π', name: 'Pi' },
434
+ { char: 'Ω', name: 'Omega' },
435
+ { char: 'α', name: 'Alpha' },
436
+ { char: 'β', name: 'Beta' },
437
+ { char: 'γ', name: 'Gamma' },
438
+ { char: 'δ', name: 'Delta' },
439
+ { char: 'µ', name: 'Micro' },
440
+ { char: '∑', name: 'Sum' },
441
+ { char: '∆', name: 'Delta (big)' },
442
+ { char: '←', name: 'Left Arrow' },
443
+ { char: '→', name: 'Right Arrow' },
444
+ { char: '↑', name: 'Up Arrow' },
445
+ { char: '↓', name: 'Down Arrow' },
446
+ { char: '↔', name: 'Left-Right Arrow' },
447
+ { char: '•', name: 'Bullet' },
448
+ { char: '…', name: 'Ellipsis' },
449
+ { char: '—', name: 'Em Dash' },
450
+ { char: '–', name: 'En Dash' },
451
+ { char: '§', name: 'Section' },
452
+ { char: '¶', name: 'Paragraph' },
453
+ { char: '†', name: 'Dagger' },
454
+ { char: '‡', name: 'Double Dagger' },
455
+ { char: '♠', name: 'Spade' },
456
+ { char: '♣', name: 'Club' },
457
+ { char: '♥', name: 'Heart' },
458
+ { char: '♦', name: 'Diamond' }
459
+ ];
460
+
461
+ const COLORS = [
462
+ '#000000', '#434343', '#666666', '#999999', '#b7b7b7', '#cccccc', '#d9d9d9', '#efefef', '#f3f3f3', '#ffffff',
463
+ '#980000', '#ff0000', '#ff9900', '#ffff00', '#00ff00', '#00ffff', '#4a86e8', '#0000ff', '#9900ff', '#ff00ff',
464
+ '#e6b8af', '#f4cccc', '#fce5cd', '#fff2cc', '#d9ead3', '#d0e0e3', '#c9daf8', '#cfe2f3', '#d9d2e9', '#ead1dc',
465
+ '#dd7e6b', '#ea9999', '#f9cb9c', '#ffe599', '#b6d7a8', '#a2c4c9', '#a4c2f4', '#9fc5e8', '#b4a7d6', '#d5a6bd',
466
+ '#cc4125', '#e06666', '#f6b26b', '#ffd966', '#93c47d', '#76a5af', '#6d9eeb', '#6fa8dc', '#8e7cc3', '#c27ba0',
467
+ '#a61c00', '#cc0000', '#e69138', '#f1c232', '#6aa84f', '#45818e', '#3c78d8', '#3d85c6', '#674ea7', '#a64d79',
468
+ '#85200c', '#990000', '#b45f06', '#bf9000', '#38761d', '#134f5c', '#1155cc', '#0b5394', '#351c75', '#741b47',
469
+ '#5b0f00', '#660000', '#783f04', '#7f6000', '#274e13', '#0c343d', '#1c4587', '#073763', '#20124d', '#4c1130'
470
+ ];
471
+
472
+ // ============================================
473
+ // SECTION 2: UTILITY FUNCTIONS
474
+ // ============================================
475
+
476
+ const Utils = {
477
+ generateId() {
478
+ return 'neiki-' + Math.random().toString(36).substr(2, 9);
479
+ },
480
+
481
+ createElement(tag, attrs = {}, children = []) {
482
+ const el = document.createElement(tag);
483
+ Object.entries(attrs).forEach(([key, value]) => {
484
+ if (key === 'className') {
485
+ el.className = value;
486
+ } else if (key === 'innerHTML') {
487
+ el.innerHTML = value;
488
+ } else if (key === 'textContent') {
489
+ el.textContent = value;
490
+ } else if (key.startsWith('on') && typeof value === 'function') {
491
+ el.addEventListener(key.slice(2).toLowerCase(), value);
492
+ } else if (key === 'style' && typeof value === 'object') {
493
+ Object.assign(el.style, value);
494
+ } else {
495
+ el.setAttribute(key, value);
496
+ }
497
+ });
498
+ children.forEach(child => {
499
+ if (typeof child === 'string') {
500
+ el.appendChild(document.createTextNode(child));
501
+ } else if (child instanceof Node) {
502
+ el.appendChild(child);
503
+ }
504
+ });
505
+ return el;
506
+ },
507
+
508
+ debounce(fn, delay) {
509
+ let timeout;
510
+ return function (...args) {
511
+ clearTimeout(timeout);
512
+ timeout = setTimeout(() => fn.apply(this, args), delay);
513
+ };
514
+ },
515
+
516
+ deepMerge(target, source) {
517
+ const result = { ...target };
518
+ Object.keys(source).forEach(key => {
519
+ if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
520
+ result[key] = Utils.deepMerge(result[key] || {}, source[key]);
521
+ } else {
522
+ result[key] = source[key];
523
+ }
524
+ });
525
+ return result;
526
+ },
527
+
528
+ sanitizeHTML(html) {
529
+ const temp = document.createElement('div');
530
+ temp.innerHTML = html;
531
+ const scripts = temp.querySelectorAll('script');
532
+ scripts.forEach(s => s.remove());
533
+ return temp.innerHTML;
534
+ },
535
+
536
+ isValidUrl(string) {
537
+ try {
538
+ new URL(string);
539
+ return true;
540
+ } catch (_) {
541
+ return false;
542
+ }
543
+ },
544
+
545
+ getSelection() {
546
+ return window.getSelection();
547
+ },
548
+
549
+ saveSelection() {
550
+ const sel = window.getSelection();
551
+ if (sel.rangeCount > 0) {
552
+ return sel.getRangeAt(0).cloneRange();
553
+ }
554
+ return null;
555
+ },
556
+
557
+ restoreSelection(range) {
558
+ if (range) {
559
+ const sel = window.getSelection();
560
+ sel.removeAllRanges();
561
+ sel.addRange(range);
562
+ }
563
+ }
564
+ };
565
+
566
+ // ============================================
567
+ // SECTION 3: ICONS (SVG)
568
+ // ============================================
569
+
570
+ const Icons = {
571
+ undo: '<svg viewBox="0 0 24 24"><path d="M12.5 8c-2.65 0-5.05.99-6.9 2.6L2 7v9h9l-3.62-3.62c1.39-1.16 3.16-1.88 5.12-1.88 3.54 0 6.55 2.31 7.6 5.5l2.37-.78C21.08 11.03 17.15 8 12.5 8z"/></svg>',
572
+ redo: '<svg viewBox="0 0 24 24"><path d="M18.4 10.6C16.55 8.99 14.15 8 11.5 8c-4.65 0-8.58 3.03-9.96 7.22L3.9 16c1.05-3.19 4.05-5.5 7.6-5.5 1.95 0 3.73.72 5.12 1.88L13 16h9V7l-3.6 3.6z"/></svg>',
573
+ bold: '<svg viewBox="0 0 24 24"><path d="M15.6 10.79c.97-.67 1.65-1.77 1.65-2.79 0-2.26-1.75-4-4-4H7v14h7.04c2.09 0 3.71-1.7 3.71-3.79 0-1.52-.86-2.82-2.15-3.42zM10 6.5h3c.83 0 1.5.67 1.5 1.5s-.67 1.5-1.5 1.5h-3v-3zm3.5 9H10v-3h3.5c.83 0 1.5.67 1.5 1.5s-.67 1.5-1.5 1.5z"/></svg>',
574
+ italic: '<svg viewBox="0 0 24 24"><path d="M10 4v3h2.21l-3.42 8H6v3h8v-3h-2.21l3.42-8H18V4z"/></svg>',
575
+ underline: '<svg viewBox="0 0 24 24"><path d="M12 17c3.31 0 6-2.69 6-6V3h-2.5v8c0 1.93-1.57 3.5-3.5 3.5S8.5 12.93 8.5 11V3H6v8c0 3.31 2.69 6 6 6zm-7 2v2h14v-2H5z"/></svg>',
576
+ strikethrough: '<svg viewBox="0 0 24 24"><path d="M10 19h4v-3h-4v3zM5 4v3h5v3h4V7h5V4H5zM3 14h18v-2H3v2z"/></svg>',
577
+ heading: '<svg viewBox="0 0 24 24"><path d="M5 4v3h5.5v12h3V7H19V4z"/></svg>',
578
+ 'font-size': '<svg viewBox="0 0 24 24"><path d="M9 4v3h5v12h3V7h5V4H9zm-6 8h3v7h3v-7h3v-3H3v3z"/></svg>',
579
+ font: '<svg viewBox="0 0 24 24"><path d="M9.93 13.5h4.14L12 7.98 9.93 13.5zM20 2H4c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-4.05 16.5l-1.14-3H9.17l-1.12 3H5.96l5.11-13h1.86l5.11 13h-2.09z"/></svg>',
580
+ 'text-color': '<svg viewBox="0 0 24 24"><path d="M11 3L5.5 17h2.25l1.12-3h6.25l1.12 3h2.25L13 3h-2zm-1.38 9L12 5.67 14.38 12H9.62z"/><rect x="3" y="19" width="18" height="3" fill="currentColor"/></svg>',
581
+ highlight: '<svg viewBox="0 0 24 24"><path d="M16.56 8.94L7.62 0 6.21 1.41l2.38 2.38-5.15 5.15c-.59.59-.59 1.54 0 2.12l5.5 5.5c.29.29.68.44 1.06.44s.77-.15 1.06-.44l5.5-5.5c.59-.58.59-1.53 0-2.12zM5.21 10L10 5.21 14.79 10H5.21zM19 11.5s-2 2.17-2 3.5c0 1.1.9 2 2 2s2-.9 2-2c0-1.33-2-3.5-2-3.5z"/><rect x="0" y="20" width="24" height="4"/></svg>',
582
+ 'align-left': '<svg viewBox="0 0 24 24"><path d="M15 15H3v2h12v-2zm0-8H3v2h12V7zM3 13h18v-2H3v2zm0 8h18v-2H3v2zM3 3v2h18V3H3z"/></svg>',
583
+ 'align-center': '<svg viewBox="0 0 24 24"><path d="M7 15v2h10v-2H7zm-4 6h18v-2H3v2zm0-8h18v-2H3v2zm4-6v2h10V7H7zM3 3v2h18V3H3z"/></svg>',
584
+ 'align-right': '<svg viewBox="0 0 24 24"><path d="M3 21h18v-2H3v2zm6-4h12v-2H9v2zm-6-4h18v-2H3v2zm6-4h12V7H9v2zM3 3v2h18V3H3z"/></svg>',
585
+ 'align-justify': '<svg viewBox="0 0 24 24"><path d="M3 21h18v-2H3v2zm0-4h18v-2H3v2zm0-4h18v-2H3v2zm0-4h18V7H3v2zm0-6v2h18V3H3z"/></svg>',
586
+ 'list-ul': '<svg viewBox="0 0 24 24"><path d="M4 10.5c-.83 0-1.5.67-1.5 1.5s.67 1.5 1.5 1.5 1.5-.67 1.5-1.5-.67-1.5-1.5-1.5zm0-6c-.83 0-1.5.67-1.5 1.5S3.17 7.5 4 7.5 5.5 6.83 5.5 6 4.83 4.5 4 4.5zm0 12c-.83 0-1.5.68-1.5 1.5s.68 1.5 1.5 1.5 1.5-.68 1.5-1.5-.67-1.5-1.5-1.5zM7 19h14v-2H7v2zm0-6h14v-2H7v2zm0-8v2h14V5H7z"/></svg>',
587
+ 'list-ol': '<svg viewBox="0 0 24 24"><path d="M2 17h2v.5H3v1h1v.5H2v1h3v-4H2v1zm1-9h1V4H2v1h1v3zm-1 3h1.8L2 13.1v.9h3v-1H3.2L5 10.9V10H2v1zm5-6v2h14V5H7zm0 14h14v-2H7v2zm0-6h14v-2H7v2z"/></svg>',
588
+ indent: '<svg viewBox="0 0 24 24"><path d="M3 21h18v-2H3v2zM3 8v8l4-4-4-4zm8 9h10v-2H11v2zM3 3v2h18V3H3zm8 6h10V7H11v2zm0 4h10v-2H11v2z"/></svg>',
589
+ outdent: '<svg viewBox="0 0 24 24"><path d="M11 17h10v-2H11v2zm-8-5l4 4V8l-4 4zm0 9h18v-2H3v2zM3 3v2h18V3H3zm8 6h10V7H11v2zm0 4h10v-2H11v2z"/></svg>',
590
+ link: '<svg viewBox="0 0 24 24"><path d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z"/></svg>',
591
+ image: '<svg viewBox="0 0 24 24"><path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/></svg>',
592
+ table: '<svg viewBox="0 0 24 24"><path d="M20 2H4c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zM8 20H4v-4h4v4zm0-6H4v-4h4v4zm0-6H4V4h4v4zm6 12h-4v-4h4v4zm0-6h-4v-4h4v4zm0-6h-4V4h4v4zm6 12h-4v-4h4v4zm0-6h-4v-4h4v4zm0-6h-4V4h4v4z"/></svg>',
593
+ quote: '<svg viewBox="0 0 24 24"><path d="M6 17h3l2-4V7H5v6h3zm8 0h3l2-4V7h-6v6h3z"/></svg>',
594
+ code: '<svg viewBox="0 0 24 24"><path d="M9.4 16.6L4.8 12l4.6-4.6L8 6l-6 6 6 6 1.4-1.4zm5.2 0l4.6-4.6-4.6-4.6L16 6l6 6-6 6-1.4-1.4z"/></svg>',
595
+ minus: '<svg viewBox="0 0 24 24"><path d="M19 13H5v-2h14v2z"/></svg>',
596
+ eraser: '<svg viewBox="0 0 24 24"><path d="M16.24 3.56l4.95 4.94c.78.79.78 2.05 0 2.84L12 20.53a4.008 4.008 0 01-5.66 0L2.81 17c-.78-.79-.78-2.05 0-2.84l10.6-10.6c.79-.78 2.05-.78 2.83 0zm-1.41 1.42L6.93 12.9l4.24 4.24 7.9-7.9-4.24-4.26z"/></svg>',
597
+ fullscreen: '<svg viewBox="0 0 24 24"><path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z"/></svg>',
598
+ close: '<svg viewBox="0 0 24 24"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>',
599
+ check: '<svg viewBox="0 0 24 24"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg>',
600
+ save: '<svg viewBox="0 0 24 24"><path d="M17 3H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V7l-4-4zm-5 16c-1.66 0-3-1.34-3-3s1.34-3 3-3 3 1.34 3 3-1.34 3-3 3zm3-10H5V5h10v4z"/></svg>',
601
+ print: '<svg viewBox="0 0 24 24"><path d="M19 8H5c-1.66 0-3 1.34-3 3v6h4v4h12v-4h4v-6c0-1.66-1.34-3-3-3zm-3 11H8v-5h8v5zm3-7c-.55 0-1-.45-1-1s.45-1 1-1 1 .45 1 1-.45 1-1 1zm-1-9H6v4h12V3z"/></svg>',
602
+ subscript: '<svg viewBox="0 0 24 24"><path d="M22 18h-2v1h3v1h-4v-2.5c0-.83.67-1.5 1.5-1.5h1.5v-1h-3v-1h2.5c.83 0 1.5.67 1.5 1.5v1c0 .83-.67 1.5-1.5 1.5zM5.88 18h2.66l3.4-5.42h.12l3.4 5.42h2.66l-4.65-7.27L17.81 4h-2.68l-3.07 4.99h-.12L8.87 4H6.19l4.32 6.73L5.88 18z"/></svg>',
603
+ superscript: '<svg viewBox="0 0 24 24"><path d="M22 7h-2v1h3v1h-4V6.5c0-.83.67-1.5 1.5-1.5h1.5V4h-3V3h2.5c.83 0 1.5.67 1.5 1.5v1c0 .83-.67 1.5-1.5 1.5zM5.88 20h2.66l3.4-5.42h.12l3.4 5.42h2.66l-4.65-7.27L17.81 6h-2.68l-3.07 4.99h-.12L8.87 6H6.19l4.32 6.73L5.88 20z"/></svg>',
604
+ search: '<svg viewBox="0 0 24 24"><path d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/></svg>',
605
+ emoji: '<svg viewBox="0 0 24 24"><path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm3.5-9c.83 0 1.5-.67 1.5-1.5S16.33 8 15.5 8 14 8.67 14 9.5s.67 1.5 1.5 1.5zm-7 0c.83 0 1.5-.67 1.5-1.5S9.33 8 8.5 8 7 8.67 7 9.5 7.67 11 8.5 11zm3.5 6.5c2.33 0 4.31-1.46 5.11-3.5H6.89c.8 2.04 2.78 3.5 5.11 3.5z"/></svg>',
606
+ specialChars: '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="2"/><text x="12" y="16" text-anchor="middle" font-size="12" font-weight="bold" fill="currentColor">©</text></svg>',
607
+ sun: '<svg viewBox="0 0 24 24"><path d="M6.76 4.84l-1.8-1.79-1.41 1.41 1.79 1.79 1.42-1.41zM4 10.5H1v2h3v-2zm9-9.95h-2V3.5h2V.55zm7.45 3.91l-1.41-1.41-1.79 1.79 1.41 1.41 1.79-1.79zm-3.21 13.7l1.79 1.8 1.41-1.41-1.8-1.79-1.4 1.4zM20 10.5v2h3v-2h-3zm-8-5c-3.31 0-6 2.69-6 6s2.69 6 6 6 6-2.69 6-6-2.69-6-6-6zm-1 16.95h2V19.5h-2v2.95zm-7.45-3.91l1.41 1.41 1.79-1.8-1.41-1.41-1.79 1.8z"/></svg>',
608
+ moon: '<svg viewBox="0 0 24 24"><path d="M12 3c-4.97 0-9 4.03-9 9s4.03 9 9 9 9-4.03 9-9c0-.46-.04-.92-.1-1.36-.98 1.37-2.58 2.26-4.4 2.26-2.98 0-5.4-2.42-5.4-5.4 0-1.81.89-3.42 2.26-4.4-.44-.06-.9-.1-1.36-.1z"/></svg>',
609
+ plus: '<svg viewBox="0 0 24 24"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>',
610
+ more: '<svg viewBox="0 0 24 24"><circle cx="6" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="18" cy="12" r="2"/></svg>',
611
+ download: '<svg viewBox="0 0 24 24"><path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"/></svg>',
612
+ eye: '<svg viewBox="0 0 24 24"><path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z"/></svg>',
613
+ trash: '<svg viewBox="0 0 24 24"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>',
614
+ 'chevron-down': '<svg viewBox="0 0 24 24"><path d="M7 10l5 5 5-5z"/></svg>'
615
+ };
616
+
617
+ // ============================================
618
+ // SECTION 4: STORAGE MANAGER (Persistence)
619
+ // ============================================
620
+
621
+ class StorageManager {
622
+ constructor(editorId) {
623
+ this.prefix = 'neiki_' + editorId + '_';
624
+ }
625
+
626
+ set(key, value) {
627
+ try {
628
+ localStorage.setItem(this.prefix + key, JSON.stringify(value));
629
+ } catch (e) {
630
+ console.warn('NeikiEditor: localStorage not available');
631
+ }
632
+ }
633
+
634
+ get(key, defaultValue = null) {
635
+ try {
636
+ const item = localStorage.getItem(this.prefix + key);
637
+ return item ? JSON.parse(item) : defaultValue;
638
+ } catch (e) {
639
+ return defaultValue;
640
+ }
641
+ }
642
+
643
+ remove(key) {
644
+ try {
645
+ localStorage.removeItem(this.prefix + key);
646
+ } catch (e) { }
647
+ }
648
+
649
+ // Global storage (shared across all editors)
650
+ static setGlobal(key, value) {
651
+ try {
652
+ localStorage.setItem('neiki_global_' + key, JSON.stringify(value));
653
+ } catch (e) { }
654
+ }
655
+
656
+ static getGlobal(key, defaultValue = null) {
657
+ try {
658
+ const item = localStorage.getItem('neiki_global_' + key);
659
+ return item ? JSON.parse(item) : defaultValue;
660
+ } catch (e) {
661
+ return defaultValue;
662
+ }
663
+ }
664
+ }
665
+
666
+ // ============================================
667
+ // SECTION 5: HISTORY MANAGER (Undo/Redo with Persistence)
668
+ // ============================================
669
+
670
+ class HistoryManager {
671
+ constructor(editor, maxSize = 100) {
672
+ this.editor = editor;
673
+ this.maxSize = maxSize;
674
+ this.undoStack = [];
675
+ this.redoStack = [];
676
+ this.isRecording = true;
677
+ // Load persisted history if available
678
+ this.loadFromStorage();
679
+ }
680
+
681
+ record() {
682
+ if (!this.isRecording) return;
683
+
684
+ const content = this.editor.getContent();
685
+ const lastState = this.undoStack[this.undoStack.length - 1];
686
+
687
+ if (lastState !== content) {
688
+ this.undoStack.push(content);
689
+ this.redoStack = [];
690
+
691
+ if (this.undoStack.length > this.maxSize) {
692
+ this.undoStack.shift();
693
+ }
694
+ this.saveToStorage();
695
+ }
696
+ }
697
+
698
+ undo() {
699
+ if (this.undoStack.length <= 1) return false;
700
+
701
+ const current = this.undoStack.pop();
702
+ this.redoStack.push(current);
703
+
704
+ const previous = this.undoStack[this.undoStack.length - 1];
705
+ this.isRecording = false;
706
+ this.editor.setContent(previous);
707
+ this.isRecording = true;
708
+ this.saveToStorage();
709
+
710
+ return true;
711
+ }
712
+
713
+ redo() {
714
+ if (this.redoStack.length === 0) return false;
715
+
716
+ const next = this.redoStack.pop();
717
+ this.undoStack.push(next);
718
+
719
+ this.isRecording = false;
720
+ this.editor.setContent(next);
721
+ this.isRecording = true;
722
+ this.saveToStorage();
723
+
724
+ return true;
725
+ }
726
+
727
+ canUndo() {
728
+ return this.undoStack.length > 1;
729
+ }
730
+
731
+ canRedo() {
732
+ return this.redoStack.length > 0;
733
+ }
734
+
735
+ clear() {
736
+ this.undoStack = [];
737
+ this.redoStack = [];
738
+ this.record();
739
+ }
740
+
741
+ saveToStorage() {
742
+ // Undo/Redo history is NOT persisted across page reloads
743
+ // This is intentional - after saving content and returning to edit,
744
+ // the "initial state" should be the saved version, not old history
745
+ }
746
+
747
+ loadFromStorage() {
748
+ // No-op - history is session-only
749
+ }
750
+
751
+ clearStorage() {
752
+ // No-op - history is session-only
753
+ }
754
+ }
755
+
756
+ // ============================================
757
+ // SECTION 6: MODAL MANAGER
758
+ // ============================================
759
+
760
+ class ModalManager {
761
+ constructor(editor) {
762
+ this.editor = editor;
763
+ this.activeModal = null;
764
+ this.overlay = null;
765
+ }
766
+
767
+ createOverlay() {
768
+ if (this.overlay) return this.overlay;
769
+
770
+ this.overlay = Utils.createElement('div', {
771
+ className: 'neiki-modal-overlay',
772
+ onClick: (e) => {
773
+ if (e.target === this.overlay) {
774
+ this.close();
775
+ }
776
+ }
777
+ });
778
+
779
+ document.body.appendChild(this.overlay);
780
+ return this.overlay;
781
+ }
782
+
783
+ open(type, data = {}) {
784
+ this.close();
785
+ this.createOverlay();
786
+
787
+ let modal;
788
+ switch (type) {
789
+ case 'link':
790
+ modal = this.createLinkModal(data);
791
+ break;
792
+ case 'image':
793
+ modal = this.createImageModal(data);
794
+ break;
795
+ case 'table':
796
+ modal = this.createTableModal(data);
797
+ break;
798
+ case 'findReplace':
799
+ modal = this.createFindReplaceModal(data);
800
+ break;
801
+ default:
802
+ return;
803
+ }
804
+
805
+ this.activeModal = modal;
806
+ this.overlay.appendChild(modal);
807
+ this.overlay.classList.add('active');
808
+
809
+ const firstInput = modal.querySelector('input');
810
+ if (firstInput) firstInput.focus();
811
+ }
812
+
813
+ close() {
814
+ if (this.overlay) {
815
+ this.overlay.classList.remove('active');
816
+ if (this.activeModal) {
817
+ this.activeModal.remove();
818
+ this.activeModal = null;
819
+ }
820
+ }
821
+ }
822
+
823
+ createLinkModal(data) {
824
+ const modal = Utils.createElement('div', { className: 'neiki-modal' });
825
+
826
+ modal.innerHTML = `
827
+ <div class="neiki-modal-header">
828
+ <h3>${t('modal.insertLink')}</h3>
829
+ <button class="neiki-modal-close" type="button">${Icons.close}</button>
830
+ </div>
831
+ <div class="neiki-modal-body">
832
+ <div class="neiki-form-group">
833
+ <label>${t('modal.url')}</label>
834
+ <input type="url" class="neiki-input" name="url" placeholder="https://example.com" value="${data.url || ''}">
835
+ </div>
836
+ <div class="neiki-form-group">
837
+ <label>${t('modal.text')}</label>
838
+ <input type="text" class="neiki-input" name="text" placeholder="${t('modal.linkText')}" value="${data.text || ''}">
839
+ </div>
840
+ <div class="neiki-form-group">
841
+ <label>
842
+ <input type="checkbox" name="newTab" ${data.newTab ? 'checked' : ''}> ${t('modal.openInNewTab')}
843
+ </label>
844
+ </div>
845
+ </div>
846
+ <div class="neiki-modal-footer">
847
+ <button class="neiki-btn neiki-btn-secondary" type="button" data-action="cancel">${t('modal.cancel')}</button>
848
+ <button class="neiki-btn neiki-btn-primary" type="button" data-action="insert">${t('modal.insert')}</button>
849
+ </div>
850
+ `;
851
+
852
+ modal.querySelector('.neiki-modal-close').addEventListener('click', () => this.close());
853
+ modal.querySelector('[data-action="cancel"]').addEventListener('click', () => this.close());
854
+ modal.querySelector('[data-action="insert"]').addEventListener('click', () => {
855
+ const url = modal.querySelector('[name="url"]').value;
856
+ const text = modal.querySelector('[name="text"]').value || url;
857
+ const newTab = modal.querySelector('[name="newTab"]').checked;
858
+
859
+ if (url) {
860
+ this.editor.commands.insertLink(url, text, newTab);
861
+ }
862
+ this.close();
863
+ });
864
+
865
+ return modal;
866
+ }
867
+
868
+ createImageModal(data) {
869
+ const modal = Utils.createElement('div', { className: 'neiki-modal' });
870
+
871
+ modal.innerHTML = `
872
+ <div class="neiki-modal-header">
873
+ <h3>${t('modal.insertImage')}</h3>
874
+ <button class="neiki-modal-close" type="button">${Icons.close}</button>
875
+ </div>
876
+ <div class="neiki-modal-body">
877
+ <div class="neiki-form-group">
878
+ <label>${t('modal.uploadImage')}</label>
879
+ <input type="file" class="neiki-input" name="upload" accept="image/*">
880
+ <small style="color: var(--neiki-text-muted); font-size: 11px;">${t('modal.convertedToBase64')}</small>
881
+ </div>
882
+ <div class="neiki-form-divider">
883
+ <span>${t('modal.or')}</span>
884
+ </div>
885
+ <div class="neiki-form-group">
886
+ <label>${t('modal.imageUrl')}</label>
887
+ <input type="url" class="neiki-input" name="url" placeholder="https://example.com/image.jpg" value="${data.url || ''}">
888
+ </div>
889
+ <div class="neiki-form-group">
890
+ <label>${t('modal.altText')}</label>
891
+ <input type="text" class="neiki-input" name="alt" placeholder="${t('modal.describeImage')}" value="${data.alt || ''}">
892
+ </div>
893
+ <div class="neiki-form-group">
894
+ <label>${t('modal.widthOptional')}</label>
895
+ <input type="text" class="neiki-input" name="width" placeholder="e.g. 300px or 50%" value="${data.width || ''}">
896
+ </div>
897
+ </div>
898
+ <div class="neiki-modal-footer">
899
+ <button class="neiki-btn neiki-btn-secondary" type="button" data-action="cancel">${t('modal.cancel')}</button>
900
+ <button class="neiki-btn neiki-btn-primary" type="button" data-action="insert">${t('modal.insert')}</button>
901
+ </div>
902
+ `;
903
+
904
+ const uploadInput = modal.querySelector('[name="upload"]');
905
+ const urlInput = modal.querySelector('[name="url"]');
906
+
907
+ // Handle file upload
908
+ uploadInput.addEventListener('change', (e) => {
909
+ const file = e.target.files[0];
910
+ if (file && file.type.startsWith('image/')) {
911
+ const reader = new FileReader();
912
+ reader.onload = (e) => {
913
+ urlInput.value = e.target.result;
914
+ urlInput.disabled = true;
915
+ };
916
+ reader.readAsDataURL(file);
917
+ } else if (file) {
918
+ alert(t('modal.invalidImageFile'));
919
+ uploadInput.value = '';
920
+ }
921
+ });
922
+
923
+ // Clear URL when upload is cleared
924
+ urlInput.addEventListener('input', () => {
925
+ if (!urlInput.value) {
926
+ urlInput.disabled = false;
927
+ uploadInput.value = '';
928
+ }
929
+ });
930
+
931
+ modal.querySelector('.neiki-modal-close').addEventListener('click', () => this.close());
932
+ modal.querySelector('[data-action="cancel"]').addEventListener('click', () => this.close());
933
+ modal.querySelector('[data-action="insert"]').addEventListener('click', () => {
934
+ const url = modal.querySelector('[name="url"]').value;
935
+ const alt = modal.querySelector('[name="alt"]').value;
936
+ const width = modal.querySelector('[name="width"]').value;
937
+
938
+ if (url) {
939
+ this.editor.commands.insertImage(url, alt, width);
940
+ }
941
+ this.close();
942
+ });
943
+
944
+ return modal;
945
+ }
946
+
947
+ createTableModal(data) {
948
+ const modal = Utils.createElement('div', { className: 'neiki-modal' });
949
+
950
+ modal.innerHTML = `
951
+ <div class="neiki-modal-header">
952
+ <h3>${t('modal.insertTable')}</h3>
953
+ <button class="neiki-modal-close" type="button">${Icons.close}</button>
954
+ </div>
955
+ <div class="neiki-modal-body">
956
+ <div class="neiki-form-row">
957
+ <div class="neiki-form-group">
958
+ <label>${t('modal.rows')}</label>
959
+ <input type="number" class="neiki-input" name="rows" min="1" max="20" value="${data.rows || 3}">
960
+ </div>
961
+ <div class="neiki-form-group">
962
+ <label>${t('modal.columns')}</label>
963
+ <input type="number" class="neiki-input" name="cols" min="1" max="10" value="${data.cols || 3}">
964
+ </div>
965
+ </div>
966
+ <div class="neiki-form-group">
967
+ <label>
968
+ <input type="checkbox" name="header" ${data.header !== false ? 'checked' : ''}> ${t('modal.includeHeaderRow')}
969
+ </label>
970
+ </div>
971
+ </div>
972
+ <div class="neiki-modal-footer">
973
+ <button class="neiki-btn neiki-btn-secondary" type="button" data-action="cancel">${t('modal.cancel')}</button>
974
+ <button class="neiki-btn neiki-btn-primary" type="button" data-action="insert">${t('modal.insert')}</button>
975
+ </div>
976
+ `;
977
+
978
+ modal.querySelector('.neiki-modal-close').addEventListener('click', () => this.close());
979
+ modal.querySelector('[data-action="cancel"]').addEventListener('click', () => this.close());
980
+ modal.querySelector('[data-action="insert"]').addEventListener('click', () => {
981
+ const rows = parseInt(modal.querySelector('[name="rows"]').value) || 3;
982
+ const cols = parseInt(modal.querySelector('[name="cols"]').value) || 3;
983
+ const header = modal.querySelector('[name="header"]').checked;
984
+
985
+ this.editor.commands.insertTable(rows, cols, header);
986
+ this.close();
987
+ });
988
+
989
+ return modal;
990
+ }
991
+
992
+ createFindReplaceModal(data) {
993
+ const modal = Utils.createElement('div', { className: 'neiki-modal neiki-modal-wide' });
994
+
995
+ modal.innerHTML = `
996
+ <div class="neiki-modal-header">
997
+ <h3>${t('modal.findReplace')}</h3>
998
+ <button class="neiki-modal-close" type="button">${Icons.close}</button>
999
+ </div>
1000
+ <div class="neiki-modal-body">
1001
+ <div class="neiki-form-group">
1002
+ <label>${t('modal.find')}</label>
1003
+ <input type="text" class="neiki-input" name="find" placeholder="${t('modal.searchText')}">
1004
+ </div>
1005
+ <div class="neiki-form-group">
1006
+ <label>${t('modal.replaceWith')}</label>
1007
+ <input type="text" class="neiki-input" name="replace" placeholder="${t('modal.replacementText')}">
1008
+ </div>
1009
+ <div class="neiki-form-group neiki-form-row">
1010
+ <label><input type="checkbox" name="regex"> ${t('modal.useRegex')}</label>
1011
+ <label><input type="checkbox" name="caseSensitive"> ${t('modal.caseSensitive')}</label>
1012
+ </div>
1013
+ <div class="neiki-find-results" style="margin-top:10px;font-size:13px;color:var(--neiki-text-muted);"></div>
1014
+ </div>
1015
+ <div class="neiki-modal-footer">
1016
+ <button class="neiki-btn neiki-btn-secondary" type="button" data-action="findNext">${t('modal.findNext')}</button>
1017
+ <button class="neiki-btn neiki-btn-secondary" type="button" data-action="replaceOne">${t('modal.replace')}</button>
1018
+ <button class="neiki-btn neiki-btn-primary" type="button" data-action="replaceAll">${t('modal.replaceAll')}</button>
1019
+ </div>
1020
+ `;
1021
+
1022
+ const findInput = modal.querySelector('[name="find"]');
1023
+ const replaceInput = modal.querySelector('[name="replace"]');
1024
+ const regexCheck = modal.querySelector('[name="regex"]');
1025
+ const caseCheck = modal.querySelector('[name="caseSensitive"]');
1026
+ const resultsDiv = modal.querySelector('.neiki-find-results');
1027
+
1028
+ let currentMatches = [];
1029
+ let currentIndex = -1;
1030
+
1031
+ const clearHighlights = () => {
1032
+ const highlights = this.editor.contentArea.querySelectorAll('.neiki-highlight-find');
1033
+ highlights.forEach(h => {
1034
+ const text = document.createTextNode(h.textContent);
1035
+ h.parentNode.replaceChild(text, h);
1036
+ });
1037
+ this.editor.contentArea.normalize();
1038
+ };
1039
+
1040
+ const findMatches = () => {
1041
+ clearHighlights();
1042
+ currentMatches = [];
1043
+ currentIndex = -1;
1044
+
1045
+ const searchText = findInput.value;
1046
+ if (!searchText) {
1047
+ resultsDiv.textContent = '';
1048
+ return;
1049
+ }
1050
+
1051
+ const content = this.editor.contentArea.innerHTML;
1052
+ let flags = 'g';
1053
+ if (!caseCheck.checked) flags += 'i';
1054
+
1055
+ let regex;
1056
+ try {
1057
+ regex = regexCheck.checked
1058
+ ? new RegExp(searchText, flags)
1059
+ : new RegExp(searchText.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), flags);
1060
+ } catch (e) {
1061
+ resultsDiv.textContent = t('modal.invalidRegex');
1062
+ return;
1063
+ }
1064
+
1065
+ // Find and highlight in text nodes
1066
+ const walker = document.createTreeWalker(
1067
+ this.editor.contentArea,
1068
+ NodeFilter.SHOW_TEXT,
1069
+ null,
1070
+ false
1071
+ );
1072
+
1073
+ const textNodes = [];
1074
+ while (walker.nextNode()) textNodes.push(walker.currentNode);
1075
+
1076
+ textNodes.forEach(node => {
1077
+ const text = node.textContent;
1078
+ const matches = [...text.matchAll(regex)];
1079
+ if (matches.length > 0) {
1080
+ const frag = document.createDocumentFragment();
1081
+ let lastIndex = 0;
1082
+ matches.forEach(match => {
1083
+ if (match.index > lastIndex) {
1084
+ frag.appendChild(document.createTextNode(text.slice(lastIndex, match.index)));
1085
+ }
1086
+ const span = document.createElement('span');
1087
+ span.className = 'neiki-highlight-find';
1088
+ span.textContent = match[0];
1089
+ frag.appendChild(span);
1090
+ currentMatches.push(span);
1091
+ lastIndex = match.index + match[0].length;
1092
+ });
1093
+ if (lastIndex < text.length) {
1094
+ frag.appendChild(document.createTextNode(text.slice(lastIndex)));
1095
+ }
1096
+ node.parentNode.replaceChild(frag, node);
1097
+ }
1098
+ });
1099
+
1100
+ resultsDiv.textContent = t('modal.matchesFound', { count: currentMatches.length });
1101
+ };
1102
+
1103
+ const findNext = () => {
1104
+ if (currentMatches.length === 0) {
1105
+ findMatches();
1106
+ }
1107
+ if (currentMatches.length === 0) return;
1108
+
1109
+ // Remove current highlight
1110
+ if (currentIndex >= 0 && currentMatches[currentIndex]) {
1111
+ currentMatches[currentIndex].classList.remove('neiki-highlight-current');
1112
+ }
1113
+
1114
+ currentIndex = (currentIndex + 1) % currentMatches.length;
1115
+ const current = currentMatches[currentIndex];
1116
+ current.classList.add('neiki-highlight-current');
1117
+ current.scrollIntoView({ behavior: 'smooth', block: 'center' });
1118
+ resultsDiv.textContent = t('modal.matchOf', { current: currentIndex + 1, total: currentMatches.length });
1119
+ };
1120
+
1121
+ const replaceOne = () => {
1122
+ if (currentIndex >= 0 && currentMatches[currentIndex]) {
1123
+ const match = currentMatches[currentIndex];
1124
+ match.textContent = replaceInput.value;
1125
+ match.classList.remove('neiki-highlight-find', 'neiki-highlight-current');
1126
+ currentMatches.splice(currentIndex, 1);
1127
+ currentIndex--;
1128
+ this.editor.history.record();
1129
+ this.editor.triggerChange();
1130
+ resultsDiv.textContent = t('modal.matchesRemaining', { count: currentMatches.length });
1131
+ if (currentMatches.length > 0) findNext();
1132
+ }
1133
+ };
1134
+
1135
+ const replaceAll = () => {
1136
+ clearHighlights();
1137
+ const searchText = findInput.value;
1138
+ const replaceText = replaceInput.value;
1139
+ if (!searchText) return;
1140
+
1141
+ let flags = 'g';
1142
+ if (!caseCheck.checked) flags += 'i';
1143
+
1144
+ let regex;
1145
+ try {
1146
+ regex = regexCheck.checked
1147
+ ? new RegExp(searchText, flags)
1148
+ : new RegExp(searchText.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), flags);
1149
+ } catch (e) {
1150
+ return;
1151
+ }
1152
+
1153
+ // Replace in text nodes
1154
+ const walker = document.createTreeWalker(
1155
+ this.editor.contentArea,
1156
+ NodeFilter.SHOW_TEXT,
1157
+ null,
1158
+ false
1159
+ );
1160
+
1161
+ const textNodes = [];
1162
+ while (walker.nextNode()) textNodes.push(walker.currentNode);
1163
+
1164
+ let count = 0;
1165
+ textNodes.forEach(node => {
1166
+ const text = node.textContent;
1167
+ const newText = text.replace(regex, () => {
1168
+ count++;
1169
+ return replaceText;
1170
+ });
1171
+ if (newText !== text) {
1172
+ node.textContent = newText;
1173
+ }
1174
+ });
1175
+
1176
+ this.editor.history.record();
1177
+ this.editor.triggerChange();
1178
+ currentMatches = [];
1179
+ currentIndex = -1;
1180
+ resultsDiv.textContent = t('modal.replacedOccurrences', { count: count });
1181
+ };
1182
+
1183
+ findInput.addEventListener('input', Utils.debounce(findMatches, 300));
1184
+ modal.querySelector('.neiki-modal-close').addEventListener('click', () => {
1185
+ clearHighlights();
1186
+ this.close();
1187
+ });
1188
+ modal.querySelector('[data-action="findNext"]').addEventListener('click', findNext);
1189
+ modal.querySelector('[data-action="replaceOne"]').addEventListener('click', replaceOne);
1190
+ modal.querySelector('[data-action="replaceAll"]').addEventListener('click', replaceAll);
1191
+
1192
+ return modal;
1193
+ }
1194
+ }
1195
+
1196
+ // ============================================
1197
+ // SECTION 6: DROPDOWN MANAGER
1198
+ // ============================================
1199
+
1200
+ class DropdownManager {
1201
+ constructor(editor) {
1202
+ this.editor = editor;
1203
+ this.activeDropdown = null;
1204
+
1205
+ document.addEventListener('click', (e) => {
1206
+ if (this.activeDropdown && !this.activeDropdown.contains(e.target)) {
1207
+ this.close();
1208
+ }
1209
+ });
1210
+ }
1211
+
1212
+ toggle(button, type) {
1213
+ const existing = button.querySelector('.neiki-dropdown');
1214
+
1215
+ if (existing) {
1216
+ this.close();
1217
+ return;
1218
+ }
1219
+
1220
+ this.close();
1221
+
1222
+ let dropdown;
1223
+ switch (type) {
1224
+ case 'heading':
1225
+ dropdown = this.createHeadingDropdown();
1226
+ break;
1227
+ case 'fontSize':
1228
+ dropdown = this.createFontSizeDropdown();
1229
+ break;
1230
+ case 'fontFamily':
1231
+ dropdown = this.createFontFamilyDropdown();
1232
+ break;
1233
+ default:
1234
+ return;
1235
+ }
1236
+
1237
+ button.appendChild(dropdown);
1238
+ this.activeDropdown = dropdown;
1239
+ }
1240
+
1241
+ close() {
1242
+ if (this.activeDropdown) {
1243
+ this.activeDropdown.remove();
1244
+ this.activeDropdown = null;
1245
+ }
1246
+ }
1247
+
1248
+ createHeadingDropdown() {
1249
+ const dropdown = Utils.createElement('div', { className: 'neiki-dropdown' });
1250
+
1251
+ HEADINGS.forEach(({ label, value }) => {
1252
+ const item = Utils.createElement('div', {
1253
+ className: 'neiki-dropdown-item',
1254
+ innerHTML: `<${value}>${label}</${value}>`,
1255
+ onClick: () => {
1256
+ this.editor.commands.formatBlock(value);
1257
+ this.close();
1258
+ }
1259
+ });
1260
+ dropdown.appendChild(item);
1261
+ });
1262
+
1263
+ return dropdown;
1264
+ }
1265
+
1266
+ createFontSizeDropdown() {
1267
+ const dropdown = Utils.createElement('div', { className: 'neiki-dropdown' });
1268
+
1269
+ FONT_SIZES.forEach(size => {
1270
+ const item = Utils.createElement('div', {
1271
+ className: 'neiki-dropdown-item',
1272
+ textContent: size + 'px',
1273
+ onClick: () => {
1274
+ this.editor.commands.fontSize(size + 'px');
1275
+ this.close();
1276
+ }
1277
+ });
1278
+ dropdown.appendChild(item);
1279
+ });
1280
+
1281
+ return dropdown;
1282
+ }
1283
+
1284
+ createFontFamilyDropdown() {
1285
+ const dropdown = Utils.createElement('div', { className: 'neiki-dropdown' });
1286
+
1287
+ FONT_FAMILIES.forEach(({ label, value }) => {
1288
+ const item = Utils.createElement('div', {
1289
+ className: 'neiki-dropdown-item',
1290
+ textContent: label,
1291
+ style: { fontFamily: value },
1292
+ onClick: () => {
1293
+ this.editor.commands.fontFamily(value);
1294
+ this.close();
1295
+ }
1296
+ });
1297
+ dropdown.appendChild(item);
1298
+ });
1299
+
1300
+ return dropdown;
1301
+ }
1302
+ }
1303
+
1304
+ // ============================================
1305
+ // SECTION 7: COLOR PICKER
1306
+ // ============================================
1307
+
1308
+ class ColorPicker {
1309
+ constructor(editor) {
1310
+ this.editor = editor;
1311
+ this.activePicker = null;
1312
+ this.activeButton = null;
1313
+
1314
+ document.addEventListener('mousedown', (e) => {
1315
+ if (this.activePicker &&
1316
+ !this.activePicker.contains(e.target) &&
1317
+ (!this.activeButton || !this.activeButton.contains(e.target))) {
1318
+ this.close();
1319
+ }
1320
+ });
1321
+ }
1322
+
1323
+ toggle(button, command) {
1324
+ if (this.activeButton === button && this.activePicker) {
1325
+ this.close();
1326
+ return;
1327
+ }
1328
+
1329
+ this.close();
1330
+
1331
+ const picker = Utils.createElement('div', { className: 'neiki-color-picker' });
1332
+
1333
+ const resetSwatch = Utils.createElement('div', {
1334
+ className: 'neiki-color-swatch neiki-color-reset',
1335
+ title: 'Reset to default'
1336
+ });
1337
+ resetSwatch.addEventListener('mousedown', (e) => {
1338
+ e.preventDefault();
1339
+ e.stopPropagation();
1340
+ if (command === 'foreColor') {
1341
+ this.editor.commands.resetForeColor();
1342
+ } else {
1343
+ this.editor.commands.resetBackColor();
1344
+ }
1345
+ this.close();
1346
+ });
1347
+ picker.appendChild(resetSwatch);
1348
+
1349
+ COLORS.forEach(color => {
1350
+ const swatch = Utils.createElement('div', {
1351
+ className: 'neiki-color-swatch',
1352
+ style: { backgroundColor: color },
1353
+ title: color
1354
+ });
1355
+ swatch.addEventListener('mousedown', (e) => {
1356
+ e.preventDefault();
1357
+ e.stopPropagation();
1358
+ if (command === 'foreColor') {
1359
+ this.editor.commands.foreColor(color);
1360
+ } else {
1361
+ this.editor.commands.backColor(color);
1362
+ }
1363
+ this.close();
1364
+ });
1365
+ picker.appendChild(swatch);
1366
+ });
1367
+
1368
+ button.appendChild(picker);
1369
+ this.activePicker = picker;
1370
+ this.activeButton = button;
1371
+ }
1372
+
1373
+ close() {
1374
+ if (this.activePicker) {
1375
+ this.activePicker.remove();
1376
+ this.activePicker = null;
1377
+ this.activeButton = null;
1378
+ }
1379
+ }
1380
+ }
1381
+
1382
+ // ============================================
1383
+ // SECTION 7b: EMOJI PICKER
1384
+ // ============================================
1385
+
1386
+ class EmojiPicker {
1387
+ constructor(editor) {
1388
+ this.editor = editor;
1389
+ this.picker = null;
1390
+ this.activeButton = null;
1391
+
1392
+ document.addEventListener('mousedown', (e) => {
1393
+ if (this.picker &&
1394
+ !this.picker.contains(e.target) &&
1395
+ (!this.activeButton || !this.activeButton.contains(e.target))) {
1396
+ this.close();
1397
+ }
1398
+ });
1399
+ }
1400
+
1401
+ toggle(button) {
1402
+ if (this.activeButton === button && this.picker) {
1403
+ this.close();
1404
+ return;
1405
+ }
1406
+
1407
+ this.close();
1408
+
1409
+ this.picker = Utils.createElement('div', { className: 'neiki-emoji-picker' });
1410
+
1411
+ EMOJIS.forEach(emoji => {
1412
+ const item = document.createElement('span');
1413
+ item.className = 'neiki-emoji-item';
1414
+ item.textContent = emoji;
1415
+ item.title = emoji;
1416
+ item.addEventListener('mousedown', (e) => {
1417
+ e.preventDefault();
1418
+ e.stopPropagation();
1419
+ this.editor.focus();
1420
+ document.execCommand('insertText', false, emoji);
1421
+ this.editor.history.record();
1422
+ this.editor.triggerChange();
1423
+ this.close();
1424
+ });
1425
+ this.picker.appendChild(item);
1426
+ });
1427
+
1428
+ button.appendChild(this.picker);
1429
+ this.activeButton = button;
1430
+ }
1431
+
1432
+ close() {
1433
+ if (this.picker) {
1434
+ this.picker.remove();
1435
+ this.picker = null;
1436
+ this.activeButton = null;
1437
+ }
1438
+ }
1439
+ }
1440
+
1441
+ // ============================================
1442
+ // SECTION 7c: SPECIAL CHARS PICKER
1443
+ // ============================================
1444
+
1445
+ class SpecialCharsPicker {
1446
+ constructor(editor) {
1447
+ this.editor = editor;
1448
+ this.picker = null;
1449
+ this.activeButton = null;
1450
+
1451
+ document.addEventListener('mousedown', (e) => {
1452
+ if (this.picker &&
1453
+ !this.picker.contains(e.target) &&
1454
+ (!this.activeButton || !this.activeButton.contains(e.target))) {
1455
+ this.close();
1456
+ }
1457
+ });
1458
+ }
1459
+
1460
+ toggle(button) {
1461
+ if (this.activeButton === button && this.picker) {
1462
+ this.close();
1463
+ return;
1464
+ }
1465
+
1466
+ this.close();
1467
+
1468
+ this.picker = Utils.createElement('div', { className: 'neiki-special-picker' });
1469
+
1470
+ SPECIAL_CHARS.forEach(({ char, name }) => {
1471
+ const item = document.createElement('span');
1472
+ item.className = 'neiki-special-item';
1473
+ item.textContent = char;
1474
+ item.title = name;
1475
+ item.addEventListener('mousedown', (e) => {
1476
+ e.preventDefault();
1477
+ e.stopPropagation();
1478
+ this.editor.focus();
1479
+ document.execCommand('insertText', false, char);
1480
+ this.editor.history.record();
1481
+ this.editor.triggerChange();
1482
+ this.close();
1483
+ });
1484
+ this.picker.appendChild(item);
1485
+ });
1486
+
1487
+ button.appendChild(this.picker);
1488
+ this.activeButton = button;
1489
+ }
1490
+
1491
+ close() {
1492
+ if (this.picker) {
1493
+ this.picker.remove();
1494
+ this.picker = null;
1495
+ this.activeButton = null;
1496
+ }
1497
+ }
1498
+ }
1499
+
1500
+ // ============================================
1501
+ // SECTION 8: COMMANDS
1502
+ // ============================================
1503
+
1504
+ class Commands {
1505
+ constructor(editor) {
1506
+ this.editor = editor;
1507
+ }
1508
+
1509
+ exec(command, value = null) {
1510
+ this.editor.focus();
1511
+ const inlineCommands = ['bold', 'italic', 'underline', 'strikeThrough', 'subscript', 'superscript', 'foreColor', 'backColor', 'fontName', 'fontSize', 'removeFormat'];
1512
+ if (inlineCommands.includes(command)) {
1513
+ this._expandToWordIfCollapsed();
1514
+ }
1515
+ document.execCommand(command, false, value);
1516
+ this.editor.history.record();
1517
+ this.editor.updateToolbar();
1518
+ this.editor.triggerChange();
1519
+ }
1520
+
1521
+ _expandToWordIfCollapsed() {
1522
+ const sel = window.getSelection();
1523
+ if (!sel || !sel.rangeCount || !sel.isCollapsed) return;
1524
+ const range = sel.getRangeAt(0);
1525
+ const node = range.startContainer;
1526
+ if (node.nodeType !== Node.TEXT_NODE) return;
1527
+ const text = node.textContent;
1528
+ let start = range.startOffset;
1529
+ let end = range.startOffset;
1530
+ // Expand backward
1531
+ while (start > 0 && /\S/.test(text[start - 1])) start--;
1532
+ // Expand forward
1533
+ while (end < text.length && /\S/.test(text[end])) end++;
1534
+ if (start === end) return;
1535
+ range.setStart(node, start);
1536
+ range.setEnd(node, end);
1537
+ sel.removeAllRanges();
1538
+ sel.addRange(range);
1539
+ }
1540
+
1541
+ bold() { this.exec('bold'); }
1542
+ italic() { this.exec('italic'); }
1543
+ underline() { this.exec('underline'); }
1544
+ strikeThrough() { this.exec('strikeThrough'); }
1545
+ subscript() { this.exec('subscript'); }
1546
+ superscript() { this.exec('superscript'); }
1547
+
1548
+ justifyLeft() { this.exec('justifyLeft'); }
1549
+ justifyCenter() { this.exec('justifyCenter'); }
1550
+ justifyRight() { this.exec('justifyRight'); }
1551
+ justifyFull() { this.exec('justifyFull'); }
1552
+
1553
+ insertUnorderedList() { this.exec('insertUnorderedList'); }
1554
+ insertOrderedList() { this.exec('insertOrderedList'); }
1555
+
1556
+ indent() { this.exec('indent'); }
1557
+ outdent() { this.exec('outdent'); }
1558
+
1559
+ removeFormat() { this.exec('removeFormat'); }
1560
+
1561
+ insertHorizontalRule() { this.exec('insertHorizontalRule'); }
1562
+
1563
+ formatBlock(tag) {
1564
+ this.exec('formatBlock', `<${tag}>`);
1565
+ }
1566
+
1567
+ fontSize(sizeStr) {
1568
+ this.editor.focus();
1569
+ this._expandToWordIfCollapsed();
1570
+ document.execCommand('fontSize', false, '7');
1571
+ const marked = this.editor.contentArea.querySelectorAll('font[size="7"]');
1572
+ marked.forEach(el => {
1573
+ const span = document.createElement('span');
1574
+ span.style.fontSize = sizeStr;
1575
+ while (el.firstChild) span.appendChild(el.firstChild);
1576
+ el.parentNode.replaceChild(span, el);
1577
+ });
1578
+ this.editor.history.record();
1579
+ this.editor.updateToolbar();
1580
+ this.editor.triggerChange();
1581
+ }
1582
+
1583
+ fontFamily(font) {
1584
+ this.exec('fontName', font);
1585
+ }
1586
+
1587
+ foreColor(color) {
1588
+ this.exec('foreColor', color);
1589
+ }
1590
+
1591
+ backColor(color) {
1592
+ this.exec('backColor', color);
1593
+ }
1594
+
1595
+ resetForeColor() {
1596
+ this._resetColorProperty('color');
1597
+ }
1598
+
1599
+ resetBackColor() {
1600
+ this._resetColorProperty('backgroundColor');
1601
+ }
1602
+
1603
+ _resetColorProperty(cssProp) {
1604
+ this.editor.focus();
1605
+ const sel = window.getSelection();
1606
+ if (!sel || !sel.rangeCount) return;
1607
+ const range = sel.getRangeAt(0);
1608
+ if (range.collapsed) return;
1609
+
1610
+ const fragment = range.extractContents();
1611
+
1612
+ const processNode = (node) => {
1613
+ if (node.nodeType === Node.ELEMENT_NODE) {
1614
+ if (node.style && node.style[cssProp]) {
1615
+ node.style[cssProp] = '';
1616
+ if (!node.getAttribute('style') || !node.getAttribute('style').trim()) {
1617
+ node.removeAttribute('style');
1618
+ }
1619
+ }
1620
+ if (cssProp === 'color' && node.tagName === 'FONT' && node.hasAttribute('color')) {
1621
+ node.removeAttribute('color');
1622
+ }
1623
+ if (cssProp === 'backgroundColor' && node.tagName === 'FONT' && node.style.backgroundColor) {
1624
+ node.style.backgroundColor = '';
1625
+ }
1626
+ if (node.tagName === 'FONT' && !node.hasAttribute('color') && !node.hasAttribute('face') &&
1627
+ !node.hasAttribute('size') && (!node.getAttribute('style') || !node.getAttribute('style').trim())) {
1628
+ const parent = node.parentNode;
1629
+ if (parent) {
1630
+ while (node.firstChild) parent.insertBefore(node.firstChild, node);
1631
+ parent.removeChild(node);
1632
+ }
1633
+ return;
1634
+ }
1635
+ if (node.tagName === 'SPAN' && (!node.getAttribute('style') || !node.getAttribute('style').trim()) && !node.className) {
1636
+ const parent = node.parentNode;
1637
+ if (parent) {
1638
+ while (node.firstChild) parent.insertBefore(node.firstChild, node);
1639
+ parent.removeChild(node);
1640
+ }
1641
+ return;
1642
+ }
1643
+ Array.from(node.childNodes).forEach(processNode);
1644
+ }
1645
+ };
1646
+
1647
+ Array.from(fragment.childNodes).forEach(processNode);
1648
+ range.insertNode(fragment);
1649
+ sel.removeAllRanges();
1650
+ sel.addRange(range);
1651
+
1652
+ this.editor.history.record();
1653
+ this.editor.updateToolbar();
1654
+ this.editor.triggerChange();
1655
+ }
1656
+
1657
+ viewCode() {
1658
+ this.editor.toggleCodeView();
1659
+ }
1660
+
1661
+ autosave() {
1662
+ this.editor.toggleAutosave();
1663
+ }
1664
+
1665
+ print() {
1666
+ this.editor.printContent();
1667
+ }
1668
+
1669
+ insertHTML(html) {
1670
+ this.editor.focus();
1671
+ document.execCommand('insertHTML', false, html);
1672
+ this.editor.history.record();
1673
+ this.editor.triggerChange();
1674
+ }
1675
+
1676
+ insertLink(url, text, newTab = false) {
1677
+ const selection = Utils.getSelection();
1678
+ const range = selection.rangeCount > 0 ? selection.getRangeAt(0) : null;
1679
+
1680
+ if (range && !range.collapsed) {
1681
+ this.exec('createLink', url);
1682
+ if (newTab) {
1683
+ const links = this.editor.contentArea.querySelectorAll('a[href="' + url + '"]');
1684
+ links.forEach(link => link.setAttribute('target', '_blank'));
1685
+ }
1686
+ } else {
1687
+ const link = document.createElement('a');
1688
+ link.href = url;
1689
+ link.textContent = text || url;
1690
+ if (newTab) link.target = '_blank';
1691
+
1692
+ this.editor.focus();
1693
+ document.execCommand('insertHTML', false, link.outerHTML);
1694
+ this.editor.history.record();
1695
+ this.editor.triggerChange();
1696
+ }
1697
+ }
1698
+
1699
+ insertImage(url, alt = '', width = '') {
1700
+ let html = `<img src="${url}"`;
1701
+ if (alt) html += ` alt="${alt}"`;
1702
+ if (width) html += ` width="${width}"`;
1703
+ html += '>';
1704
+
1705
+ this.editor.focus();
1706
+ document.execCommand('insertHTML', false, html);
1707
+ this.editor.history.record();
1708
+ this.editor.triggerChange();
1709
+ }
1710
+
1711
+ insertTable(rows, cols, hasHeader = true) {
1712
+ let html = '<table class="neiki-table">';
1713
+
1714
+ for (let i = 0; i < rows; i++) {
1715
+ html += '<tr>';
1716
+ for (let j = 0; j < cols; j++) {
1717
+ if (i === 0 && hasHeader) {
1718
+ html += '<th>Header</th>';
1719
+ } else {
1720
+ html += '<td>Cell</td>';
1721
+ }
1722
+ }
1723
+ html += '</tr>';
1724
+ }
1725
+
1726
+ html += '</table><p><br></p>';
1727
+
1728
+ this.editor.focus();
1729
+ document.execCommand('insertHTML', false, html);
1730
+ this.editor.history.record();
1731
+ this.editor.triggerChange();
1732
+ }
1733
+
1734
+ undo() {
1735
+ if (this.editor.history.undo()) {
1736
+ this.editor.updateToolbar();
1737
+ this.editor.triggerChange();
1738
+ }
1739
+ }
1740
+
1741
+ redo() {
1742
+ if (this.editor.history.redo()) {
1743
+ this.editor.updateToolbar();
1744
+ this.editor.triggerChange();
1745
+ }
1746
+ }
1747
+ }
1748
+
1749
+ // ============================================
1750
+ // SECTION 9: MAIN EDITOR CLASS
1751
+ // ============================================
1752
+
1753
+ class NeikiEditor {
1754
+ constructor(element, options = {}) {
1755
+ this.originalElement = typeof element === 'string'
1756
+ ? document.querySelector(element)
1757
+ : element;
1758
+
1759
+ if (!this.originalElement) {
1760
+ throw new Error('NeikiEditor: Element not found');
1761
+ }
1762
+
1763
+ // Use stable ID based on element's id or a hash of selector, not random
1764
+ this.id = this.originalElement.id ||
1765
+ this.originalElement.getAttribute('data-neiki-id') ||
1766
+ 'neiki_' + (typeof element === 'string' ? element.replace(/[^a-zA-Z0-9]/g, '_') : 'editor');
1767
+
1768
+ this.config = Utils.deepMerge(DEFAULT_CONFIG, options);
1769
+ this.isFullscreen = false;
1770
+ this.isAutosaveEnabled = false;
1771
+ this.autosaveInterval = null;
1772
+
1773
+ this.init();
1774
+ }
1775
+
1776
+ init() {
1777
+ // Initialize storage first
1778
+ this.storage = new StorageManager(this.id);
1779
+
1780
+ // Set language for translations
1781
+ _currentLanguage = this.config.language || 'en';
1782
+
1783
+ // Load theme preference
1784
+ const savedTheme = StorageManager.getGlobal('theme', this.config.theme);
1785
+ this.config.theme = savedTheme;
1786
+
1787
+ this.createStructure();
1788
+ this.createToolbar();
1789
+ this.createContentArea();
1790
+ this.createStatusBar();
1791
+
1792
+ this.history = new HistoryManager(this);
1793
+ this.modal = new ModalManager(this);
1794
+ this.dropdown = new DropdownManager(this);
1795
+ this.colorPicker = new ColorPicker(this);
1796
+ this.emojiPicker = new EmojiPicker(this);
1797
+ this.specialCharsPicker = new SpecialCharsPicker(this);
1798
+ this.commands = new Commands(this);
1799
+ this.tableContextMenu = new TableContextMenu(this);
1800
+ this.floatingToolbar = new FloatingToolbar(this);
1801
+
1802
+ this.bindEvents();
1803
+ this.initDragDrop();
1804
+ this.initPlugins();
1805
+
1806
+ // Sync restored content to original element
1807
+ this.syncToOriginal();
1808
+
1809
+ // Record initial state (content already restored in createContentArea)
1810
+ this.history.record();
1811
+
1812
+ // Restore autosave state
1813
+ const savedAutosave = this.storage.get('autosave_enabled', false);
1814
+ if (savedAutosave) {
1815
+ this.enableAutosave();
1816
+ }
1817
+
1818
+ this.updateStatusBar();
1819
+ this.updateToolbar();
1820
+
1821
+ if (this.config.autofocus) {
1822
+ this.focus();
1823
+ }
1824
+
1825
+ if (this.config.onReady) {
1826
+ this.config.onReady(this);
1827
+ }
1828
+ }
1829
+
1830
+ createStructure() {
1831
+ const langClass = _currentLanguage !== 'en' ? `neiki-lang-${_currentLanguage}` : '';
1832
+ this.container = Utils.createElement('div', {
1833
+ className: `neiki-editor ${this.config.theme === 'dark' ? 'neiki-dark' : ''} ${langClass}`.trim(),
1834
+ id: this.id
1835
+ });
1836
+
1837
+ this.originalElement.style.display = 'none';
1838
+ this.originalElement.parentNode.insertBefore(this.container, this.originalElement);
1839
+ }
1840
+
1841
+ createToolbar() {
1842
+ this.toolbar = Utils.createElement('div', { className: 'neiki-toolbar' });
1843
+ this.toolbarButtons = {};
1844
+ this.toolbarSelects = {};
1845
+
1846
+ let currentGroup = Utils.createElement('div', { className: 'neiki-toolbar-group' });
1847
+
1848
+ const appendToGroup = (el) => { currentGroup.appendChild(el); };
1849
+ const flushGroup = () => {
1850
+ if (currentGroup.childNodes.length > 0) {
1851
+ this.toolbar.appendChild(currentGroup);
1852
+ }
1853
+ currentGroup = Utils.createElement('div', { className: 'neiki-toolbar-group' });
1854
+ };
1855
+
1856
+ this.config.toolbar.forEach(item => {
1857
+ if (item === '|') {
1858
+ flushGroup();
1859
+ return;
1860
+ }
1861
+
1862
+ const config = TOOLBAR_ITEMS[item];
1863
+ if (!config) return;
1864
+
1865
+ // Handle <select> type (heading, fontFamily)
1866
+ if (config.type === 'select') {
1867
+ const select = Utils.createElement('select', {
1868
+ className: 'neiki-select',
1869
+ title: t(config.titleKey),
1870
+ 'data-command': item
1871
+ });
1872
+
1873
+ if (item === 'heading') {
1874
+ HEADINGS.forEach(({ labelKey, value }) => {
1875
+ const opt = document.createElement('option');
1876
+ opt.value = value;
1877
+ opt.textContent = t(labelKey);
1878
+ select.appendChild(opt);
1879
+ });
1880
+ } else if (item === 'fontFamily') {
1881
+ FONT_FAMILIES.forEach(({ labelKey, value }) => {
1882
+ const opt = document.createElement('option');
1883
+ opt.value = value;
1884
+ opt.textContent = t(labelKey);
1885
+ opt.style.fontFamily = value;
1886
+ select.appendChild(opt);
1887
+ });
1888
+ }
1889
+
1890
+ select.addEventListener('change', (e) => {
1891
+ e.preventDefault();
1892
+ if (item === 'heading') {
1893
+ this.commands.formatBlock(select.value);
1894
+ } else if (item === 'fontFamily') {
1895
+ this.commands.fontFamily(select.value);
1896
+ }
1897
+ this.focus();
1898
+ });
1899
+
1900
+ this.toolbarSelects[item] = select;
1901
+ appendToGroup(select);
1902
+ return;
1903
+ }
1904
+
1905
+ // Handle fontSizeWidget type
1906
+ if (config.type === 'fontSizeWidget') {
1907
+ const wrapper = Utils.createElement('div', { className: 'neiki-fontsize-widget' });
1908
+
1909
+ const minusBtn = Utils.createElement('button', {
1910
+ className: 'neiki-fontsize-btn',
1911
+ type: 'button',
1912
+ title: t('toolbar.decreaseFontSize'),
1913
+ innerHTML: '<svg viewBox="0 0 24 24" width="14" height="14"><path d="M19 13H5v-2h14v2z" fill="currentColor"/></svg>'
1914
+ });
1915
+
1916
+ const input = Utils.createElement('input', {
1917
+ className: 'neiki-fontsize-input',
1918
+ type: 'text',
1919
+ title: t('toolbar.fontSize'),
1920
+ value: '16'
1921
+ });
1922
+
1923
+ const plusBtn = Utils.createElement('button', {
1924
+ className: 'neiki-fontsize-btn',
1925
+ type: 'button',
1926
+ title: t('toolbar.increaseFontSize'),
1927
+ innerHTML: '<svg viewBox="0 0 24 24" width="14" height="14"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z" fill="currentColor"/></svg>'
1928
+ });
1929
+
1930
+ // Save/restore selection for font size operations
1931
+ let _savedRange = null;
1932
+ const _saveSelection = () => {
1933
+ const sel = window.getSelection();
1934
+ if (sel && sel.rangeCount > 0) {
1935
+ _savedRange = sel.getRangeAt(0).cloneRange();
1936
+ }
1937
+ };
1938
+ const _restoreSelection = () => {
1939
+ if (_savedRange) {
1940
+ const sel = window.getSelection();
1941
+ sel.removeAllRanges();
1942
+ sel.addRange(_savedRange);
1943
+ }
1944
+ };
1945
+
1946
+ // Dropdown for preset sizes
1947
+ const dropdown = Utils.createElement('div', { className: 'neiki-fontsize-dropdown' });
1948
+ FONT_SIZES.forEach(size => {
1949
+ const item = Utils.createElement('div', {
1950
+ className: 'neiki-fontsize-dropdown-item',
1951
+ textContent: size,
1952
+ 'data-size': String(size)
1953
+ });
1954
+ item.addEventListener('mousedown', (e) => {
1955
+ e.preventDefault();
1956
+ e.stopPropagation();
1957
+ });
1958
+ item.addEventListener('click', (e) => {
1959
+ e.preventDefault();
1960
+ e.stopPropagation();
1961
+ input.value = size;
1962
+ _restoreSelection();
1963
+ this.commands.fontSize(size + 'px');
1964
+ dropdown.classList.remove('show');
1965
+ });
1966
+ dropdown.appendChild(item);
1967
+ });
1968
+
1969
+ const applyFontSize = () => {
1970
+ const val = parseInt(input.value);
1971
+ if (val && val > 0) {
1972
+ _restoreSelection();
1973
+ this.commands.fontSize(val + 'px');
1974
+ }
1975
+ };
1976
+
1977
+ minusBtn.addEventListener('mousedown', (e) => {
1978
+ e.preventDefault();
1979
+ e.stopPropagation();
1980
+ _saveSelection();
1981
+ });
1982
+ minusBtn.addEventListener('click', (e) => {
1983
+ e.preventDefault();
1984
+ _restoreSelection();
1985
+ const current = parseInt(input.value) || 16;
1986
+ const newSize = Math.max(1, current - 1);
1987
+ input.value = newSize;
1988
+ this.commands.fontSize(newSize + 'px');
1989
+ });
1990
+
1991
+ plusBtn.addEventListener('mousedown', (e) => {
1992
+ e.preventDefault();
1993
+ e.stopPropagation();
1994
+ _saveSelection();
1995
+ });
1996
+ plusBtn.addEventListener('click', (e) => {
1997
+ e.preventDefault();
1998
+ _restoreSelection();
1999
+ const current = parseInt(input.value) || 16;
2000
+ const newSize = Math.min(999, current + 1);
2001
+ input.value = newSize;
2002
+ this.commands.fontSize(newSize + 'px');
2003
+ });
2004
+
2005
+ input.addEventListener('mousedown', (e) => {
2006
+ _saveSelection();
2007
+ });
2008
+
2009
+ input.addEventListener('focus', () => {
2010
+ dropdown.classList.add('show');
2011
+ });
2012
+
2013
+ input.addEventListener('keydown', (e) => {
2014
+ if (e.key === 'Enter') {
2015
+ e.preventDefault();
2016
+ applyFontSize();
2017
+ dropdown.classList.remove('show');
2018
+ this.focus();
2019
+ }
2020
+ if (e.key === 'Escape') {
2021
+ dropdown.classList.remove('show');
2022
+ this.focus();
2023
+ }
2024
+ });
2025
+
2026
+ document.addEventListener('mousedown', (e) => {
2027
+ if (!wrapper.contains(e.target)) {
2028
+ dropdown.classList.remove('show');
2029
+ }
2030
+ });
2031
+
2032
+ wrapper.appendChild(minusBtn);
2033
+ wrapper.appendChild(input);
2034
+ wrapper.appendChild(plusBtn);
2035
+ wrapper.appendChild(dropdown);
2036
+
2037
+ this.fontSizeInput = input;
2038
+ appendToGroup(wrapper);
2039
+ return;
2040
+ }
2041
+
2042
+ // Handle Insert dropdown
2043
+ if (config.type === 'insertDropdown') {
2044
+ const btn = Utils.createElement('button', {
2045
+ className: 'neiki-toolbar-btn neiki-insert-dropdown-btn',
2046
+ title: t(config.titleKey),
2047
+ type: 'button'
2048
+ });
2049
+ btn.innerHTML = Icons[config.icon] + '<span class="neiki-insert-label">' + t('toolbar.insert') + '</span><span class="neiki-chevron">' + Icons['chevron-down'] + '</span>';
2050
+
2051
+ const dropdown = Utils.createElement('div', { className: 'neiki-insert-dropdown' });
2052
+
2053
+ const insertItems = [
2054
+ { key: 'link', icon: Icons.link, labelKey: 'insert.link', action: () => this.modal.open('link', { text: Utils.getSelection().toString() }) },
2055
+ { key: 'image', icon: Icons.image, labelKey: 'insert.image', action: () => this.modal.open('image', {}) },
2056
+ { key: 'table', icon: Icons.table, labelKey: 'insert.table', action: () => this.modal.open('table', {}) },
2057
+ { key: 'emoji', icon: Icons.emoji, labelKey: 'insert.emoji', action: () => this.emojiPicker.toggle(btn) },
2058
+ { key: 'specialChars', icon: Icons.specialChars, labelKey: 'insert.symbol', action: () => this.specialCharsPicker.toggle(btn) }
2059
+ ];
2060
+
2061
+ insertItems.forEach(({ icon, labelKey, action }) => {
2062
+ const item = Utils.createElement('div', {
2063
+ className: 'neiki-dropdown-item'
2064
+ });
2065
+ item.innerHTML = '<span class="neiki-dropdown-item-icon">' + icon + '</span>' + t(labelKey);
2066
+ item.addEventListener('click', (e) => {
2067
+ e.preventDefault();
2068
+ e.stopPropagation();
2069
+ dropdown.classList.remove('show');
2070
+ action();
2071
+ });
2072
+ dropdown.appendChild(item);
2073
+ });
2074
+
2075
+ btn.addEventListener('click', (e) => {
2076
+ e.preventDefault();
2077
+ e.stopPropagation();
2078
+ // Close moreMenu if open
2079
+ const moreDD = this.toolbar.querySelector('.neiki-more-dropdown.show');
2080
+ if (moreDD) moreDD.classList.remove('show');
2081
+ // Close emoji/specialChars pickers if open
2082
+ this.emojiPicker.close();
2083
+ this.specialCharsPicker.close();
2084
+ dropdown.classList.toggle('show');
2085
+ });
2086
+
2087
+ document.addEventListener('mousedown', (e) => {
2088
+ if (!btn.contains(e.target) && !dropdown.contains(e.target)) {
2089
+ dropdown.classList.remove('show');
2090
+ }
2091
+ });
2092
+
2093
+ btn.appendChild(dropdown);
2094
+ this.toolbarButtons[item] = btn;
2095
+ appendToGroup(btn);
2096
+ return;
2097
+ }
2098
+
2099
+ // Handle More menu (⋯)
2100
+ if (config.type === 'moreMenu') {
2101
+ const btn = Utils.createElement('button', {
2102
+ className: 'neiki-toolbar-btn neiki-more-btn',
2103
+ title: t(config.titleKey),
2104
+ type: 'button',
2105
+ innerHTML: Icons[config.icon],
2106
+ 'data-command': item
2107
+ });
2108
+
2109
+ const dropdown = Utils.createElement('div', { className: 'neiki-more-dropdown' });
2110
+
2111
+ const moreItems = [
2112
+ { key: 'save', icon: Icons.save, labelKey: 'menu.save', action: () => this.triggerSave() },
2113
+ { key: 'preview', icon: Icons.eye, labelKey: 'menu.preview', action: () => this.previewContent() },
2114
+ { key: 'download', icon: Icons.download, labelKey: 'menu.download', action: () => this.downloadContent() },
2115
+ { key: 'print', icon: Icons.print, labelKey: 'menu.print', action: () => this.printContent() },
2116
+ { key: 'divider' },
2117
+ { key: 'autosave', icon: Icons.save, labelKey: 'menu.autosave', action: () => this.toggleAutosave(), toggle: true },
2118
+ { key: 'divider' },
2119
+ { key: 'clearAll', icon: Icons.trash, labelKey: 'menu.clearAll', action: () => this.clearAll(), danger: true },
2120
+ { key: 'themeToggle', icon: Icons.sun, labelKey: 'menu.toggleTheme', action: () => { this.toggleTheme(); this._updateThemeMenuItem(); } },
2121
+ { key: 'fullscreen', icon: Icons.fullscreen, labelKey: 'menu.fullscreen', action: () => this.toggleFullscreen() }
2122
+ ];
2123
+
2124
+ moreItems.forEach(({ key, icon, labelKey, action, danger, toggle }) => {
2125
+ if (key === 'divider') {
2126
+ dropdown.appendChild(Utils.createElement('div', { className: 'neiki-dropdown-divider' }));
2127
+ return;
2128
+ }
2129
+ const menuItem = Utils.createElement('div', {
2130
+ className: 'neiki-dropdown-item' + (danger ? ' neiki-dropdown-item-danger' : '')
2131
+ });
2132
+ menuItem.innerHTML = '<span class="neiki-dropdown-item-icon">' + icon + '</span><span class="neiki-dropdown-item-label">' + t(labelKey) + '</span>';
2133
+
2134
+ if (key === 'autosave') {
2135
+ const badge = Utils.createElement('span', { className: 'neiki-autosave-badge' });
2136
+ badge.textContent = '✕';
2137
+ menuItem.appendChild(badge);
2138
+ this._autosaveMenuItem = menuItem;
2139
+ this._autosaveBadge = badge;
2140
+ }
2141
+
2142
+ if (key === 'themeToggle') {
2143
+ this._themeMenuItem = menuItem;
2144
+ this._themeMenuIcon = menuItem.querySelector('.neiki-dropdown-item-icon');
2145
+ }
2146
+
2147
+ menuItem.addEventListener('click', (e) => {
2148
+ e.preventDefault();
2149
+ e.stopPropagation();
2150
+ if (key !== 'autosave') dropdown.classList.remove('show');
2151
+ action();
2152
+ });
2153
+ dropdown.appendChild(menuItem);
2154
+ });
2155
+
2156
+ btn.addEventListener('click', (e) => {
2157
+ e.preventDefault();
2158
+ e.stopPropagation();
2159
+ // Close insertDropdown if open
2160
+ const insDD = this.toolbar.querySelector('.neiki-insert-dropdown.show');
2161
+ if (insDD) insDD.classList.remove('show');
2162
+ dropdown.classList.toggle('show');
2163
+ });
2164
+
2165
+ document.addEventListener('mousedown', (e) => {
2166
+ if (!btn.contains(e.target) && !dropdown.contains(e.target)) {
2167
+ dropdown.classList.remove('show');
2168
+ }
2169
+ });
2170
+
2171
+ btn.appendChild(dropdown);
2172
+ this.toolbarButtons[item] = btn;
2173
+ appendToGroup(btn);
2174
+ currentGroup.style.marginLeft = 'auto';
2175
+ return;
2176
+ }
2177
+
2178
+ // Default: regular button
2179
+ const button = Utils.createElement('button', {
2180
+ className: 'neiki-toolbar-btn',
2181
+ title: t(config.titleKey),
2182
+ type: 'button',
2183
+ innerHTML: Icons[config.icon] || '',
2184
+ 'data-command': item
2185
+ });
2186
+
2187
+ button.addEventListener('click', (e) => {
2188
+ e.preventDefault();
2189
+ e.stopPropagation();
2190
+ this.handleToolbarClick(item, button);
2191
+ });
2192
+
2193
+ this.toolbarButtons[item] = button;
2194
+ appendToGroup(button);
2195
+ });
2196
+
2197
+ // Flush the last group
2198
+ flushGroup();
2199
+
2200
+ this.container.appendChild(this.toolbar);
2201
+ }
2202
+
2203
+ createContentArea() {
2204
+ this.contentWrapper = Utils.createElement('div', { className: 'neiki-content-wrapper' });
2205
+
2206
+ this.contentArea = Utils.createElement('div', {
2207
+ className: 'neiki-content',
2208
+ contentEditable: !this.config.readonly,
2209
+ spellcheck: this.config.spellcheck,
2210
+ 'data-placeholder': t('placeholder')
2211
+ });
2212
+
2213
+ if (this.config.minHeight) {
2214
+ this.contentArea.style.minHeight = this.config.minHeight + 'px';
2215
+ }
2216
+ if (this.config.maxHeight) {
2217
+ this.contentArea.style.maxHeight = this.config.maxHeight + 'px';
2218
+ this.contentArea.style.overflowY = 'auto';
2219
+ }
2220
+
2221
+ // Check if autosave is enabled AND has saved content
2222
+ const autosaveEnabled = this.storage.get('autosave_enabled', false);
2223
+ const autosavedContent = this.storage.get('autosave_content', null);
2224
+
2225
+ if (autosaveEnabled && autosavedContent) {
2226
+ // Restore autosaved content only if autosave was enabled
2227
+ this.contentArea.innerHTML = autosavedContent;
2228
+ } else {
2229
+ // Always use original element content (textarea value or innerHTML)
2230
+ // This ensures the page's actual content is shown, not old localStorage data
2231
+ if (this.originalElement.value) {
2232
+ this.contentArea.innerHTML = this.originalElement.value;
2233
+ } else if (this.originalElement.innerHTML.trim()) {
2234
+ this.contentArea.innerHTML = this.originalElement.innerHTML;
2235
+ }
2236
+ }
2237
+
2238
+ this._ensureDefaultBlock();
2239
+
2240
+ this.contentWrapper.appendChild(this.contentArea);
2241
+
2242
+ // Code view overlay
2243
+ this.isCodeViewOpen = false;
2244
+ this.codeView = Utils.createElement('div', { className: 'neiki-code-view' });
2245
+ const codeViewHeader = Utils.createElement('div', { className: 'neiki-code-view-header' });
2246
+ const codeViewTitle = Utils.createElement('span', {
2247
+ className: 'neiki-code-view-title',
2248
+ textContent: 'HTML Source'
2249
+ });
2250
+ const codeViewApply = Utils.createElement('button', {
2251
+ className: 'neiki-btn neiki-code-view-apply',
2252
+ type: 'button',
2253
+ title: 'Apply changes and close'
2254
+ });
2255
+ codeViewApply.innerHTML = Icons.close + '<span style="margin-left:5px;font-size:12px;font-weight:500;">Apply & Close</span>';
2256
+ codeViewApply.addEventListener('click', () => this.toggleCodeView());
2257
+ codeViewHeader.appendChild(codeViewTitle);
2258
+ codeViewHeader.appendChild(codeViewApply);
2259
+ this.codeViewTextarea = Utils.createElement('textarea', {
2260
+ className: 'neiki-code-view-textarea',
2261
+ spellcheck: 'false'
2262
+ });
2263
+ this.codeView.appendChild(codeViewHeader);
2264
+ this.codeView.appendChild(this.codeViewTextarea);
2265
+ this.contentWrapper.appendChild(this.codeView);
2266
+
2267
+ this.container.appendChild(this.contentWrapper);
2268
+ }
2269
+
2270
+ bindEvents() {
2271
+ // Content changes
2272
+ this.contentArea.addEventListener('input', Utils.debounce(() => {
2273
+ this._ensureDefaultBlock();
2274
+ this.history.record();
2275
+ this.syncToOriginal();
2276
+ this.triggerChange();
2277
+ this.updateStatusBar();
2278
+ }, 300));
2279
+
2280
+ // Selection changes
2281
+ document.addEventListener('selectionchange', () => {
2282
+ if (this.contentArea.contains(document.activeElement) ||
2283
+ document.activeElement === this.contentArea) {
2284
+ this.updateToolbar();
2285
+ this.updateStatusBar();
2286
+ }
2287
+ });
2288
+
2289
+ // Focus/Blur
2290
+ this.contentArea.addEventListener('focus', () => {
2291
+ if (this.config.onFocus) this.config.onFocus(this);
2292
+ });
2293
+
2294
+ this.contentArea.addEventListener('blur', () => {
2295
+ if (this.config.onBlur) this.config.onBlur(this);
2296
+ });
2297
+
2298
+ // Keyboard shortcuts
2299
+ this.contentArea.addEventListener('keydown', (e) => this.handleKeydown(e));
2300
+
2301
+ // Paste handling
2302
+ this.contentArea.addEventListener('paste', (e) => this.handlePaste(e));
2303
+ }
2304
+
2305
+ handleToolbarClick(item, button) {
2306
+ const config = TOOLBAR_ITEMS[item];
2307
+ if (!config) return;
2308
+
2309
+ // Skip custom types (handled in createToolbar)
2310
+ if (config.type) return;
2311
+
2312
+ // Handle color pickers
2313
+ if (config.picker === 'color') {
2314
+ this.colorPicker.toggle(button, config.command);
2315
+ return;
2316
+ }
2317
+
2318
+ // Handle emoji picker
2319
+ if (config.picker === 'emoji') {
2320
+ this.emojiPicker.toggle(button);
2321
+ return;
2322
+ }
2323
+
2324
+ // Handle special chars picker
2325
+ if (config.picker === 'specialChars') {
2326
+ this.specialCharsPicker.toggle(button);
2327
+ return;
2328
+ }
2329
+
2330
+ // Handle modals
2331
+ if (config.modal) {
2332
+ const savedRange = Utils.saveSelection();
2333
+ let data = {};
2334
+
2335
+ if (item === 'link') {
2336
+ const sel = Utils.getSelection();
2337
+ data.text = sel.toString();
2338
+ }
2339
+
2340
+ this.modal.open(item, data);
2341
+ Utils.restoreSelection(savedRange);
2342
+ return;
2343
+ }
2344
+
2345
+ // Handle fullscreen
2346
+ if (item === 'fullscreen') {
2347
+ this.toggleFullscreen();
2348
+ return;
2349
+ }
2350
+
2351
+ // Handle theme toggle
2352
+ if (item === 'themeToggle') {
2353
+ this.toggleTheme();
2354
+ return;
2355
+ }
2356
+
2357
+ // Handle regular commands
2358
+ if (config.value) {
2359
+ this.commands[config.command](config.value);
2360
+ } else if (this.commands[config.command]) {
2361
+ this.commands[config.command]();
2362
+ } else {
2363
+ this.commands.exec(config.command);
2364
+ }
2365
+ }
2366
+
2367
+ handleKeydown(e) {
2368
+ // Ctrl/Cmd shortcuts
2369
+ if (e.ctrlKey || e.metaKey) {
2370
+ switch (e.key.toLowerCase()) {
2371
+ case 'b':
2372
+ e.preventDefault();
2373
+ this.commands.bold();
2374
+ break;
2375
+ case 'i':
2376
+ e.preventDefault();
2377
+ this.commands.italic();
2378
+ break;
2379
+ case 'u':
2380
+ e.preventDefault();
2381
+ this.commands.underline();
2382
+ break;
2383
+ case 'k':
2384
+ e.preventDefault();
2385
+ this.modal.open('link', { text: Utils.getSelection().toString() });
2386
+ break;
2387
+ case 'z':
2388
+ e.preventDefault();
2389
+ if (e.shiftKey) {
2390
+ this.commands.redo();
2391
+ } else {
2392
+ this.commands.undo();
2393
+ }
2394
+ break;
2395
+ case 'y':
2396
+ e.preventDefault();
2397
+ this.commands.redo();
2398
+ break;
2399
+ case 's':
2400
+ e.preventDefault();
2401
+ this.triggerSave();
2402
+ break;
2403
+ }
2404
+ }
2405
+
2406
+ // Tab handling
2407
+ if (e.key === 'Tab') {
2408
+ e.preventDefault();
2409
+ if (e.shiftKey) {
2410
+ this.commands.outdent();
2411
+ } else {
2412
+ this.commands.indent();
2413
+ }
2414
+ }
2415
+ }
2416
+
2417
+ handlePaste(e) {
2418
+ // Get plain text and sanitize
2419
+ e.preventDefault();
2420
+
2421
+ let text = '';
2422
+ if (e.clipboardData) {
2423
+ // Try to get HTML first
2424
+ let html = e.clipboardData.getData('text/html');
2425
+ if (html) {
2426
+ text = Utils.sanitizeHTML(html);
2427
+ } else {
2428
+ text = e.clipboardData.getData('text/plain');
2429
+ // Convert line breaks to <br>
2430
+ text = text.replace(/\n/g, '<br>');
2431
+ }
2432
+ }
2433
+
2434
+ document.execCommand('insertHTML', false, text);
2435
+ this.history.record();
2436
+ this.triggerChange();
2437
+ }
2438
+
2439
+ updateToolbar() {
2440
+ Object.entries(this.toolbarButtons).forEach(([item, button]) => {
2441
+ const config = TOOLBAR_ITEMS[item];
2442
+ if (!config || config.type) return;
2443
+
2444
+ let isActive = false;
2445
+
2446
+ try {
2447
+ switch (config.command) {
2448
+ case 'bold':
2449
+ case 'italic':
2450
+ case 'underline':
2451
+ case 'strikeThrough':
2452
+ case 'subscript':
2453
+ case 'superscript':
2454
+ case 'insertUnorderedList':
2455
+ case 'insertOrderedList':
2456
+ isActive = document.queryCommandState(config.command);
2457
+ break;
2458
+ case 'justifyLeft':
2459
+ case 'justifyCenter':
2460
+ case 'justifyRight':
2461
+ case 'justifyFull':
2462
+ isActive = document.queryCommandState(config.command);
2463
+ break;
2464
+ }
2465
+ } catch (e) {
2466
+ // queryCommandState can throw in some browsers
2467
+ }
2468
+
2469
+ button.classList.toggle('active', isActive);
2470
+ });
2471
+
2472
+ // Update undo/redo states
2473
+ if (this.toolbarButtons.undo) {
2474
+ this.toolbarButtons.undo.disabled = !this.history.canUndo();
2475
+ }
2476
+ if (this.toolbarButtons.redo) {
2477
+ this.toolbarButtons.redo.disabled = !this.history.canRedo();
2478
+ }
2479
+ // Update viewCode active state
2480
+ if (this.toolbarButtons.viewCode) {
2481
+ this.toolbarButtons.viewCode.classList.toggle('active', this.isCodeViewOpen);
2482
+ }
2483
+
2484
+ // Sync heading select
2485
+ if (this.toolbarSelects.heading) {
2486
+ const block = this.getCurrentBlockType();
2487
+ const validValues = HEADINGS.map(h => h.value);
2488
+ this.toolbarSelects.heading.value = validValues.includes(block) ? block : 'p';
2489
+ }
2490
+
2491
+ // Sync fontFamily select
2492
+ if (this.toolbarSelects.fontFamily) {
2493
+ try {
2494
+ const font = document.queryCommandValue('fontName');
2495
+ if (font) {
2496
+ const match = FONT_FAMILIES.find(f => f.value.toLowerCase().includes(font.toLowerCase().replace(/"/g, '')));
2497
+ if (match) this.toolbarSelects.fontFamily.value = match.value;
2498
+ }
2499
+ } catch (e) {}
2500
+ }
2501
+
2502
+ // Sync font size input
2503
+ if (this.fontSizeInput) {
2504
+ try {
2505
+ const sel = window.getSelection();
2506
+ if (sel && sel.rangeCount) {
2507
+ let node = sel.getRangeAt(0).startContainer;
2508
+ if (node.nodeType === Node.TEXT_NODE) node = node.parentNode;
2509
+ const computed = window.getComputedStyle(node);
2510
+ const size = Math.round(parseFloat(computed.fontSize));
2511
+ if (size) this.fontSizeInput.value = size;
2512
+ }
2513
+ } catch (e) {}
2514
+ }
2515
+
2516
+ // Update autosave badge in more menu
2517
+ if (this._autosaveBadge) {
2518
+ this._autosaveBadge.textContent = this.isAutosaveEnabled ? '✓' : '✕';
2519
+ this._autosaveBadge.classList.toggle('active', this.isAutosaveEnabled);
2520
+ }
2521
+ }
2522
+
2523
+ toggleFullscreen() {
2524
+ this.isFullscreen = !this.isFullscreen;
2525
+ this.container.classList.toggle('neiki-fullscreen', this.isFullscreen);
2526
+ document.body.classList.toggle('neiki-fullscreen-active', this.isFullscreen);
2527
+
2528
+ if (this.toolbarButtons.fullscreen) {
2529
+ this.toolbarButtons.fullscreen.classList.toggle('active', this.isFullscreen);
2530
+ }
2531
+ }
2532
+
2533
+ toggleTheme() {
2534
+ const isDark = this.container.classList.contains('neiki-dark');
2535
+ const newTheme = isDark ? 'light' : 'dark';
2536
+
2537
+ this.container.classList.toggle('neiki-dark', !isDark);
2538
+ this.config.theme = newTheme;
2539
+
2540
+ // Persist theme choice
2541
+ StorageManager.setGlobal('theme', newTheme);
2542
+
2543
+ // Update button icon and active state
2544
+ if (this.toolbarButtons.themeToggle) {
2545
+ this.toolbarButtons.themeToggle.innerHTML = isDark ? Icons.sun : Icons.moon;
2546
+ this.toolbarButtons.themeToggle.classList.toggle('active', !isDark);
2547
+ this.toolbarButtons.themeToggle.title = isDark ? 'Switch to Dark Mode' : 'Switch to Light Mode';
2548
+ }
2549
+ this._updateThemeMenuItem();
2550
+ }
2551
+
2552
+ _updateThemeMenuItem() {
2553
+ if (this._themeMenuIcon) {
2554
+ const isDark = this.container.classList.contains('neiki-dark');
2555
+ this._themeMenuIcon.innerHTML = isDark ? Icons.moon : Icons.sun;
2556
+ }
2557
+ }
2558
+
2559
+ triggerSave() {
2560
+ if (this.config.onSave) {
2561
+ this.config.onSave(this.getContent(), this);
2562
+ }
2563
+ }
2564
+
2565
+ previewContent() {
2566
+ const content = this.getContent();
2567
+
2568
+ // Create overlay
2569
+ const overlay = Utils.createElement('div', { className: 'neiki-preview-overlay' });
2570
+
2571
+ const modal = Utils.createElement('div', { className: 'neiki-preview-modal' });
2572
+
2573
+ const header = Utils.createElement('div', { className: 'neiki-preview-header' });
2574
+ header.innerHTML = '<span>' + t('preview.title') + '</span>';
2575
+ const closeBtn = Utils.createElement('button', {
2576
+ className: 'neiki-preview-close',
2577
+ type: 'button',
2578
+ innerHTML: Icons.close
2579
+ });
2580
+ closeBtn.addEventListener('click', () => overlay.remove());
2581
+ header.appendChild(closeBtn);
2582
+
2583
+ const body = Utils.createElement('div', { className: 'neiki-preview-body' });
2584
+ body.innerHTML = content;
2585
+
2586
+ modal.appendChild(header);
2587
+ modal.appendChild(body);
2588
+ overlay.appendChild(modal);
2589
+
2590
+ overlay.addEventListener('click', (e) => {
2591
+ if (e.target === overlay) overlay.remove();
2592
+ });
2593
+
2594
+ document.addEventListener('keydown', function escHandler(e) {
2595
+ if (e.key === 'Escape') {
2596
+ overlay.remove();
2597
+ document.removeEventListener('keydown', escHandler);
2598
+ }
2599
+ });
2600
+
2601
+ document.body.appendChild(overlay);
2602
+ }
2603
+
2604
+ downloadContent() {
2605
+ const content = this.getContent();
2606
+ const fullHTML = '<!DOCTYPE html>\n<html lang="en">\n<head>\n<meta charset="UTF-8">\n<meta name="viewport" content="width=device-width, initial-scale=1.0">\n<title>Document</title>\n<style>\nbody{font-family:-apple-system,BlinkMacSystemFont,\'Segoe UI\',Roboto,sans-serif;max-width:800px;margin:40px auto;padding:0 20px;line-height:1.7;color:#1e293b;}\nimg{max-width:100%;}\ntable{border-collapse:collapse;width:100%;}\ntable td,table th{border:1px solid #d1d5db;padding:8px 12px;}\n</style>\n</head>\n<body>\n' + content + '\n</body>\n</html>';
2607
+
2608
+ const blob = new Blob([fullHTML], { type: 'text/html' });
2609
+ const url = URL.createObjectURL(blob);
2610
+ const a = document.createElement('a');
2611
+ a.href = url;
2612
+ a.download = 'document.html';
2613
+ a.click();
2614
+ URL.revokeObjectURL(url);
2615
+ }
2616
+
2617
+ clearAll() {
2618
+ if (this.getContent().trim() && !confirm(t('confirm.clearAll'))) return;
2619
+ this.setContent('');
2620
+ this.history.record();
2621
+ this.triggerChange();
2622
+ this.updateStatusBar();
2623
+ }
2624
+
2625
+ _ensureDefaultBlock() {
2626
+ // Ensure the content area always has at least one <p> block
2627
+ if (!this.contentArea) return;
2628
+ const html = this.contentArea.innerHTML.trim();
2629
+ if (!html || html === '<br>') {
2630
+ this.contentArea.innerHTML = '<p><br></p>';
2631
+ // Place cursor inside the paragraph if focused
2632
+ try {
2633
+ if (document.activeElement === this.contentArea) {
2634
+ const p = this.contentArea.querySelector('p');
2635
+ if (p) {
2636
+ const sel = window.getSelection();
2637
+ const range = document.createRange();
2638
+ range.setStart(p, 0);
2639
+ range.collapse(true);
2640
+ sel.removeAllRanges();
2641
+ sel.addRange(range);
2642
+ }
2643
+ }
2644
+ } catch (e) {}
2645
+ return;
2646
+ }
2647
+ // Wrap bare text nodes in <p>
2648
+ const childNodes = Array.from(this.contentArea.childNodes);
2649
+ childNodes.forEach(node => {
2650
+ if (node.nodeType === Node.TEXT_NODE && node.textContent.trim()) {
2651
+ const p = document.createElement('p');
2652
+ node.parentNode.insertBefore(p, node);
2653
+ p.appendChild(node);
2654
+ }
2655
+ });
2656
+ }
2657
+
2658
+ syncToOriginal() {
2659
+ if (this.originalElement.tagName === 'TEXTAREA' ||
2660
+ this.originalElement.tagName === 'INPUT') {
2661
+ this.originalElement.value = this.getContent();
2662
+ } else {
2663
+ this.originalElement.innerHTML = this.getContent();
2664
+ }
2665
+ }
2666
+
2667
+ triggerChange() {
2668
+ if (this.config.onChange) {
2669
+ this.config.onChange(this.getContent(), this);
2670
+ }
2671
+
2672
+ // Dispatch custom event
2673
+ this.container.dispatchEvent(new CustomEvent('neiki:change', {
2674
+ detail: { content: this.getContent(), editor: this }
2675
+ }));
2676
+ }
2677
+
2678
+ // ============================================
2679
+ // PUBLIC API
2680
+ // ============================================
2681
+
2682
+ getContent() {
2683
+ return this.contentArea.innerHTML;
2684
+ }
2685
+
2686
+ setContent(html) {
2687
+ this.contentArea.innerHTML = Utils.sanitizeHTML(html);
2688
+ this._ensureDefaultBlock();
2689
+ this.syncToOriginal();
2690
+ }
2691
+
2692
+ getText() {
2693
+ return this.contentArea.textContent || this.contentArea.innerText;
2694
+ }
2695
+
2696
+ isEmpty() {
2697
+ const text = this.getText().trim();
2698
+ return text === '' || text === '\n';
2699
+ }
2700
+
2701
+ focus() {
2702
+ this.contentArea.focus();
2703
+ }
2704
+
2705
+ blur() {
2706
+ this.contentArea.blur();
2707
+ }
2708
+
2709
+ enable() {
2710
+ this.contentArea.contentEditable = 'true';
2711
+ this.container.classList.remove('neiki-disabled');
2712
+ }
2713
+
2714
+ disable() {
2715
+ this.contentArea.contentEditable = 'false';
2716
+ this.container.classList.add('neiki-disabled');
2717
+ }
2718
+
2719
+ destroy() {
2720
+ this.modal.close();
2721
+ this.dropdown.close();
2722
+ this.colorPicker.close();
2723
+
2724
+ this.container.remove();
2725
+ this.originalElement.style.display = '';
2726
+
2727
+ if (this.modal.overlay) {
2728
+ this.modal.overlay.remove();
2729
+ }
2730
+ }
2731
+
2732
+ setTheme(theme) {
2733
+ this.config.theme = theme;
2734
+ this.container.classList.toggle('neiki-dark', theme === 'dark');
2735
+ StorageManager.setGlobal('theme', theme);
2736
+ }
2737
+
2738
+ createStatusBar() {
2739
+ this.statusBar = Utils.createElement('div', { className: 'neiki-statusbar' });
2740
+ const left = Utils.createElement('div', { className: 'neiki-statusbar-left' });
2741
+ const right = Utils.createElement('div', { className: 'neiki-statusbar-right' });
2742
+
2743
+ this.statusWordCount = Utils.createElement('span', {
2744
+ className: 'neiki-statusbar-item',
2745
+ textContent: '0 ' + t('status.words')
2746
+ });
2747
+ this.statusCharCount = Utils.createElement('span', {
2748
+ className: 'neiki-statusbar-item',
2749
+ textContent: '0 ' + t('status.chars')
2750
+ });
2751
+ this.statusAutosave = Utils.createElement('span', {
2752
+ className: 'neiki-statusbar-item neiki-statusbar-autosave'
2753
+ });
2754
+ this.statusAutosave.style.display = 'none';
2755
+ this.statusBlockType = Utils.createElement('span', {
2756
+ className: 'neiki-statusbar-item neiki-statusbar-block',
2757
+ textContent: 'p'
2758
+ });
2759
+
2760
+ left.appendChild(this.statusWordCount);
2761
+ left.appendChild(this.statusCharCount);
2762
+ right.appendChild(this.statusAutosave);
2763
+ right.appendChild(this.statusBlockType);
2764
+ this.statusBar.appendChild(left);
2765
+ this.statusBar.appendChild(right);
2766
+ this.container.appendChild(this.statusBar);
2767
+ }
2768
+
2769
+ updateStatusBar() {
2770
+ if (!this.statusBar) return;
2771
+ const text = this.getText().trim();
2772
+ const words = text ? text.split(/\s+/).filter(w => w.length > 0).length : 0;
2773
+ const chars = this.getText().length;
2774
+ this.statusWordCount.textContent = words + ' ' + (words === 1 ? t('status.word') : t('status.words'));
2775
+ this.statusCharCount.textContent = chars + ' ' + (chars === 1 ? t('status.char') : t('status.chars'));
2776
+ this.statusBlockType.textContent = this.getCurrentBlockType();
2777
+ }
2778
+
2779
+ getCurrentBlockType() {
2780
+ const sel = window.getSelection();
2781
+ if (!sel || !sel.rangeCount) return 'p';
2782
+ let node = sel.getRangeAt(0).startContainer;
2783
+ if (node.nodeType === Node.TEXT_NODE) node = node.parentNode;
2784
+ const blockTags = ['P', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'BLOCKQUOTE', 'PRE', 'LI', 'DIV'];
2785
+ while (node && node !== this.contentArea) {
2786
+ if (blockTags.includes(node.tagName)) return node.tagName.toLowerCase();
2787
+ node = node.parentNode;
2788
+ }
2789
+ return 'p';
2790
+ }
2791
+
2792
+ toggleCodeView() {
2793
+ if (!this.isCodeViewOpen) {
2794
+ this.codeViewTextarea.value = this.contentArea.innerHTML;
2795
+ this.codeView.classList.add('show');
2796
+ this.isCodeViewOpen = true;
2797
+ this.codeViewTextarea.focus();
2798
+ this._codeViewEsc = (e) => { if (e.key === 'Escape') this.toggleCodeView(); };
2799
+ document.addEventListener('keydown', this._codeViewEsc);
2800
+ } else {
2801
+ this.contentArea.innerHTML = Utils.sanitizeHTML(this.codeViewTextarea.value);
2802
+ this.codeView.classList.remove('show');
2803
+ this.isCodeViewOpen = false;
2804
+ this.history.record();
2805
+ this.syncToOriginal();
2806
+ this.triggerChange();
2807
+ this.updateStatusBar();
2808
+ if (this._codeViewEsc) {
2809
+ document.removeEventListener('keydown', this._codeViewEsc);
2810
+ this._codeViewEsc = null;
2811
+ }
2812
+ }
2813
+ this.updateToolbar();
2814
+ }
2815
+
2816
+ // ============================================
2817
+ // AUTOSAVE METHODS
2818
+ // ============================================
2819
+
2820
+ toggleAutosave() {
2821
+ if (this.isAutosaveEnabled) {
2822
+ this.disableAutosave();
2823
+ } else {
2824
+ this.enableAutosave();
2825
+ }
2826
+ this.updateToolbar();
2827
+ }
2828
+
2829
+ enableAutosave() {
2830
+ this.isAutosaveEnabled = true;
2831
+ this.storage.set('autosave_enabled', true);
2832
+
2833
+ // Show autosave status
2834
+ if (this.statusAutosave) {
2835
+ this.statusAutosave.style.display = '';
2836
+ this.statusAutosave.textContent = t('autosave.savedLocally');
2837
+ }
2838
+
2839
+ // Listen for content changes to trigger autosave
2840
+ if (!this._autosaveContentHandler) {
2841
+ this._autosaveContentHandler = Utils.debounce(() => {
2842
+ if (!this.isAutosaveEnabled) return;
2843
+ // Show "Autosaving..."
2844
+ if (this.statusAutosave) {
2845
+ this.statusAutosave.textContent = t('autosave.autosaving');
2846
+ this.statusAutosave.style.display = '';
2847
+ }
2848
+ this.storage.set('autosave_content', this.getContent());
2849
+ // Show "Saved locally" after brief delay
2850
+ setTimeout(() => {
2851
+ if (this.statusAutosave && this.isAutosaveEnabled) {
2852
+ this.statusAutosave.textContent = t('autosave.savedLocally');
2853
+ }
2854
+ }, 500);
2855
+ }, 1000);
2856
+ this.contentArea.addEventListener('input', this._autosaveContentHandler);
2857
+ }
2858
+
2859
+ // Save immediately
2860
+ this.storage.set('autosave_content', this.getContent());
2861
+ this.updateToolbar();
2862
+ }
2863
+
2864
+ disableAutosave() {
2865
+ this.isAutosaveEnabled = false;
2866
+ this.storage.set('autosave_enabled', false);
2867
+ if (this.autosaveInterval) {
2868
+ clearInterval(this.autosaveInterval);
2869
+ this.autosaveInterval = null;
2870
+ }
2871
+ if (this.statusAutosave) {
2872
+ this.statusAutosave.style.display = 'none';
2873
+ }
2874
+ this.updateToolbar();
2875
+ }
2876
+
2877
+ // ============================================
2878
+ // PRINT METHOD
2879
+ // ============================================
2880
+
2881
+ printContent() {
2882
+ const printWindow = window.open('', '_blank');
2883
+ const content = this.getContent();
2884
+ printWindow.document.write(`
2885
+ <!DOCTYPE html>
2886
+ <html>
2887
+ <head>
2888
+ <title>Print</title>
2889
+ <style>
2890
+ body { font-family: Arial, sans-serif; padding: 20px; line-height: 1.6; }
2891
+ table { border-collapse: collapse; width: 100%; margin: 1em 0; }
2892
+ td, th { border: 1px solid #ccc; padding: 8px; }
2893
+ img { max-width: 100%; }
2894
+ blockquote { border-left: 4px solid #ccc; margin: 1em 0; padding-left: 1em; font-style: italic; }
2895
+ pre { background: #f5f5f5; padding: 1em; overflow-x: auto; }
2896
+ </style>
2897
+ </head>
2898
+ <body>${content}</body>
2899
+ </html>
2900
+ `);
2901
+ printWindow.document.close();
2902
+ printWindow.focus();
2903
+ setTimeout(() => {
2904
+ printWindow.print();
2905
+ printWindow.close();
2906
+ }, 250);
2907
+ }
2908
+
2909
+ // ============================================
2910
+ // JSON API
2911
+ // ============================================
2912
+
2913
+ getJSON() {
2914
+ const parseNode = (node) => {
2915
+ if (node.nodeType === Node.TEXT_NODE) {
2916
+ return { type: 'text', content: node.textContent };
2917
+ }
2918
+ if (node.nodeType !== Node.ELEMENT_NODE) return null;
2919
+
2920
+ const result = {
2921
+ type: node.tagName.toLowerCase(),
2922
+ children: []
2923
+ };
2924
+
2925
+ // Capture attributes
2926
+ if (node.attributes.length > 0) {
2927
+ result.attrs = {};
2928
+ for (let attr of node.attributes) {
2929
+ result.attrs[attr.name] = attr.value;
2930
+ }
2931
+ }
2932
+
2933
+ // Capture inline styles as separate object
2934
+ if (node.style && node.style.cssText) {
2935
+ result.style = node.style.cssText;
2936
+ }
2937
+
2938
+ // Process children
2939
+ for (let child of node.childNodes) {
2940
+ const parsed = parseNode(child);
2941
+ if (parsed) result.children.push(parsed);
2942
+ }
2943
+
2944
+ return result;
2945
+ };
2946
+
2947
+ const children = [];
2948
+ for (let child of this.contentArea.childNodes) {
2949
+ const parsed = parseNode(child);
2950
+ if (parsed) children.push(parsed);
2951
+ }
2952
+
2953
+ return {
2954
+ version: '1.0',
2955
+ content: children
2956
+ };
2957
+ }
2958
+
2959
+ setJSON(json) {
2960
+ if (!json || !json.content) return;
2961
+
2962
+ const buildNode = (data) => {
2963
+ if (data.type === 'text') {
2964
+ return document.createTextNode(data.content || '');
2965
+ }
2966
+
2967
+ const el = document.createElement(data.type);
2968
+
2969
+ // Set attributes
2970
+ if (data.attrs) {
2971
+ for (let [key, value] of Object.entries(data.attrs)) {
2972
+ el.setAttribute(key, value);
2973
+ }
2974
+ }
2975
+
2976
+ // Set inline style
2977
+ if (data.style) {
2978
+ el.style.cssText = data.style;
2979
+ }
2980
+
2981
+ // Build children
2982
+ if (data.children) {
2983
+ for (let child of data.children) {
2984
+ const childNode = buildNode(child);
2985
+ if (childNode) el.appendChild(childNode);
2986
+ }
2987
+ }
2988
+
2989
+ return el;
2990
+ };
2991
+
2992
+ this.contentArea.innerHTML = '';
2993
+ for (let child of json.content) {
2994
+ const node = buildNode(child);
2995
+ if (node) this.contentArea.appendChild(node);
2996
+ }
2997
+
2998
+ this.history.record();
2999
+ this.syncToOriginal();
3000
+ this.triggerChange();
3001
+ this.updateStatusBar();
3002
+ }
3003
+
3004
+ getHTML() {
3005
+ return this.getContent();
3006
+ }
3007
+
3008
+ setHTML(html) {
3009
+ this.setContent(html);
3010
+ }
3011
+
3012
+ // ============================================
3013
+ // PLUGIN SUPPORT
3014
+ // ============================================
3015
+
3016
+ initPlugins() {
3017
+ const plugins = NeikiEditor.getPlugins();
3018
+ plugins.forEach(plugin => {
3019
+ try {
3020
+ // Add plugin button to toolbar if it has icon
3021
+ if (plugin.icon && plugin.action) {
3022
+ const button = Utils.createElement('button', {
3023
+ className: 'neiki-toolbar-btn neiki-plugin-btn',
3024
+ title: plugin.tooltip || plugin.name,
3025
+ type: 'button',
3026
+ innerHTML: plugin.icon,
3027
+ 'data-plugin': plugin.name
3028
+ });
3029
+
3030
+ button.addEventListener('click', (e) => {
3031
+ e.preventDefault();
3032
+ plugin.action(this);
3033
+ });
3034
+
3035
+ this.toolbar.appendChild(button);
3036
+ }
3037
+
3038
+ // Call plugin init if exists
3039
+ if (plugin.init) {
3040
+ plugin.init(this);
3041
+ }
3042
+ } catch (err) {
3043
+ console.error(`NeikiEditor: Plugin "${plugin.name}" failed to initialize`, err);
3044
+ }
3045
+ });
3046
+ }
3047
+
3048
+ // Plugin API methods
3049
+ insertHTML(html) {
3050
+ this.commands.insertHTML(html);
3051
+ }
3052
+
3053
+ getSelection() {
3054
+ return Utils.getSelection();
3055
+ }
3056
+
3057
+ wrapSelection(tagName, attributes = {}) {
3058
+ const sel = window.getSelection();
3059
+ if (!sel.rangeCount || sel.getRangeAt(0).collapsed) return;
3060
+
3061
+ const range = sel.getRangeAt(0);
3062
+ const wrapper = document.createElement(tagName);
3063
+
3064
+ Object.entries(attributes).forEach(([key, value]) => {
3065
+ wrapper.setAttribute(key, value);
3066
+ });
3067
+
3068
+ try {
3069
+ range.surroundContents(wrapper);
3070
+ this.history.record();
3071
+ this.triggerChange();
3072
+ } catch (e) {
3073
+ // surroundContents fails if selection crosses element boundaries
3074
+ const fragment = range.extractContents();
3075
+ wrapper.appendChild(fragment);
3076
+ range.insertNode(wrapper);
3077
+ this.history.record();
3078
+ this.triggerChange();
3079
+ }
3080
+ }
3081
+
3082
+ unwrapSelection(tagName) {
3083
+ const sel = window.getSelection();
3084
+ if (!sel.rangeCount) return;
3085
+
3086
+ const range = sel.getRangeAt(0);
3087
+ let node = range.commonAncestorContainer;
3088
+ if (node.nodeType === Node.TEXT_NODE) node = node.parentNode;
3089
+
3090
+ const wrapper = node.closest(tagName);
3091
+ if (wrapper && this.contentArea.contains(wrapper)) {
3092
+ const parent = wrapper.parentNode;
3093
+ while (wrapper.firstChild) {
3094
+ parent.insertBefore(wrapper.firstChild, wrapper);
3095
+ }
3096
+ parent.removeChild(wrapper);
3097
+ this.history.record();
3098
+ this.triggerChange();
3099
+ }
3100
+ }
3101
+
3102
+ execCommand(command, value = null) {
3103
+ this.commands.exec(command, value);
3104
+ }
3105
+
3106
+ // ============================================
3107
+ // DRAG & DROP
3108
+ // ============================================
3109
+
3110
+ initDragDrop() {
3111
+ let dragCounter = 0;
3112
+
3113
+ this.contentArea.addEventListener('dragenter', (e) => {
3114
+ e.preventDefault();
3115
+ dragCounter++;
3116
+ this.contentArea.classList.add('neiki-drag-over');
3117
+ });
3118
+
3119
+ this.contentArea.addEventListener('dragleave', (e) => {
3120
+ e.preventDefault();
3121
+ dragCounter--;
3122
+ if (dragCounter === 0) {
3123
+ this.contentArea.classList.remove('neiki-drag-over');
3124
+ }
3125
+ });
3126
+
3127
+ this.contentArea.addEventListener('dragover', (e) => {
3128
+ e.preventDefault();
3129
+ });
3130
+
3131
+ this.contentArea.addEventListener('drop', (e) => {
3132
+ e.preventDefault();
3133
+ dragCounter = 0;
3134
+ this.contentArea.classList.remove('neiki-drag-over');
3135
+
3136
+ const files = Array.from(e.dataTransfer.files);
3137
+ const imageFiles = files.filter(file => file.type.startsWith('image/'));
3138
+
3139
+ if (imageFiles.length > 0) {
3140
+ // Get cursor position from drop event
3141
+ const dropX = e.clientX;
3142
+ const dropY = e.clientY;
3143
+
3144
+ imageFiles.forEach(file => {
3145
+ const reader = new FileReader();
3146
+ reader.onload = (readerEvent) => {
3147
+ // Set cursor position at drop location
3148
+ const range = document.caretRangeFromPoint(dropX, dropY);
3149
+ if (range) {
3150
+ const sel = window.getSelection();
3151
+ sel.removeAllRanges();
3152
+ sel.addRange(range);
3153
+ }
3154
+
3155
+ this.commands.insertImage(readerEvent.target.result, file.name, '');
3156
+ };
3157
+ reader.readAsDataURL(file);
3158
+ });
3159
+ }
3160
+ });
3161
+ }
3162
+ }
3163
+
3164
+ // ============================================
3165
+ // SECTION 10: FLOATING SELECTION TOOLBAR
3166
+ // ============================================
3167
+
3168
+ class FloatingToolbar {
3169
+ constructor(editor) {
3170
+ this.editor = editor;
3171
+ this.toolbar = null;
3172
+ this.isVisible = false;
3173
+ this.hideTimeout = null;
3174
+
3175
+ this.createToolbar();
3176
+ this.bindEvents();
3177
+ }
3178
+
3179
+ createToolbar() {
3180
+ this.toolbar = Utils.createElement('div', { className: 'neiki-floating-toolbar' });
3181
+
3182
+ const buttons = [
3183
+ { item: 'bold', icon: Icons.bold, title: 'Bold' },
3184
+ { item: 'italic', icon: Icons.italic, title: 'Italic' },
3185
+ { item: 'underline', icon: Icons.underline, title: 'Underline' },
3186
+ { item: 'strikeThrough', icon: Icons.strikethrough, title: 'Strikethrough' },
3187
+ { item: 'link', icon: Icons.link, title: 'Link' }
3188
+ ];
3189
+
3190
+ buttons.forEach(({ item, icon, title }) => {
3191
+ const button = Utils.createElement('button', {
3192
+ className: 'neiki-toolbar-btn neiki-floating-btn',
3193
+ title: title,
3194
+ type: 'button',
3195
+ innerHTML: icon,
3196
+ 'data-command': item
3197
+ });
3198
+
3199
+ button.addEventListener('click', (e) => {
3200
+ e.preventDefault();
3201
+ e.stopPropagation();
3202
+ this.handleButtonClick(item);
3203
+ });
3204
+
3205
+ this.toolbar.appendChild(button);
3206
+ });
3207
+
3208
+ document.body.appendChild(this.toolbar);
3209
+ }
3210
+
3211
+ bindEvents() {
3212
+ document.addEventListener('selectionchange', () => {
3213
+ clearTimeout(this.hideTimeout);
3214
+ this.hideTimeout = setTimeout(() => this.updatePosition(), 100);
3215
+ });
3216
+
3217
+ document.addEventListener('mouseup', () => {
3218
+ setTimeout(() => this.updatePosition(), 10);
3219
+ });
3220
+
3221
+ document.addEventListener('scroll', () => {
3222
+ if (this.isVisible) this.updatePosition();
3223
+ });
3224
+ }
3225
+
3226
+ updatePosition() {
3227
+ const sel = window.getSelection();
3228
+
3229
+ if (!sel.rangeCount || sel.isCollapsed || !this.editor.contentArea.contains(sel.anchorNode)) {
3230
+ this.hide();
3231
+ return;
3232
+ }
3233
+
3234
+ const range = sel.getRangeAt(0);
3235
+ const rect = range.getBoundingClientRect();
3236
+
3237
+ if (rect.width === 0 && rect.height === 0) {
3238
+ this.hide();
3239
+ return;
3240
+ }
3241
+
3242
+ this.show();
3243
+
3244
+ const toolbarRect = this.toolbar.getBoundingClientRect();
3245
+ const x = rect.left + (rect.width / 2) - (toolbarRect.width / 2);
3246
+ const y = rect.top - toolbarRect.height - 10;
3247
+
3248
+ // Keep toolbar within viewport
3249
+ const finalX = Math.max(10, Math.min(x, window.innerWidth - toolbarRect.width - 10));
3250
+ const finalY = Math.max(10, y);
3251
+
3252
+ this.toolbar.style.left = finalX + 'px';
3253
+ this.toolbar.style.top = finalY + 'px';
3254
+ }
3255
+
3256
+ show() {
3257
+ if (!this.isVisible) {
3258
+ this.toolbar.classList.add('show');
3259
+ this.isVisible = true;
3260
+ }
3261
+ }
3262
+
3263
+ hide() {
3264
+ if (this.isVisible) {
3265
+ this.toolbar.classList.remove('show');
3266
+ this.isVisible = false;
3267
+ }
3268
+ }
3269
+
3270
+ handleButtonClick(item) {
3271
+ if (item === 'link') {
3272
+ const sel = Utils.getSelection();
3273
+ this.editor.modal.open('link', { text: sel.toString() });
3274
+ } else {
3275
+ this.editor.commands[item]();
3276
+ }
3277
+ this.hide();
3278
+ }
3279
+ }
3280
+
3281
+ // ============================================
3282
+ // SECTION 11: PLUGIN SYSTEM
3283
+ // ============================================
3284
+
3285
+ const registeredPlugins = [];
3286
+
3287
+ NeikiEditor.registerPlugin = function (plugin) {
3288
+ if (!plugin.name) {
3289
+ console.error('NeikiEditor: Plugin must have a name');
3290
+ return;
3291
+ }
3292
+ registeredPlugins.push(plugin);
3293
+ };
3294
+
3295
+ NeikiEditor.getPlugins = function () {
3296
+ return [...registeredPlugins];
3297
+ };
3298
+
3299
+ // ============================================
3300
+ // SECTION 11: TABLE CONTEXT MENU
3301
+ // ============================================
3302
+
3303
+ class TableContextMenu {
3304
+ constructor(editor) {
3305
+ this.editor = editor;
3306
+ this.menu = null;
3307
+ this.currentCell = null;
3308
+
3309
+ this.createMenu();
3310
+ this.bindEvents();
3311
+ }
3312
+
3313
+ createMenu() {
3314
+ this.menu = Utils.createElement('div', { className: 'neiki-context-menu' });
3315
+ this.menu.innerHTML = `
3316
+ <div class="neiki-context-item" data-action="insertRowAbove">${Icons.table} Insert Row Above</div>
3317
+ <div class="neiki-context-item" data-action="insertRowBelow">${Icons.table} Insert Row Below</div>
3318
+ <div class="neiki-context-item" data-action="insertColLeft">${Icons.table} Insert Column Left</div>
3319
+ <div class="neiki-context-item" data-action="insertColRight">${Icons.table} Insert Column Right</div>
3320
+ <div class="neiki-context-divider"></div>
3321
+ <div class="neiki-context-item" data-action="deleteRow">${Icons.eraser} Delete Row</div>
3322
+ <div class="neiki-context-item" data-action="deleteCol">${Icons.eraser} Delete Column</div>
3323
+ <div class="neiki-context-item neiki-context-danger" data-action="deleteTable">${Icons.eraser} Delete Table</div>
3324
+ <div class="neiki-context-divider"></div>
3325
+ <div class="neiki-context-item" data-action="mergeCells">${Icons.table} Merge Cells</div>
3326
+ <div class="neiki-context-item" data-action="splitCell">${Icons.table} Split Cell</div>
3327
+ `;
3328
+ document.body.appendChild(this.menu);
3329
+
3330
+ this.menu.querySelectorAll('.neiki-context-item').forEach(item => {
3331
+ item.addEventListener('click', (e) => {
3332
+ const action = item.dataset.action;
3333
+ this.executeAction(action);
3334
+ this.hide();
3335
+ });
3336
+ });
3337
+ }
3338
+
3339
+ bindEvents() {
3340
+ this.editor.contentArea.addEventListener('contextmenu', (e) => {
3341
+ const cell = e.target.closest('td, th');
3342
+ if (cell) {
3343
+ e.preventDefault();
3344
+ this.currentCell = cell;
3345
+ this.show(e.clientX, e.clientY);
3346
+ }
3347
+ });
3348
+
3349
+ document.addEventListener('click', (e) => {
3350
+ if (!this.menu.contains(e.target)) {
3351
+ this.hide();
3352
+ }
3353
+ });
3354
+
3355
+ document.addEventListener('keydown', (e) => {
3356
+ if (e.key === 'Escape') this.hide();
3357
+ });
3358
+ }
3359
+
3360
+ show(x, y) {
3361
+ this.menu.style.display = 'block';
3362
+
3363
+ // Adjust position to stay within viewport
3364
+ const rect = this.menu.getBoundingClientRect();
3365
+ const viewportWidth = window.innerWidth;
3366
+ const viewportHeight = window.innerHeight;
3367
+
3368
+ if (x + rect.width > viewportWidth) {
3369
+ x = viewportWidth - rect.width - 10;
3370
+ }
3371
+ if (y + rect.height > viewportHeight) {
3372
+ y = viewportHeight - rect.height - 10;
3373
+ }
3374
+
3375
+ this.menu.style.left = x + 'px';
3376
+ this.menu.style.top = y + 'px';
3377
+ }
3378
+
3379
+ hide() {
3380
+ this.menu.style.display = 'none';
3381
+ this.currentCell = null;
3382
+ }
3383
+
3384
+ executeAction(action) {
3385
+ if (!this.currentCell) return;
3386
+
3387
+ const table = this.currentCell.closest('table');
3388
+ const row = this.currentCell.closest('tr');
3389
+ if (!table || !row) return;
3390
+
3391
+ const rowIndex = Array.from(table.rows).indexOf(row);
3392
+ const cellIndex = Array.from(row.cells).indexOf(this.currentCell);
3393
+
3394
+ switch (action) {
3395
+ case 'insertRowAbove':
3396
+ this.insertRow(table, rowIndex, 'before');
3397
+ break;
3398
+ case 'insertRowBelow':
3399
+ this.insertRow(table, rowIndex, 'after');
3400
+ break;
3401
+ case 'insertColLeft':
3402
+ this.insertColumn(table, cellIndex, 'before');
3403
+ break;
3404
+ case 'insertColRight':
3405
+ this.insertColumn(table, cellIndex, 'after');
3406
+ break;
3407
+ case 'deleteRow':
3408
+ this.deleteRow(table, rowIndex);
3409
+ break;
3410
+ case 'deleteCol':
3411
+ this.deleteColumn(table, cellIndex);
3412
+ break;
3413
+ case 'deleteTable':
3414
+ table.remove();
3415
+ break;
3416
+ case 'mergeCells':
3417
+ this.mergeCells();
3418
+ break;
3419
+ case 'splitCell':
3420
+ this.splitCell();
3421
+ break;
3422
+ }
3423
+
3424
+ this.editor.history.record();
3425
+ this.editor.triggerChange();
3426
+ }
3427
+
3428
+ insertRow(table, index, position) {
3429
+ const refRow = table.rows[index];
3430
+ const colCount = refRow.cells.length;
3431
+ const newRow = table.insertRow(position === 'before' ? index : index + 1);
3432
+
3433
+ for (let i = 0; i < colCount; i++) {
3434
+ const cell = newRow.insertCell();
3435
+ cell.innerHTML = '&nbsp;';
3436
+ }
3437
+ }
3438
+
3439
+ insertColumn(table, index, position) {
3440
+ const insertIndex = position === 'before' ? index : index + 1;
3441
+
3442
+ for (let row of table.rows) {
3443
+ const cell = row.insertCell(insertIndex);
3444
+ cell.innerHTML = '&nbsp;';
3445
+ // Match header/cell type
3446
+ if (row.cells[0] && row.cells[0].tagName === 'TH') {
3447
+ const th = document.createElement('th');
3448
+ th.innerHTML = '&nbsp;';
3449
+ cell.parentNode.replaceChild(th, cell);
3450
+ }
3451
+ }
3452
+ }
3453
+
3454
+ deleteRow(table, index) {
3455
+ if (table.rows.length > 1) {
3456
+ table.deleteRow(index);
3457
+ }
3458
+ }
3459
+
3460
+ deleteColumn(table, index) {
3461
+ for (let row of table.rows) {
3462
+ if (row.cells.length > 1 && row.cells[index]) {
3463
+ row.deleteCell(index);
3464
+ }
3465
+ }
3466
+ }
3467
+
3468
+ mergeCells() {
3469
+ const sel = window.getSelection();
3470
+ if (!sel.rangeCount) return;
3471
+
3472
+ // Simple merge: just add colspan/rowspan info
3473
+ // Full implementation would need selection tracking
3474
+ const cell = this.currentCell;
3475
+ const colspan = parseInt(cell.getAttribute('colspan') || 1);
3476
+ cell.setAttribute('colspan', colspan + 1);
3477
+
3478
+ // Remove next cell if exists
3479
+ const nextCell = cell.nextElementSibling;
3480
+ if (nextCell && (nextCell.tagName === 'TD' || nextCell.tagName === 'TH')) {
3481
+ cell.innerHTML += ' ' + nextCell.innerHTML;
3482
+ nextCell.remove();
3483
+ }
3484
+ }
3485
+
3486
+ splitCell() {
3487
+ const cell = this.currentCell;
3488
+ const colspan = parseInt(cell.getAttribute('colspan') || 1);
3489
+
3490
+ if (colspan > 1) {
3491
+ cell.setAttribute('colspan', colspan - 1);
3492
+ const newCell = document.createElement(cell.tagName);
3493
+ newCell.innerHTML = '&nbsp;';
3494
+ cell.after(newCell);
3495
+ }
3496
+ }
3497
+ }
3498
+
3499
+ // ============================================
3500
+ // SECTION 12: EXPORT
3501
+ // ============================================
3502
+
3503
+ // Factory function
3504
+ function createEditor(element, options) {
3505
+ return new NeikiEditor(element, options);
3506
+ }
3507
+
3508
+ // jQuery-like initialization
3509
+ if (typeof jQuery !== 'undefined') {
3510
+ jQuery.fn.neikiEditor = function (options) {
3511
+ return this.each(function () {
3512
+ if (!jQuery.data(this, 'neikiEditor')) {
3513
+ jQuery.data(this, 'neikiEditor', new NeikiEditor(this, options));
3514
+ }
3515
+ });
3516
+ };
3517
+ }
3518
+
3519
+ // Export
3520
+ global.NeikiEditor = NeikiEditor;
3521
+ global.createNeikiEditor = createEditor;
3522
+
3523
+ // AMD
3524
+ if (typeof define === 'function' && define.amd) {
3525
+ define('NeikiEditor', [], function () { return NeikiEditor; });
3526
+ }
3527
+
3528
+ // CommonJS
3529
+ if (typeof module === 'object' && module.exports) {
3530
+ module.exports = NeikiEditor;
3531
+ }
3532
+
3533
+ })(typeof window !== 'undefined' ? window : this);