neiki-editor 2.10.1 → 3.0.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.
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * NeikiEditor - A Modern WYSIWYG Editor
3
- * Version: 2.10.1
3
+ * Version: 3.0.1
4
4
  *
5
5
  * A lightweight, feature-rich text editor with support for:
6
6
  * - Rich text formatting (bold, italic, underline, etc.)
@@ -49,6 +49,7 @@
49
49
  'toolbar.outdent': 'Decrease Indent',
50
50
  'toolbar.link': 'Insert Link (Ctrl+K)',
51
51
  'toolbar.image': 'Insert Image',
52
+ 'toolbar.video': 'Insert Video',
52
53
  'toolbar.table': 'Insert Table',
53
54
  'toolbar.blockquote': 'Blockquote',
54
55
  'toolbar.viewCode': 'View Code (Toggle HTML)',
@@ -61,7 +62,7 @@
61
62
  'toolbar.specialChars': 'Special Characters',
62
63
  'toolbar.fullscreen': 'Fullscreen',
63
64
  'toolbar.autosave': 'Toggle Autosave',
64
- 'toolbar.themeToggle': 'Toggle Theme',
65
+ 'toolbar.themeToggle': 'Change theme',
65
66
  'toolbar.print': 'Print',
66
67
  'toolbar.code': 'Code',
67
68
  'toolbar.insert': 'Insert',
@@ -87,6 +88,7 @@
87
88
  // Insert dropdown
88
89
  'insert.link': 'Link',
89
90
  'insert.image': 'Image',
91
+ 'insert.video': 'Video',
90
92
  'insert.table': 'Table',
91
93
  'insert.emoji': 'Emoji',
92
94
  'insert.symbol': 'Symbol',
@@ -98,9 +100,13 @@
98
100
  'menu.print': 'Print',
99
101
  'menu.autosave': 'Autosave',
100
102
  'menu.clearAll': 'Clear all',
101
- 'menu.toggleTheme': 'Toggle Theme',
103
+ 'menu.toggleTheme': 'Change theme',
102
104
  'menu.fullscreen': 'Fullscreen',
103
105
  'menu.help': 'Help',
106
+ 'theme.light': 'Light',
107
+ 'theme.dark': 'Dark',
108
+ 'theme.blue': 'Blue',
109
+ 'theme.darkBlue': 'Dark Blue',
104
110
 
105
111
  // Help modal
106
112
  'help.author': 'Author',
@@ -130,6 +136,15 @@
130
136
  'modal.describeImage': 'Describe the image',
131
137
  'modal.widthOptional': 'Width (optional)',
132
138
  'modal.invalidImageFile': 'Please select a valid image file.',
139
+ 'modal.insertVideo': 'Insert Video',
140
+ 'modal.uploadVideo': 'Upload Video',
141
+ 'modal.convertedVideoToBase64': 'Will be converted to base64',
142
+ 'modal.videoUrl': 'Video URL',
143
+ 'modal.videoTitle': 'Video title',
144
+ 'modal.describeVideo': 'Describe the video',
145
+ 'modal.invalidVideoFile': 'Please select a valid video file.',
146
+ 'modal.uploadingVideo': 'Uploading...',
147
+ 'modal.videoUploadError': 'Video upload failed. Please try again.',
133
148
 
134
149
  // Table modal
135
150
  'modal.insertTable': 'Insert Table',
@@ -179,6 +194,8 @@
179
194
  'imageToolbar.replaceImage': 'Replace Image',
180
195
  'imageToolbar.deleteImage': 'Delete Image',
181
196
  'imageToolbar.dragToMove': 'Drag to move',
197
+ 'videoToolbar.replaceVideo': 'Replace Video',
198
+ 'videoToolbar.deleteVideo': 'Delete Video',
182
199
 
183
200
  // Placeholder
184
201
  'placeholder': 'Start typing...'
@@ -206,6 +223,7 @@
206
223
  'toolbar.outdent': 'Zmenšit odsazení',
207
224
  'toolbar.link': 'Vložit odkaz (Ctrl+K)',
208
225
  'toolbar.image': 'Vložit obrázek',
226
+ 'toolbar.video': 'Vložit video',
209
227
  'toolbar.table': 'Vložit tabulku',
210
228
  'toolbar.blockquote': 'Citace',
211
229
  'toolbar.viewCode': 'Zobrazit kód (HTML)',
@@ -218,7 +236,7 @@
218
236
  'toolbar.specialChars': 'Speciální znaky',
219
237
  'toolbar.fullscreen': 'Celá obrazovka',
220
238
  'toolbar.autosave': 'Auto. ukládání',
221
- 'toolbar.themeToggle': 'Přepnout motiv',
239
+ 'toolbar.themeToggle': 'Změnit motiv',
222
240
  'toolbar.print': 'Tisk',
223
241
  'toolbar.code': 'Kód',
224
242
  'toolbar.insert': 'Vložit',
@@ -244,6 +262,7 @@
244
262
  // Insert dropdown
245
263
  'insert.link': 'Odkaz',
246
264
  'insert.image': 'Obrázek',
265
+ 'insert.video': 'Video',
247
266
  'insert.table': 'Tabulka',
248
267
  'insert.emoji': 'Emoji',
249
268
  'insert.symbol': 'Symbol',
@@ -255,9 +274,13 @@
255
274
  'menu.print': 'Tisk',
256
275
  'menu.autosave': 'Auto. ukládání',
257
276
  'menu.clearAll': 'Vymazat vše',
258
- 'menu.toggleTheme': 'Přepnout motiv',
277
+ 'menu.toggleTheme': 'Změnit motiv',
259
278
  'menu.fullscreen': 'Celá obrazovka',
260
279
  'menu.help': 'Nápověda',
280
+ 'theme.light': 'Světlý',
281
+ 'theme.dark': 'Tmavý',
282
+ 'theme.blue': 'Modrý',
283
+ 'theme.darkBlue': 'Tmavě modrý',
261
284
  'help.author': 'Autor',
262
285
  'help.version': 'Verze',
263
286
  'help.github': 'GitHub',
@@ -285,6 +308,15 @@
285
308
  'modal.describeImage': 'Popis obrázku',
286
309
  'modal.widthOptional': 'Šířka (volitelné)',
287
310
  'modal.invalidImageFile': 'Vyberte prosím platný soubor obrázku.',
311
+ 'modal.insertVideo': 'Vložit video',
312
+ 'modal.uploadVideo': 'Nahrát video',
313
+ 'modal.convertedVideoToBase64': 'Bude převedeno na base64',
314
+ 'modal.videoUrl': 'URL videa',
315
+ 'modal.videoTitle': 'Název videa',
316
+ 'modal.describeVideo': 'Popis videa',
317
+ 'modal.invalidVideoFile': 'Vyberte prosím platný soubor videa.',
318
+ 'modal.uploadingVideo': 'Nahrávání...',
319
+ 'modal.videoUploadError': 'Nahrávání videa selhalo. Zkuste to znovu.',
288
320
 
289
321
  // Table modal
290
322
  'modal.insertTable': 'Vložit tabulku',
@@ -331,6 +363,8 @@
331
363
  'imageToolbar.replaceImage': 'Nahradit obrázek',
332
364
  'imageToolbar.deleteImage': 'Smazat obrázek',
333
365
  'imageToolbar.dragToMove': 'Přetáhněte pro přesun',
366
+ 'videoToolbar.replaceVideo': 'Nahradit video',
367
+ 'videoToolbar.deleteVideo': 'Smazat video',
334
368
 
335
369
  'placeholder': 'Začněte psát...'
336
370
  },
@@ -369,7 +403,7 @@
369
403
  'toolbar.specialChars': '特殊字符',
370
404
  'toolbar.fullscreen': '全屏',
371
405
  'toolbar.autosave': '自动保存',
372
- 'toolbar.themeToggle': '切换主题',
406
+ 'toolbar.themeToggle': '更改主题',
373
407
  'toolbar.print': '打印',
374
408
  'toolbar.code': '代码',
375
409
  'toolbar.insert': '插入',
@@ -398,9 +432,13 @@
398
432
  'menu.print': '打印',
399
433
  'menu.autosave': '自动保存',
400
434
  'menu.clearAll': '清除全部',
401
- 'menu.toggleTheme': '切换主题',
435
+ 'menu.toggleTheme': '更改主题',
402
436
  'menu.fullscreen': '全屏',
403
437
  'menu.help': '帮助',
438
+ 'theme.light': '浅色',
439
+ 'theme.dark': '深色',
440
+ 'theme.blue': '蓝色',
441
+ 'theme.darkBlue': '深蓝色',
404
442
  'help.author': '作者',
405
443
  'help.version': '版本',
406
444
  'help.github': 'GitHub',
@@ -458,6 +496,8 @@
458
496
  'imageToolbar.replaceImage': '替换图片',
459
497
  'imageToolbar.deleteImage': '删除图片',
460
498
  'imageToolbar.dragToMove': '拖动移动',
499
+ 'videoToolbar.replaceVideo': '替换视频',
500
+ 'videoToolbar.deleteVideo': '删除视频',
461
501
 
462
502
  'placeholder': '开始输入...'
463
503
  },
@@ -527,6 +567,10 @@
527
567
  'menu.toggleTheme': 'Cambiar tema',
528
568
  'menu.fullscreen': 'Pantalla completa',
529
569
  'menu.help': 'Ayuda',
570
+ 'theme.light': 'Claro',
571
+ 'theme.dark': 'Oscuro',
572
+ 'theme.blue': 'Azul',
573
+ 'theme.darkBlue': 'Azul oscuro',
530
574
  'help.author': 'Autor',
531
575
  'help.version': 'Versión',
532
576
  'help.github': 'GitHub',
@@ -584,6 +628,8 @@
584
628
  'imageToolbar.replaceImage': 'Reemplazar imagen',
585
629
  'imageToolbar.deleteImage': 'Eliminar imagen',
586
630
  'imageToolbar.dragToMove': 'Arrastrar para mover',
631
+ 'videoToolbar.replaceVideo': 'Reemplazar video',
632
+ 'videoToolbar.deleteVideo': 'Eliminar video',
587
633
 
588
634
  'placeholder': 'Empiece a escribir...'
589
635
  },
@@ -621,7 +667,7 @@
621
667
  'toolbar.specialChars': 'Sonderzeichen',
622
668
  'toolbar.fullscreen': 'Vollbild',
623
669
  'toolbar.autosave': 'Automatisch speichern',
624
- 'toolbar.themeToggle': 'Design wechseln',
670
+ 'toolbar.themeToggle': 'Design ändern',
625
671
  'toolbar.print': 'Drucken',
626
672
  'toolbar.code': 'Code',
627
673
  'toolbar.insert': 'Einfügen',
@@ -650,9 +696,13 @@
650
696
  'menu.print': 'Drucken',
651
697
  'menu.autosave': 'Automatisch speichern',
652
698
  'menu.clearAll': 'Alles löschen',
653
- 'menu.toggleTheme': 'Design wechseln',
699
+ 'menu.toggleTheme': 'Design ändern',
654
700
  'menu.fullscreen': 'Vollbild',
655
701
  'menu.help': 'Hilfe',
702
+ 'theme.light': 'Hell',
703
+ 'theme.dark': 'Dunkel',
704
+ 'theme.blue': 'Blau',
705
+ 'theme.darkBlue': 'Dunkelblau',
656
706
  'help.author': 'Autor',
657
707
  'help.version': 'Version',
658
708
  'help.github': 'GitHub',
@@ -710,6 +760,8 @@
710
760
  'imageToolbar.replaceImage': 'Bild ersetzen',
711
761
  'imageToolbar.deleteImage': 'Bild löschen',
712
762
  'imageToolbar.dragToMove': 'Ziehen zum Verschieben',
763
+ 'videoToolbar.replaceVideo': 'Video ersetzen',
764
+ 'videoToolbar.deleteVideo': 'Video löschen',
713
765
 
714
766
  'placeholder': 'Hier schreiben...'
715
767
  },
@@ -779,6 +831,10 @@
779
831
  'menu.toggleTheme': 'Changer de thème',
780
832
  'menu.fullscreen': 'Plein écran',
781
833
  'menu.help': 'Aide',
834
+ 'theme.light': 'Clair',
835
+ 'theme.dark': 'Sombre',
836
+ 'theme.blue': 'Bleu',
837
+ 'theme.darkBlue': 'Bleu foncé',
782
838
  'help.author': 'Auteur',
783
839
  'help.version': 'Version',
784
840
  'help.github': 'GitHub',
@@ -836,6 +892,8 @@
836
892
  'imageToolbar.replaceImage': 'Remplacer l\'image',
837
893
  'imageToolbar.deleteImage': 'Supprimer l\'image',
838
894
  'imageToolbar.dragToMove': 'Glisser pour déplacer',
895
+ 'videoToolbar.replaceVideo': 'Remplacer la vidéo',
896
+ 'videoToolbar.deleteVideo': 'Supprimer la vidéo',
839
897
 
840
898
  'placeholder': 'Commencez à écrire...'
841
899
  },
@@ -873,7 +931,7 @@
873
931
  'toolbar.specialChars': 'Caracteres especiais',
874
932
  'toolbar.fullscreen': 'Tela cheia',
875
933
  'toolbar.autosave': 'Salvamento automático',
876
- 'toolbar.themeToggle': 'Alternar tema',
934
+ 'toolbar.themeToggle': 'Alterar tema',
877
935
  'toolbar.print': 'Imprimir',
878
936
  'toolbar.code': 'Código',
879
937
  'toolbar.insert': 'Inserir',
@@ -902,9 +960,13 @@
902
960
  'menu.print': 'Imprimir',
903
961
  'menu.autosave': 'Salvamento automático',
904
962
  'menu.clearAll': 'Limpar tudo',
905
- 'menu.toggleTheme': 'Alternar tema',
963
+ 'menu.toggleTheme': 'Alterar tema',
906
964
  'menu.fullscreen': 'Tela cheia',
907
965
  'menu.help': 'Ajuda',
966
+ 'theme.light': 'Claro',
967
+ 'theme.dark': 'Escuro',
968
+ 'theme.blue': 'Azul',
969
+ 'theme.darkBlue': 'Azul escuro',
908
970
  'help.author': 'Autor',
909
971
  'help.version': 'Versão',
910
972
  'help.github': 'GitHub',
@@ -962,6 +1024,8 @@
962
1024
  'imageToolbar.replaceImage': 'Substituir imagem',
963
1025
  'imageToolbar.deleteImage': 'Excluir imagem',
964
1026
  'imageToolbar.dragToMove': 'Arraste para mover',
1027
+ 'videoToolbar.replaceVideo': 'Substituir vídeo',
1028
+ 'videoToolbar.deleteVideo': 'Excluir vídeo',
965
1029
 
966
1030
  'placeholder': 'Comece a digitar...'
967
1031
  },
@@ -999,7 +1063,7 @@
999
1063
  'toolbar.specialChars': '特殊文字',
1000
1064
  'toolbar.fullscreen': '全画面',
1001
1065
  'toolbar.autosave': '自動保存',
1002
- 'toolbar.themeToggle': 'テーマ切替',
1066
+ 'toolbar.themeToggle': 'テーマを変更',
1003
1067
  'toolbar.print': '印刷',
1004
1068
  'toolbar.code': 'コード',
1005
1069
  'toolbar.insert': '挿入',
@@ -1028,9 +1092,13 @@
1028
1092
  'menu.print': '印刷',
1029
1093
  'menu.autosave': '自動保存',
1030
1094
  'menu.clearAll': 'すべて消去',
1031
- 'menu.toggleTheme': 'テーマ切替',
1095
+ 'menu.toggleTheme': 'テーマを変更',
1032
1096
  'menu.fullscreen': '全画面',
1033
1097
  'menu.help': 'ヘルプ',
1098
+ 'theme.light': 'ライト',
1099
+ 'theme.dark': 'ダーク',
1100
+ 'theme.blue': 'ブルー',
1101
+ 'theme.darkBlue': 'ダークブルー',
1034
1102
  'help.author': '作成者',
1035
1103
  'help.version': 'バージョン',
1036
1104
  'help.github': 'GitHub',
@@ -1088,6 +1156,8 @@
1088
1156
  'imageToolbar.replaceImage': '画像を置換',
1089
1157
  'imageToolbar.deleteImage': '画像を削除',
1090
1158
  'imageToolbar.dragToMove': 'ドラッグで移動',
1159
+ 'videoToolbar.replaceVideo': '動画を置換',
1160
+ 'videoToolbar.deleteVideo': '動画を削除',
1091
1161
 
1092
1162
  'placeholder': '入力してください...'
1093
1163
  }
@@ -1117,6 +1187,14 @@
1117
1187
  return text;
1118
1188
  }
1119
1189
 
1190
+ const THEMES = ['light', 'dark', 'blue', 'dark-blue'];
1191
+ const THEME_OPTIONS = [
1192
+ { value: 'light', labelKey: 'theme.light' },
1193
+ { value: 'dark', labelKey: 'theme.dark' },
1194
+ { value: 'blue', labelKey: 'theme.blue' },
1195
+ { value: 'dark-blue', labelKey: 'theme.darkBlue' }
1196
+ ];
1197
+
1120
1198
  const DEFAULT_CONFIG = {
1121
1199
  toolbar: [
1122
1200
  'viewCode', 'undo', 'redo', 'findReplace', '|',
@@ -1147,6 +1225,7 @@
1147
1225
  onReady: null,
1148
1226
  showHelp: true,
1149
1227
  imageUploadHandler: null,
1228
+ videoUploadHandler: null,
1150
1229
  customClass: null
1151
1230
  };
1152
1231
 
@@ -1172,6 +1251,7 @@
1172
1251
  outdent: { icon: 'outdent', titleKey: 'toolbar.outdent', command: 'outdent' },
1173
1252
  link: { icon: 'link', titleKey: 'toolbar.link', command: 'createLink', modal: true },
1174
1253
  image: { icon: 'image', titleKey: 'toolbar.image', command: 'insertImage', modal: true },
1254
+ video: { icon: 'video', titleKey: 'toolbar.video', command: 'insertVideo', modal: true },
1175
1255
  table: { icon: 'table', titleKey: 'toolbar.table', command: 'insertTable', modal: true },
1176
1256
  blockquote: { icon: 'quote', titleKey: 'toolbar.blockquote', command: 'formatBlock', value: 'blockquote' },
1177
1257
  viewCode: { icon: 'code', titleKey: 'toolbar.viewCode', command: 'viewCode' },
@@ -1185,7 +1265,7 @@
1185
1265
  specialChars: { icon: 'specialChars', titleKey: 'toolbar.specialChars', command: 'specialChars', picker: 'specialChars' },
1186
1266
  fullscreen: { icon: 'fullscreen', titleKey: 'toolbar.fullscreen', command: 'fullscreen' },
1187
1267
  autosave: { icon: 'save', titleKey: 'toolbar.autosave', command: 'autosave', toggle: true },
1188
- themeToggle: { icon: 'sun', titleKey: 'toolbar.themeToggle', command: 'themeToggle', toggle: true },
1268
+ themeToggle: { titleKey: 'toolbar.themeToggle', command: 'themeToggle', type: 'themeSelect' },
1189
1269
  print: { icon: 'print', titleKey: 'toolbar.print', command: 'print' },
1190
1270
  insertDropdown: { icon: 'plus', titleKey: 'toolbar.insert', type: 'insertDropdown' },
1191
1271
  moreMenu: { icon: 'more', titleKey: 'toolbar.moreOptions', type: 'moreMenu' }
@@ -1363,9 +1443,9 @@
1363
1443
  'a', 'b', 'blockquote', 'br', 'caption', 'code', 'col', 'colgroup', 'div', 'em',
1364
1444
  'font', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'img', 'li', 'ol',
1365
1445
  'p', 'pre', 's', 'span', 'strike', 'strong', 'sub', 'sup', 'table', 'tbody',
1366
- 'td', 'tfoot', 'th', 'thead', 'tr', 'u', 'ul'
1446
+ 'source', 'td', 'tfoot', 'th', 'thead', 'tr', 'u', 'ul', 'video'
1367
1447
  ]);
1368
- const voidTags = new Set(['br', 'col', 'hr', 'img']);
1448
+ const voidTags = new Set(['br', 'col', 'hr', 'img', 'source']);
1369
1449
  const urlAttrs = new Set(['href', 'src', 'xlink:href', 'poster']);
1370
1450
  let index = 0;
1371
1451
 
@@ -1443,7 +1523,8 @@
1443
1523
  const attrValue = attr.value.trim();
1444
1524
 
1445
1525
  if (!Utils.isSafeHTMLAttribute(attrName)) return;
1446
- if (urlAttrs.has(attrName) && !Utils.isSafeUrl(attrValue, tagName === 'img')) return;
1526
+ const dataMediaType = tagName === 'img' ? 'image' : (tagName === 'video' || tagName === 'source' ? 'video' : null);
1527
+ if (urlAttrs.has(attrName) && !Utils.isSafeUrl(attrValue, dataMediaType)) return;
1447
1528
  if (attrName === 'style' && !Utils.isSafeStyleValue(attrValue)) return;
1448
1529
 
1449
1530
  el.setAttribute(attr.name, attr.value);
@@ -1584,7 +1665,7 @@
1584
1665
  html += ' ' + attr.name + '="' + Utils.escapeHTML(attr.value) + '"';
1585
1666
  });
1586
1667
 
1587
- if (new Set(['br', 'col', 'hr', 'img']).has(tagName)) {
1668
+ if (new Set(['br', 'col', 'hr', 'img', 'source']).has(tagName)) {
1588
1669
  html += '>';
1589
1670
  } else {
1590
1671
  html += '>' + Utils.serializeHTML(child) + '</' + tagName + '>';
@@ -1594,9 +1675,10 @@
1594
1675
  return html;
1595
1676
  },
1596
1677
 
1597
- isSafeUrl(value, allowImageData = false) {
1678
+ isSafeUrl(value, dataMediaType = null) {
1598
1679
  if (!value) return true;
1599
1680
  if (value.startsWith('#') || value.startsWith('/') || value.startsWith('./') || value.startsWith('../')) return true;
1681
+ const mediaType = dataMediaType === true ? 'image' : dataMediaType;
1600
1682
 
1601
1683
  try {
1602
1684
  const parsed = new URL(value, window.location.href);
@@ -1605,7 +1687,8 @@
1605
1687
  protocol === 'https:' ||
1606
1688
  protocol === 'mailto:' ||
1607
1689
  protocol === 'tel:' ||
1608
- (allowImageData && protocol === 'data:' && /^data:image\//i.test(value));
1690
+ (mediaType === 'image' && protocol === 'data:' && /^data:image\//i.test(value)) ||
1691
+ (mediaType === 'video' && protocol === 'data:' && /^data:video\//i.test(value));
1609
1692
  } catch (e) {
1610
1693
  return false;
1611
1694
  }
@@ -1732,6 +1815,7 @@
1732
1815
  outdent: '<svg viewBox="0 0 24 24"><path d="M11 17h10v-2H11v2zm-8-5l4 4V8l-4 4zm0 9h18v-2H3v2zM3 3v2h18V3H3zm8 6h10V7H11v2zm0 4h10v-2H11v2z"/></svg>',
1733
1816
  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>',
1734
1817
  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>',
1818
+ video: '<svg viewBox="0 0 24 24"><path d="M17 10.5V6c0-1.1-.9-2-2-2H4C2.9 4 2 4.9 2 6v12c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2v-4.5l5 5v-13l-5 5zM9 16V8l5 4-5 4z"/></svg>',
1735
1819
  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>',
1736
1820
  quote: '<svg viewBox="0 0 24 24"><path d="M6 17h3l2-4V7H5v6h3zm8 0h3l2-4V7h-6v6h3z"/></svg>',
1737
1821
  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>',
@@ -1757,7 +1841,7 @@
1757
1841
  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>',
1758
1842
  'chevron-down': '<svg viewBox="0 0 24 24"><path d="M7 10l5 5 5-5z"/></svg>',
1759
1843
  help: '<svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 17h-2v-2h2v2zm2.07-7.75l-.9.92C13.45 12.9 13 13.5 13 15h-2v-.5c0-1.1.45-2.1 1.17-2.83l1.24-1.26c.37-.36.59-.86.59-1.41 0-1.1-.9-2-2-2s-2 .9-2 2H8c0-2.21 1.79-4 4-4s4 1.79 4 4c0 .88-.36 1.68-.93 2.25z"/></svg>',
1760
- grip: '<svg viewBox="0 0 24 24"><circle cx="9" cy="5" r="1.5"/><circle cx="15" cy="5" r="1.5"/><circle cx="9" cy="10" r="1.5"/><circle cx="15" cy="10" r="1.5"/><circle cx="9" cy="15" r="1.5"/><circle cx="15" cy="15" r="1.5"/><circle cx="9" cy="20" r="1.5"/><circle cx="15" cy="20" r="1.5"/></svg>',
1844
+ grip: '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path d="M0 0h24v24H0z" fill="none"/><path fill="none" stroke="currentColor" stroke-linecap="square" stroke-width="2" d="m15 5l-3-3l-3 3m0 14l3 3l3-3m4-4l3-3l-3-3M5 9l-3 3l3 3m7-12v9m0 0v9m0-9h9m-9 0H3"/></svg>',
1761
1845
  moveUp: '<svg viewBox="0 0 24 24"><path d="M7.41 15.41L12 10.83l4.59 4.58L18 14l-6-6-6 6z"/></svg>',
1762
1846
  moveDown: '<svg viewBox="0 0 24 24"><path d="M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6z"/></svg>',
1763
1847
  replaceImage: '<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"/><path d="M19.35 10.04A7.49 7.49 0 0012 4C9.11 4 6.6 5.64 5.35 8.04" fill="none" stroke="currentColor" stroke-width="1.5"/></svg>'
@@ -1914,7 +1998,10 @@
1914
1998
  }
1915
1999
 
1916
2000
  createOverlay() {
1917
- if (this.overlay) return this.overlay;
2001
+ if (this.overlay) {
2002
+ this.syncThemeClasses();
2003
+ return this.overlay;
2004
+ }
1918
2005
 
1919
2006
  this.overlay = Utils.createElement('div', {
1920
2007
  className: 'neiki-modal-overlay',
@@ -1926,9 +2013,18 @@
1926
2013
  });
1927
2014
 
1928
2015
  document.body.appendChild(this.overlay);
2016
+ this.syncThemeClasses();
1929
2017
  return this.overlay;
1930
2018
  }
1931
2019
 
2020
+ syncThemeClasses() {
2021
+ if (!this.overlay || !this.editor.getThemeClasses) return;
2022
+ this.overlay.classList.remove('neiki-dark', 'neiki-theme-blue', 'neiki-theme-dark-blue');
2023
+ this.editor.getThemeClasses(this.editor.config.theme).split(' ').filter(Boolean).forEach(className => {
2024
+ this.overlay.classList.add(className);
2025
+ });
2026
+ }
2027
+
1932
2028
  open(type, data = {}) {
1933
2029
  if (this.editor.saveCurrentSelection) {
1934
2030
  this.editor.saveCurrentSelection();
@@ -1945,6 +2041,9 @@
1945
2041
  case 'image':
1946
2042
  modal = this.createImageModal(data);
1947
2043
  break;
2044
+ case 'video':
2045
+ modal = this.createVideoModal(data);
2046
+ break;
1948
2047
  case 'table':
1949
2048
  modal = this.createTableModal(data);
1950
2049
  break;
@@ -2263,6 +2362,179 @@
2263
2362
  return modal;
2264
2363
  }
2265
2364
 
2365
+ createVideoModal(data) {
2366
+ const modal = Utils.createElement('div', { className: 'neiki-modal' });
2367
+ const hasUploadHandler = typeof this.editor.config.videoUploadHandler === 'function';
2368
+ const uploadHint = hasUploadHandler ? t('modal.handledViaUploader') : t('modal.convertedVideoToBase64');
2369
+
2370
+ modal.innerHTML = `
2371
+ <div class="neiki-modal-header">
2372
+ <h3>${Utils.escapeHTML(t('modal.insertVideo'))}</h3>
2373
+ <button class="neiki-modal-close" type="button">${Icons.close}</button>
2374
+ </div>
2375
+ <div class="neiki-modal-body">
2376
+ <div class="neiki-form-group">
2377
+ <label>${Utils.escapeHTML(t('modal.uploadVideo'))}</label>
2378
+ <div class="neiki-image-upload-zone" role="button" tabindex="0">
2379
+ <input type="file" class="neiki-image-upload-input" name="upload" accept="video/*">
2380
+ <div class="neiki-image-upload-icon">${Icons.video}</div>
2381
+ <div class="neiki-image-upload-title">${Utils.escapeHTML(t('modal.uploadVideo'))}</div>
2382
+ <div class="neiki-image-upload-hint">${Utils.escapeHTML(uploadHint)}</div>
2383
+ <div class="neiki-image-upload-files" aria-live="polite"></div>
2384
+ </div>
2385
+ </div>
2386
+ <div class="neiki-form-divider">
2387
+ <span>${Utils.escapeHTML(t('modal.or'))}</span>
2388
+ </div>
2389
+ <div class="neiki-form-group">
2390
+ <label>${Utils.escapeHTML(t('modal.videoUrl'))}</label>
2391
+ <input type="url" class="neiki-input" name="url" placeholder="https://example.com/video.mp4">
2392
+ </div>
2393
+ <div class="neiki-form-group">
2394
+ <label>${Utils.escapeHTML(t('modal.videoTitle'))}</label>
2395
+ <input type="text" class="neiki-input" name="title">
2396
+ </div>
2397
+ <div class="neiki-form-group">
2398
+ <label>${Utils.escapeHTML(t('modal.widthOptional'))}</label>
2399
+ <input type="text" class="neiki-input" name="width" placeholder="e.g. 640px or 100%">
2400
+ </div>
2401
+ </div>
2402
+ <div class="neiki-modal-footer">
2403
+ <button class="neiki-btn neiki-btn-secondary" type="button" data-action="cancel">${Utils.escapeHTML(t('modal.cancel'))}</button>
2404
+ <button class="neiki-btn neiki-btn-primary" type="button" data-action="insert">${Utils.escapeHTML(t('modal.insert'))}</button>
2405
+ </div>
2406
+ `;
2407
+
2408
+ const uploadInput = modal.querySelector('[name="upload"]');
2409
+ const uploadZone = modal.querySelector('.neiki-image-upload-zone');
2410
+ const uploadFiles = modal.querySelector('.neiki-image-upload-files');
2411
+ const urlInput = modal.querySelector('[name="url"]');
2412
+ const insertBtn = modal.querySelector('[data-action="insert"]');
2413
+ let pendingFile = null;
2414
+ let uploadDragCounter = 0;
2415
+
2416
+ urlInput.value = data.url || '';
2417
+ modal.querySelector('[name="title"]').placeholder = t('modal.describeVideo');
2418
+ modal.querySelector('[name="title"]').value = data.title || '';
2419
+ modal.querySelector('[name="width"]').value = data.width || '';
2420
+
2421
+ const updateUploadFeedback = (file) => {
2422
+ uploadZone.classList.toggle('has-files', !!file);
2423
+ uploadFiles.textContent = file ? file.name : '';
2424
+ };
2425
+
2426
+ const handleSelectedFiles = (fileList) => {
2427
+ const file = Array.from(fileList || []).find(f => f.type.startsWith('video/'));
2428
+
2429
+ if (!file) {
2430
+ if (fileList && fileList.length > 0) alert(t('modal.invalidVideoFile'));
2431
+ pendingFile = null;
2432
+ updateUploadFeedback(null);
2433
+ urlInput.disabled = false;
2434
+ return;
2435
+ }
2436
+
2437
+ pendingFile = file;
2438
+ updateUploadFeedback(file);
2439
+
2440
+ if (!hasUploadHandler) {
2441
+ const reader = new FileReader();
2442
+ reader.onload = (ev) => {
2443
+ urlInput.value = ev.target.result;
2444
+ urlInput.disabled = true;
2445
+ };
2446
+ reader.readAsDataURL(file);
2447
+ } else {
2448
+ urlInput.value = '';
2449
+ urlInput.disabled = true;
2450
+ }
2451
+ };
2452
+
2453
+ uploadInput.addEventListener('change', (e) => {
2454
+ handleSelectedFiles(e.target.files);
2455
+ });
2456
+
2457
+ uploadZone.addEventListener('click', (e) => {
2458
+ if (e.target !== uploadInput) uploadInput.click();
2459
+ });
2460
+
2461
+ uploadZone.addEventListener('keydown', (e) => {
2462
+ if (e.key === 'Enter' || e.key === ' ') {
2463
+ e.preventDefault();
2464
+ uploadInput.click();
2465
+ }
2466
+ });
2467
+
2468
+ uploadZone.addEventListener('dragenter', (e) => {
2469
+ e.preventDefault();
2470
+ uploadDragCounter++;
2471
+ uploadZone.classList.add('drag-over');
2472
+ });
2473
+
2474
+ uploadZone.addEventListener('dragover', (e) => {
2475
+ e.preventDefault();
2476
+ });
2477
+
2478
+ uploadZone.addEventListener('dragleave', (e) => {
2479
+ e.preventDefault();
2480
+ uploadDragCounter--;
2481
+ if (uploadDragCounter <= 0) {
2482
+ uploadDragCounter = 0;
2483
+ uploadZone.classList.remove('drag-over');
2484
+ }
2485
+ });
2486
+
2487
+ uploadZone.addEventListener('drop', (e) => {
2488
+ e.preventDefault();
2489
+ uploadDragCounter = 0;
2490
+ uploadZone.classList.remove('drag-over');
2491
+ handleSelectedFiles(e.dataTransfer.files);
2492
+ });
2493
+
2494
+ urlInput.addEventListener('input', () => {
2495
+ if (!urlInput.value) {
2496
+ urlInput.disabled = false;
2497
+ uploadInput.value = '';
2498
+ pendingFile = null;
2499
+ updateUploadFeedback(null);
2500
+ }
2501
+ });
2502
+
2503
+ modal.querySelector('.neiki-modal-close').addEventListener('click', () => this.close());
2504
+ modal.querySelector('[data-action="cancel"]').addEventListener('click', () => this.close());
2505
+ modal.querySelector('[data-action="insert"]').addEventListener('click', async () => {
2506
+ const title = modal.querySelector('[name="title"]').value;
2507
+ const width = modal.querySelector('[name="width"]').value;
2508
+
2509
+ if (pendingFile && hasUploadHandler) {
2510
+ insertBtn.disabled = true;
2511
+ insertBtn.textContent = t('modal.uploadingVideo');
2512
+
2513
+ try {
2514
+ const url = await this.editor.config.videoUploadHandler(pendingFile);
2515
+ if (url) {
2516
+ this.editor.restoreSavedSelection();
2517
+ this.editor.commands.insertVideo(url, title || pendingFile.name, width);
2518
+ }
2519
+ } catch (err) {
2520
+ alert(t('modal.videoUploadError'));
2521
+ }
2522
+
2523
+ this.close();
2524
+ return;
2525
+ }
2526
+
2527
+ const url = modal.querySelector('[name="url"]').value;
2528
+ if (url) {
2529
+ this.editor.restoreSavedSelection();
2530
+ this.editor.commands.insertVideo(url, title, width);
2531
+ }
2532
+ this.close();
2533
+ });
2534
+
2535
+ return modal;
2536
+ }
2537
+
2266
2538
  createTableModal(data) {
2267
2539
  const modal = Utils.createElement('div', { className: 'neiki-modal' });
2268
2540
 
@@ -2529,7 +2801,7 @@
2529
2801
  <img src="https://github.com/neikiri/neiki-editor/raw/main/logo.png" alt="Neiki's Editor" style="width: 120px; height: auto; margin: 0 auto 16px; display: block;">
2530
2802
  <div style="font-size: 14px; line-height: 2; color: var(--neiki-text-primary);">
2531
2803
  <div><strong>${Utils.escapeHTML(t('help.author'))}:</strong> neikiri (Jindřich Stoklasa)</div>
2532
- <div><strong>${Utils.escapeHTML(t('help.version'))}:</strong> 2.10.1</div>
2804
+ <div><strong>${Utils.escapeHTML(t('help.version'))}:</strong> 3.0.1</div>
2533
2805
  <div><strong>${Utils.escapeHTML(t('help.github'))}:</strong> <a href="https://github.com/neikiri/neiki-editor" target="_blank" rel="noopener noreferrer" style="color: var(--neiki-accent);">github.com/neikiri/neiki-editor</a></div>
2534
2806
  <div><strong>${Utils.escapeHTML(t('help.documentation'))}:</strong> <a href="https://github.com/neikiri/neiki-editor/wiki" target="_blank" rel="noopener noreferrer" style="color: var(--neiki-accent);">Wiki</a></div>
2535
2807
  </div>
@@ -3293,6 +3565,19 @@
3293
3565
  this.editor.triggerChange();
3294
3566
  }
3295
3567
 
3568
+ insertVideo(url, title = '', width = '') {
3569
+ if (!Utils.isSafeUrl(url, 'video')) return;
3570
+ let html = `<video controls src="${Utils.escapeHTML(url)}"`;
3571
+ if (title) html += ` title="${Utils.escapeHTML(title)}"`;
3572
+ if (width) html += ` width="${Utils.escapeHTML(width)}"`;
3573
+ html += '></video><p><br></p>';
3574
+
3575
+ this.editor.focus();
3576
+ document.execCommand('insertHTML', false, html);
3577
+ this.editor.history.record();
3578
+ this.editor.triggerChange();
3579
+ }
3580
+
3296
3581
  insertTable(rows, cols, hasHeader = true) {
3297
3582
  let html = '<table class="neiki-table">';
3298
3583
 
@@ -3382,10 +3667,11 @@
3382
3667
 
3383
3668
  // Load theme preference
3384
3669
  const savedTheme = StorageManager.getGlobal('theme', this.config.theme);
3385
- this.config.theme = savedTheme;
3670
+ this.config.theme = THEMES.includes(savedTheme) ? savedTheme : 'light';
3386
3671
 
3387
3672
  this.createStructure();
3388
3673
  this.createToolbar();
3674
+ this.applyTheme(this.config.theme);
3389
3675
  this.createContentArea();
3390
3676
  this.createStatusBar();
3391
3677
 
@@ -3404,6 +3690,7 @@
3404
3690
 
3405
3691
  this.bindEvents();
3406
3692
  this.initDragDrop();
3693
+ this.initSelectionDragDrop();
3407
3694
  this.initPlugins();
3408
3695
 
3409
3696
  // Sync restored content to original element
@@ -3433,7 +3720,7 @@
3433
3720
  createStructure() {
3434
3721
  const langClass = _currentLanguage !== 'en' ? `neiki-lang-${_currentLanguage}` : '';
3435
3722
  this.container = Utils.createElement('div', {
3436
- className: `neiki-editor ${this.config.theme === 'dark' ? 'neiki-dark' : ''} ${langClass}`.trim(),
3723
+ className: `neiki-editor ${this.getThemeClasses(this.config.theme)} ${langClass}`.trim(),
3437
3724
  id: this.id
3438
3725
  });
3439
3726
 
@@ -3441,6 +3728,37 @@
3441
3728
  this.originalElement.parentNode.insertBefore(this.container, this.originalElement);
3442
3729
  }
3443
3730
 
3731
+ getThemeClasses(theme) {
3732
+ const normalizedTheme = THEMES.includes(theme) ? theme : 'light';
3733
+ const classes = [];
3734
+
3735
+ if (normalizedTheme === 'dark' || normalizedTheme === 'dark-blue') {
3736
+ classes.push('neiki-dark');
3737
+ }
3738
+
3739
+ if (normalizedTheme !== 'light' && normalizedTheme !== 'dark') {
3740
+ classes.push('neiki-theme-' + normalizedTheme);
3741
+ }
3742
+
3743
+ return classes.join(' ');
3744
+ }
3745
+
3746
+ applyTheme(theme) {
3747
+ const normalizedTheme = THEMES.includes(theme) ? theme : 'light';
3748
+ this.config.theme = normalizedTheme;
3749
+ this.container.classList.remove('neiki-dark', 'neiki-theme-blue', 'neiki-theme-dark-blue');
3750
+ this.getThemeClasses(normalizedTheme).split(' ').filter(Boolean).forEach(className => {
3751
+ this.container.classList.add(className);
3752
+ });
3753
+ StorageManager.setGlobal('theme', normalizedTheme);
3754
+
3755
+ if (this._themeSelect) {
3756
+ this._themeSelect.value = normalizedTheme;
3757
+ }
3758
+ if (this.modal) this.modal.syncThemeClasses();
3759
+ this._updateThemeMenuItem();
3760
+ }
3761
+
3444
3762
  createAutosaveStorageId(element) {
3445
3763
  const customKey = this.config.autosaveKey ||
3446
3764
  this.originalElement.getAttribute('data-neiki-autosave-key');
@@ -3756,6 +4074,39 @@
3756
4074
  return;
3757
4075
  }
3758
4076
 
4077
+ // Handle theme select
4078
+ if (config.type === 'themeSelect') {
4079
+ const wrapper = Utils.createElement('label', {
4080
+ className: 'neiki-theme-select-wrapper',
4081
+ title: t(config.titleKey)
4082
+ });
4083
+ wrapper.appendChild(Utils.createElement('span', {
4084
+ className: 'neiki-theme-select-icon',
4085
+ innerHTML: Icons.sun
4086
+ }));
4087
+
4088
+ const select = Utils.createElement('select', {
4089
+ className: 'neiki-select neiki-theme-select',
4090
+ 'aria-label': t(config.titleKey)
4091
+ });
4092
+
4093
+ THEME_OPTIONS.forEach(option => {
4094
+ select.appendChild(Utils.createElement('option', {
4095
+ value: option.value,
4096
+ textContent: t(option.labelKey)
4097
+ }));
4098
+ });
4099
+
4100
+ select.value = this.config.theme;
4101
+ select.addEventListener('change', () => this.setTheme(select.value));
4102
+ wrapper.appendChild(select);
4103
+
4104
+ this.toolbarButtons[item] = select;
4105
+ this._themeSelect = select;
4106
+ appendToGroup(wrapper);
4107
+ return;
4108
+ }
4109
+
3759
4110
  // Handle Insert dropdown
3760
4111
  if (config.type === 'insertDropdown') {
3761
4112
  const btn = Utils.createElement('button', {
@@ -3776,6 +4127,10 @@
3776
4127
  key: 'image', icon: Icons.image, labelKey: 'insert.image',
3777
4128
  action: () => { this.saveCurrentSelection(); this.modal.open('image', {}); }
3778
4129
  },
4130
+ {
4131
+ key: 'video', icon: Icons.video, labelKey: 'insert.video',
4132
+ action: () => { this.saveCurrentSelection(); this.modal.open('video', {}); }
4133
+ },
3779
4134
  {
3780
4135
  key: 'table', icon: Icons.table, labelKey: 'insert.table',
3781
4136
  action: () => { this.saveCurrentSelection(); this.modal.open('table', {}); }
@@ -3852,7 +4207,7 @@
3852
4207
  { key: 'autosave', icon: Icons.save, labelKey: 'menu.autosave', action: () => this.toggleAutosave(), toggle: true },
3853
4208
  { key: 'divider' },
3854
4209
  { key: 'clearAll', icon: Icons.trash, labelKey: 'menu.clearAll', action: () => this.clearAll(), danger: true },
3855
- { key: 'themeToggle', icon: Icons.sun, labelKey: 'menu.toggleTheme', action: () => { this.toggleTheme(); this._updateThemeMenuItem(); } },
4210
+ { key: 'themeSelect', icon: Icons.sun, labelKey: 'menu.toggleTheme' },
3856
4211
  { key: 'fullscreen', icon: Icons.fullscreen, labelKey: 'menu.fullscreen', action: () => this.toggleFullscreen() }
3857
4212
  ];
3858
4213
 
@@ -3866,6 +4221,35 @@
3866
4221
  dropdown.appendChild(Utils.createElement('div', { className: 'neiki-dropdown-divider' }));
3867
4222
  return;
3868
4223
  }
4224
+
4225
+ if (key === 'themeSelect') {
4226
+ const menuItem = Utils.createElement('label', {
4227
+ className: 'neiki-dropdown-item neiki-theme-menu-item'
4228
+ });
4229
+ menuItem.innerHTML = '<span class="neiki-dropdown-item-icon">' + icon + '</span>';
4230
+
4231
+ const select = Utils.createElement('select', {
4232
+ className: 'neiki-theme-menu-select',
4233
+ 'aria-label': t(labelKey)
4234
+ });
4235
+ THEME_OPTIONS.forEach(option => {
4236
+ select.appendChild(Utils.createElement('option', {
4237
+ value: option.value,
4238
+ textContent: t(option.labelKey)
4239
+ }));
4240
+ });
4241
+ select.value = this.config.theme;
4242
+ select.addEventListener('click', (e) => e.stopPropagation());
4243
+ select.addEventListener('change', () => {
4244
+ this.setTheme(select.value);
4245
+ });
4246
+ menuItem.appendChild(select);
4247
+ this._themeMenuItem = menuItem;
4248
+ this._themeMenuSelect = select;
4249
+ dropdown.appendChild(menuItem);
4250
+ return;
4251
+ }
4252
+
3869
4253
  const menuItem = Utils.createElement('div', {
3870
4254
  className: 'neiki-dropdown-item' + (danger ? ' neiki-dropdown-item-danger' : '')
3871
4255
  });
@@ -3879,11 +4263,6 @@
3879
4263
  this._autosaveBadge = badge;
3880
4264
  }
3881
4265
 
3882
- if (key === 'themeToggle') {
3883
- this._themeMenuItem = menuItem;
3884
- this._themeMenuIcon = menuItem.querySelector('.neiki-dropdown-item-icon');
3885
- }
3886
-
3887
4266
  menuItem.addEventListener('click', (e) => {
3888
4267
  e.preventDefault();
3889
4268
  e.stopPropagation();
@@ -4007,8 +4386,20 @@
4007
4386
  className: 'neiki-code-view-textarea',
4008
4387
  spellcheck: 'false'
4009
4388
  });
4389
+ this.codeViewHighlight = Utils.createElement('pre', {
4390
+ className: 'neiki-code-view-highlight',
4391
+ 'aria-hidden': 'true'
4392
+ });
4393
+ this.codeViewEditor = Utils.createElement('div', { className: 'neiki-code-view-editor' });
4394
+ this.codeViewEditor.appendChild(this.codeViewHighlight);
4395
+ this.codeViewEditor.appendChild(this.codeViewTextarea);
4396
+ this.codeViewTextarea.addEventListener('input', () => this.renderCodeViewHighlight());
4397
+ this.codeViewTextarea.addEventListener('scroll', () => {
4398
+ this.codeViewHighlight.scrollTop = this.codeViewTextarea.scrollTop;
4399
+ this.codeViewHighlight.scrollLeft = this.codeViewTextarea.scrollLeft;
4400
+ });
4010
4401
  this.codeView.appendChild(codeViewHeader);
4011
- this.codeView.appendChild(this.codeViewTextarea);
4402
+ this.codeView.appendChild(this.codeViewEditor);
4012
4403
  this.contentWrapper.appendChild(this.codeView);
4013
4404
 
4014
4405
  this.container.appendChild(this.contentWrapper);
@@ -4017,6 +4408,7 @@
4017
4408
  bindEvents() {
4018
4409
  // Content changes
4019
4410
  this.contentArea.addEventListener('input', Utils.debounce(() => {
4411
+ this.removeStrayGripSvgs();
4020
4412
  this._ensureDefaultBlock();
4021
4413
  this.history.record();
4022
4414
  this.syncToOriginal();
@@ -4328,6 +4720,9 @@
4328
4720
  }
4329
4721
  }
4330
4722
  break;
4723
+ case 'themeToggle':
4724
+ isActive = this.config.theme === 'dark' || this.config.theme === 'dark-blue';
4725
+ break;
4331
4726
  }
4332
4727
  } catch (e) {
4333
4728
  // queryCommandState can throw in some browsers
@@ -4398,28 +4793,14 @@
4398
4793
  }
4399
4794
 
4400
4795
  toggleTheme() {
4401
- const isDark = this.container.classList.contains('neiki-dark');
4402
- const newTheme = isDark ? 'light' : 'dark';
4403
-
4404
- this.container.classList.toggle('neiki-dark', !isDark);
4405
- this.config.theme = newTheme;
4406
-
4407
- // Persist theme choice
4408
- StorageManager.setGlobal('theme', newTheme);
4409
-
4410
- // Update button icon and active state
4411
- if (this.toolbarButtons.themeToggle) {
4412
- this.toolbarButtons.themeToggle.innerHTML = isDark ? Icons.sun : Icons.moon;
4413
- this.toolbarButtons.themeToggle.classList.toggle('active', !isDark);
4414
- this.toolbarButtons.themeToggle.title = isDark ? 'Switch to Dark Mode' : 'Switch to Light Mode';
4415
- }
4416
- this._updateThemeMenuItem();
4796
+ const currentIndex = THEMES.indexOf(this.config.theme);
4797
+ const nextTheme = THEMES[(currentIndex + 1) % THEMES.length];
4798
+ this.applyTheme(nextTheme);
4417
4799
  }
4418
4800
 
4419
4801
  _updateThemeMenuItem() {
4420
- if (this._themeMenuIcon) {
4421
- const isDark = this.container.classList.contains('neiki-dark');
4422
- this._themeMenuIcon.innerHTML = isDark ? Icons.moon : Icons.sun;
4802
+ if (this._themeMenuSelect) {
4803
+ this._themeMenuSelect.value = this.config.theme;
4423
4804
  }
4424
4805
  }
4425
4806
 
@@ -4584,15 +4965,30 @@
4584
4965
  const clone = this.contentArea.cloneNode(true);
4585
4966
  // Unwrap image resizer wrappers
4586
4967
  clone.querySelectorAll('.neiki-img-resizable').forEach(wrapper => {
4587
- const img = wrapper.querySelector('img');
4588
- if (img) wrapper.parentNode.insertBefore(img, wrapper);
4968
+ const media = wrapper.querySelector('img, video');
4969
+ if (media) wrapper.parentNode.insertBefore(media, wrapper);
4589
4970
  wrapper.remove();
4590
4971
  });
4591
4972
  // Remove grip handles, placeholders, resize handles
4592
4973
  clone.querySelectorAll('.neiki-block-grip, .neiki-block-placeholder, .neiki-table-col-resize-handle, .neiki-img-resize-handle, .neiki-img-size-label, .neiki-img-toolbar').forEach(el => el.remove());
4974
+ this.removeStrayGripSvgs(clone);
4593
4975
  return clone.innerHTML;
4594
4976
  }
4595
4977
 
4978
+ removeStrayGripSvgs(root = this.contentArea) {
4979
+ root.querySelectorAll('svg[viewBox="0 0 24 24"]').forEach(svg => {
4980
+ if (svg.closest('.neiki-toolbar, .neiki-floating-toolbar, .neiki-img-toolbar, .neiki-block-grip, button')) return;
4981
+ const circles = Array.from(svg.children);
4982
+ const isGrip = circles.length === 8 && circles.every(child =>
4983
+ child.tagName && child.tagName.toLowerCase() === 'circle' &&
4984
+ child.getAttribute('r') === '1.5' &&
4985
+ ['9', '15'].includes(child.getAttribute('cx')) &&
4986
+ ['5', '10', '15', '20'].includes(child.getAttribute('cy'))
4987
+ );
4988
+ if (isGrip) svg.remove();
4989
+ });
4990
+ }
4991
+
4596
4992
  setContent(html) {
4597
4993
  if (this.imageResizer) this.imageResizer.deselect();
4598
4994
  this.savedSelectionRange = null;
@@ -4668,9 +5064,7 @@
4668
5064
  }
4669
5065
 
4670
5066
  setTheme(theme) {
4671
- this.config.theme = theme;
4672
- this.container.classList.toggle('neiki-dark', theme === 'dark');
4673
- StorageManager.setGlobal('theme', theme);
5067
+ this.applyTheme(theme);
4674
5068
  }
4675
5069
 
4676
5070
  createStatusBar() {
@@ -4727,9 +5121,117 @@
4727
5121
  return 'p';
4728
5122
  }
4729
5123
 
5124
+ formatHTMLSource(html) {
5125
+ const input = String(html || '').trim();
5126
+ if (!input) return '';
5127
+
5128
+ const voidTags = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr']);
5129
+ const blockTags = new Set(['article', 'aside', 'blockquote', 'caption', 'colgroup', 'div', 'figure', 'figcaption', 'footer', 'form', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'header', 'hr', 'li', 'main', 'ol', 'p', 'pre', 'section', 'table', 'tbody', 'td', 'tfoot', 'th', 'thead', 'tr', 'ul', 'video']);
5130
+ const tokens = [];
5131
+ let index = 0;
5132
+
5133
+ while (index < input.length) {
5134
+ const tagStart = input.indexOf('<', index);
5135
+
5136
+ if (tagStart === -1) {
5137
+ tokens.push(input.slice(index));
5138
+ break;
5139
+ }
5140
+
5141
+ if (tagStart > index) {
5142
+ tokens.push(input.slice(index, tagStart));
5143
+ }
5144
+
5145
+ if (input.slice(tagStart, tagStart + 4) === '<!--') {
5146
+ const commentEnd = input.indexOf('-->', tagStart + 4);
5147
+ const end = commentEnd === -1 ? input.length : commentEnd + 3;
5148
+ tokens.push(input.slice(tagStart, end));
5149
+ index = end;
5150
+ continue;
5151
+ }
5152
+
5153
+ const tagEnd = Utils.findTagEnd(input, tagStart + 1);
5154
+ if (tagEnd === -1) {
5155
+ tokens.push(input.slice(tagStart));
5156
+ break;
5157
+ }
5158
+
5159
+ tokens.push(input.slice(tagStart, tagEnd + 1));
5160
+ index = tagEnd + 1;
5161
+ }
5162
+
5163
+ const lines = [];
5164
+ let indent = 0;
5165
+ let inlineDepth = 0;
5166
+ let hasInlineContent = false;
5167
+
5168
+ tokens.forEach(token => {
5169
+ const trimmed = token.trim();
5170
+ if (!trimmed) return;
5171
+ const textPrefix = !/^</.test(trimmed) && /^\s/.test(token) ? ' ' : '';
5172
+
5173
+ const tagMatch = trimmed.match(/^<\/?\s*([a-zA-Z0-9:-]+)/);
5174
+ const tagName = tagMatch ? tagMatch[1].toLowerCase() : '';
5175
+ const isClosing = /^<\//.test(trimmed);
5176
+ const isTag = /^</.test(trimmed);
5177
+ const isSelfClosing = /\/>$/.test(trimmed) || voidTags.has(tagName);
5178
+ const isBlock = blockTags.has(tagName);
5179
+
5180
+ if (isTag && isClosing) {
5181
+ if (!isBlock && lines.length > 0) {
5182
+ lines[lines.length - 1] += trimmed;
5183
+ inlineDepth = Math.max(inlineDepth - 1, 0);
5184
+ return;
5185
+ }
5186
+ indent = Math.max(indent - 1, 0);
5187
+ }
5188
+
5189
+ const prefix = ' '.repeat(indent);
5190
+ if (!isTag && (inlineDepth > 0 || hasInlineContent) && lines.length > 0) {
5191
+ lines[lines.length - 1] += (textPrefix || (lines[lines.length - 1].endsWith('>') ? '' : ' ')) + trimmed;
5192
+ } else if (!isTag || isBlock || lines.length === 0 || isClosing) {
5193
+ lines.push(prefix + trimmed);
5194
+ } else {
5195
+ lines[lines.length - 1] += trimmed;
5196
+ }
5197
+
5198
+ if (isTag && !isClosing && !isSelfClosing && isBlock) {
5199
+ indent++;
5200
+ hasInlineContent = false;
5201
+ } else if (isTag && !isClosing && !isSelfClosing && !isBlock) {
5202
+ inlineDepth++;
5203
+ hasInlineContent = true;
5204
+ } else if (!isTag) {
5205
+ hasInlineContent = true;
5206
+ }
5207
+ });
5208
+
5209
+ return lines.join('\n');
5210
+ }
5211
+
5212
+ renderCodeViewHighlight() {
5213
+ if (!this.codeViewHighlight || !this.codeViewTextarea) return;
5214
+ const source = this.codeViewTextarea.value;
5215
+ const html = Utils.escapeHTML(source).replace(/(&lt;!--[\s\S]*?--&gt;)|(&lt;\/?[\s\S]*?&gt;)/g, (match, comment) => {
5216
+ if (comment) return `<span class="neiki-html-comment">${match}</span>`;
5217
+
5218
+ const tagParts = match.match(/^(&lt;\/?)([a-zA-Z0-9:-]+)([\s\S]*?)(\/?&gt;)$/);
5219
+ if (!tagParts) return match;
5220
+
5221
+ const attrs = tagParts[3].replace(/(\s+)([a-zA-Z_:][-a-zA-Z0-9_:.]*)(=)(&quot;.*?&quot;|&#039;.*?&#039;|[^\s&]+)?/g, (attrMatch, space, name, eq, value) => {
5222
+ return `${space}<span class="neiki-html-attr">${name}</span>${eq}<span class="neiki-html-string">${value || ''}</span>`;
5223
+ });
5224
+
5225
+ return `<span class="neiki-html-punct">${tagParts[1]}</span><span class="neiki-html-tag">${tagParts[2]}</span>${attrs}<span class="neiki-html-punct">${tagParts[4]}</span>`;
5226
+ });
5227
+
5228
+ this.codeViewHighlight.innerHTML = html + (source.endsWith('\n') ? ' ' : '');
5229
+ }
5230
+
4730
5231
  toggleCodeView() {
4731
5232
  if (!this.isCodeViewOpen) {
4732
- this.codeViewTextarea.value = this.contentArea.innerHTML;
5233
+ this.codeViewTextarea.value = this.formatHTMLSource(this.contentArea.innerHTML);
5234
+ this.renderCodeViewHighlight();
4733
5235
  this.codeView.classList.add('show');
4734
5236
  this.isCodeViewOpen = true;
4735
5237
  this.codeViewTextarea.focus();
@@ -5045,6 +5547,131 @@
5045
5547
  // DRAG & DROP
5046
5548
  // ============================================
5047
5549
 
5550
+ getCaretRangeFromPoint(x, y) {
5551
+ if (document.caretRangeFromPoint) {
5552
+ return document.caretRangeFromPoint(x, y);
5553
+ }
5554
+ if (document.caretPositionFromPoint) {
5555
+ const pos = document.caretPositionFromPoint(x, y);
5556
+ if (pos) {
5557
+ const range = document.createRange();
5558
+ range.setStart(pos.offsetNode, pos.offset);
5559
+ range.collapse(true);
5560
+ return range;
5561
+ }
5562
+ }
5563
+ return null;
5564
+ }
5565
+
5566
+ showDropIndicator(range, x, y) {
5567
+ if (!this.dropIndicator) {
5568
+ this.dropIndicator = document.createElement('div');
5569
+ this.dropIndicator.className = 'neiki-drop-indicator';
5570
+ this.dropIndicator.setAttribute('aria-hidden', 'true');
5571
+ this.contentWrapper.appendChild(this.dropIndicator);
5572
+ }
5573
+
5574
+ const wrapperRect = this.contentWrapper.getBoundingClientRect();
5575
+ let rect = null;
5576
+
5577
+ if (range) {
5578
+ const rects = range.getClientRects();
5579
+ rect = rects.length ? rects[0] : range.getBoundingClientRect();
5580
+ }
5581
+
5582
+ const left = rect && rect.left ? rect.left - wrapperRect.left : x - wrapperRect.left;
5583
+ const top = rect && rect.top ? rect.top - wrapperRect.top : y - wrapperRect.top;
5584
+ const height = rect && rect.height ? Math.max(rect.height, 18) : 22;
5585
+
5586
+ this.dropIndicator.style.left = Math.max(6, Math.min(left, wrapperRect.width - 6)) + 'px';
5587
+ this.dropIndicator.style.top = Math.max(6, Math.min(top, wrapperRect.height - 6)) + 'px';
5588
+ this.dropIndicator.style.height = height + 'px';
5589
+ this.dropIndicator.classList.add('show');
5590
+ }
5591
+
5592
+ hideDropIndicator() {
5593
+ if (this.dropIndicator) {
5594
+ this.dropIndicator.classList.remove('show');
5595
+ }
5596
+ }
5597
+
5598
+ initSelectionDragDrop() {
5599
+ this.draggedSelectionRange = null;
5600
+
5601
+ this.contentArea.addEventListener('dragstart', (e) => {
5602
+ const sel = window.getSelection();
5603
+ if (!sel || !sel.rangeCount || sel.isCollapsed) return;
5604
+ if (!this.contentArea.contains(sel.anchorNode) || !this.contentArea.contains(sel.focusNode)) return;
5605
+
5606
+ const range = sel.getRangeAt(0);
5607
+ const targetNode = e.target.nodeType === Node.TEXT_NODE ? e.target.parentNode : e.target;
5608
+ try {
5609
+ if (targetNode && !range.intersectsNode(targetNode)) return;
5610
+ } catch (err) {
5611
+ return;
5612
+ }
5613
+
5614
+ const container = this.cleanDraggedFragment(range.cloneContents());
5615
+
5616
+ this.draggedSelectionRange = range.cloneRange();
5617
+ e.dataTransfer.effectAllowed = 'move';
5618
+ e.dataTransfer.setData('application/x-neiki-selection', '1');
5619
+ e.dataTransfer.setData('text/html', container.innerHTML);
5620
+ e.dataTransfer.setData('text/plain', sel.toString());
5621
+ });
5622
+
5623
+ this.contentArea.addEventListener('dragend', () => {
5624
+ this.draggedSelectionRange = null;
5625
+ this.hideDropIndicator();
5626
+ });
5627
+ }
5628
+
5629
+ cleanDraggedFragment(fragment) {
5630
+ const container = document.createElement('div');
5631
+ container.appendChild(fragment);
5632
+ container.querySelectorAll('.neiki-block-grip, .neiki-block-placeholder, .neiki-table-col-resize-handle, .neiki-img-resize-handle, .neiki-img-size-label, .neiki-img-toolbar').forEach(el => el.remove());
5633
+ this.removeStrayGripSvgs(container);
5634
+ return container;
5635
+ }
5636
+
5637
+ handleSelectionDrop(e) {
5638
+ if (!this.draggedSelectionRange || !e.dataTransfer.types.includes('application/x-neiki-selection')) {
5639
+ return false;
5640
+ }
5641
+
5642
+ const dropRange = this.getCaretRangeFromPoint(e.clientX, e.clientY);
5643
+ if (!dropRange || !this.contentArea.contains(dropRange.startContainer)) return false;
5644
+ this.hideDropIndicator();
5645
+
5646
+ const sourceRange = this.draggedSelectionRange;
5647
+ const container = this.cleanDraggedFragment(sourceRange.cloneContents());
5648
+ const fragment = document.createDocumentFragment();
5649
+ while (container.firstChild) fragment.appendChild(container.firstChild);
5650
+ const marker = document.createTextNode('');
5651
+ dropRange.insertNode(marker);
5652
+
5653
+ try {
5654
+ if (sourceRange.intersectsNode(marker)) {
5655
+ marker.remove();
5656
+ this.draggedSelectionRange = null;
5657
+ this.hideDropIndicator();
5658
+ return true;
5659
+ }
5660
+ } catch (err) {}
5661
+
5662
+ sourceRange.deleteContents();
5663
+ marker.parentNode.insertBefore(fragment, marker);
5664
+ marker.remove();
5665
+
5666
+ this.draggedSelectionRange = null;
5667
+ this._ensureDefaultBlock();
5668
+ this.history.record();
5669
+ this.syncToOriginal();
5670
+ this.triggerChange();
5671
+ this.updateStatusBar();
5672
+ return true;
5673
+ }
5674
+
5048
5675
  initDragDrop() {
5049
5676
  let dragCounter = 0;
5050
5677
 
@@ -5061,28 +5688,39 @@
5061
5688
  dragCounter--;
5062
5689
  if (dragCounter === 0) {
5063
5690
  this.contentArea.classList.remove('neiki-drag-over');
5691
+ this.hideDropIndicator();
5064
5692
  }
5065
5693
  });
5066
5694
 
5067
5695
  this.contentArea.addEventListener('dragover', (e) => {
5068
5696
  e.preventDefault();
5697
+ if (e.dataTransfer.types.includes('Files') || e.dataTransfer.types.includes('application/x-neiki-selection')) {
5698
+ const range = this.getCaretRangeFromPoint(e.clientX, e.clientY);
5699
+ if (range && this.contentArea.contains(range.startContainer)) {
5700
+ this.showDropIndicator(range, e.clientX, e.clientY);
5701
+ }
5702
+ }
5069
5703
  });
5070
5704
 
5071
5705
  this.contentArea.addEventListener('drop', (e) => {
5072
5706
  e.preventDefault();
5073
5707
  dragCounter = 0;
5074
5708
  this.contentArea.classList.remove('neiki-drag-over');
5709
+ this.hideDropIndicator();
5710
+
5711
+ if (this.handleSelectionDrop(e)) return;
5075
5712
 
5076
5713
  const files = Array.from(e.dataTransfer.files);
5077
5714
  const imageFiles = files.filter(file => file.type.startsWith('image/'));
5715
+ const videoFiles = files.filter(file => file.type.startsWith('video/'));
5078
5716
 
5079
- if (imageFiles.length > 0) {
5717
+ if (imageFiles.length > 0 || videoFiles.length > 0) {
5080
5718
  // Get cursor position from drop event
5081
5719
  const dropX = e.clientX;
5082
5720
  const dropY = e.clientY;
5083
5721
 
5084
5722
  const setCursorAtDrop = () => {
5085
- const range = document.caretRangeFromPoint(dropX, dropY);
5723
+ const range = this.getCaretRangeFromPoint(dropX, dropY);
5086
5724
  if (range) {
5087
5725
  const sel = window.getSelection();
5088
5726
  sel.removeAllRanges();
@@ -5090,30 +5728,53 @@
5090
5728
  }
5091
5729
  };
5092
5730
 
5093
- const hasUploadHandler = typeof this.config.imageUploadHandler === 'function';
5731
+ const insertFile = async (file, type) => {
5732
+ setCursorAtDrop();
5733
+ const isImage = type === 'image';
5734
+ const handler = isImage ? this.config.imageUploadHandler : this.config.videoUploadHandler;
5735
+ const hasUploadHandler = typeof handler === 'function';
5094
5736
 
5095
- if (hasUploadHandler) {
5737
+ if (hasUploadHandler) {
5738
+ const url = await handler(file);
5739
+ if (url) {
5740
+ if (isImage) this.commands.insertImage(url, file.name, '');
5741
+ else this.commands.insertVideo(url, file.name, '');
5742
+ }
5743
+ return;
5744
+ }
5745
+
5746
+ await new Promise((resolve) => {
5747
+ const reader = new FileReader();
5748
+ reader.onload = (readerEvent) => {
5749
+ setCursorAtDrop();
5750
+ if (isImage) this.commands.insertImage(readerEvent.target.result, file.name, '');
5751
+ else this.commands.insertVideo(readerEvent.target.result, file.name, '');
5752
+ resolve();
5753
+ };
5754
+ reader.readAsDataURL(file);
5755
+ });
5756
+ };
5757
+
5758
+ if (typeof this.config.imageUploadHandler === 'function' || typeof this.config.videoUploadHandler === 'function') {
5096
5759
  (async () => {
5097
5760
  for (const file of imageFiles) {
5098
5761
  try {
5099
- setCursorAtDrop();
5100
- const url = await this.config.imageUploadHandler(file);
5101
- if (url) {
5102
- this.commands.insertImage(url, file.name, '');
5103
- }
5762
+ await insertFile(file, 'image');
5104
5763
  } catch (err) {
5105
5764
  console.error('NeikiEditor: Image upload failed', err);
5106
5765
  }
5107
5766
  }
5767
+ for (const file of videoFiles) {
5768
+ try {
5769
+ await insertFile(file, 'video');
5770
+ } catch (err) {
5771
+ console.error('NeikiEditor: Video upload failed', err);
5772
+ }
5773
+ }
5108
5774
  })();
5109
5775
  } else {
5110
- imageFiles.forEach(file => {
5111
- const reader = new FileReader();
5112
- reader.onload = (readerEvent) => {
5113
- setCursorAtDrop();
5114
- this.commands.insertImage(readerEvent.target.result, file.name, '');
5115
- };
5116
- reader.readAsDataURL(file);
5776
+ [...imageFiles.map(file => [file, 'image']), ...videoFiles.map(file => [file, 'video'])].forEach(([file, type]) => {
5777
+ insertFile(file, type);
5117
5778
  });
5118
5779
  }
5119
5780
  }
@@ -5146,8 +5807,8 @@
5146
5807
  this._dragStarted = false;
5147
5808
 
5148
5809
  this.editor.contentArea.addEventListener('mousedown', (e) => {
5149
- const img = e.target.closest('img');
5150
- if (!img || !this.editor.contentArea.contains(img)) return;
5810
+ const img = e.target.closest('img, video');
5811
+ if (!img || !this.editor.contentArea.contains(img) || this.isEditorUiElement(img)) return;
5151
5812
  if (e.target.closest('.neiki-img-resize-handle') || e.target.closest('.neiki-img-toolbar')) return;
5152
5813
 
5153
5814
  e.preventDefault();
@@ -5192,8 +5853,8 @@
5192
5853
 
5193
5854
  this.editor.contentArea.addEventListener('click', (e) => {
5194
5855
  if (this._dragStarted) return;
5195
- const img = e.target.closest('img');
5196
- if (img && this.editor.contentArea.contains(img)) {
5856
+ const img = e.target.closest('img, video');
5857
+ if (img && this.editor.contentArea.contains(img) && !this.isEditorUiElement(img)) {
5197
5858
  e.preventDefault();
5198
5859
  if (!this.wrapper || this.currentImg !== img) {
5199
5860
  this.selectImage(img);
@@ -5205,8 +5866,8 @@
5205
5866
 
5206
5867
  // Touch: tap image to select (drag only via grip handle in img toolbar)
5207
5868
  this.editor.contentArea.addEventListener('touchend', (e) => {
5208
- const img = e.target.closest('img');
5209
- if (!img || !this.editor.contentArea.contains(img)) return;
5869
+ const img = e.target.closest('img, video');
5870
+ if (!img || !this.editor.contentArea.contains(img) || this.isEditorUiElement(img)) return;
5210
5871
  if (e.target.closest('.neiki-img-resize-handle') || e.target.closest('.neiki-img-toolbar')) return;
5211
5872
  e.preventDefault();
5212
5873
  if (!this.wrapper || this.currentImg !== img) {
@@ -5216,7 +5877,7 @@
5216
5877
 
5217
5878
  // Prevent native image drag inside editor (causes duplicate on drop)
5218
5879
  this.editor.contentArea.addEventListener('dragstart', (e) => {
5219
- if (e.target.tagName === 'IMG') {
5880
+ if (e.target.tagName === 'IMG' || e.target.tagName === 'VIDEO') {
5220
5881
  e.preventDefault();
5221
5882
  }
5222
5883
  });
@@ -5234,9 +5895,14 @@
5234
5895
  });
5235
5896
  }
5236
5897
 
5898
+ isEditorUiElement(node) {
5899
+ return !!(node && node.closest && node.closest('.neiki-block-grip, .neiki-img-toolbar, .neiki-img-resize-handle, .neiki-img-size-label, .neiki-table-col-resize-handle'));
5900
+ }
5901
+
5237
5902
  selectImage(img) {
5238
5903
  this.deselect();
5239
5904
  this.currentImg = img;
5905
+ const isVideo = img.tagName === 'VIDEO';
5240
5906
 
5241
5907
  // Create wrapper around image
5242
5908
  this.wrapper = document.createElement('span');
@@ -5323,7 +5989,7 @@
5323
5989
  const replaceBtn = document.createElement('button');
5324
5990
  replaceBtn.className = 'neiki-img-toolbar-btn';
5325
5991
  replaceBtn.type = 'button';
5326
- replaceBtn.title = t('imageToolbar.replaceImage');
5992
+ replaceBtn.title = isVideo ? t('videoToolbar.replaceVideo') : t('imageToolbar.replaceImage');
5327
5993
  replaceBtn.innerHTML = Icons.replaceImage;
5328
5994
  replaceBtn.addEventListener('click', (e) => {
5329
5995
  e.preventDefault();
@@ -5334,7 +6000,7 @@
5334
6000
  const deleteBtn = document.createElement('button');
5335
6001
  deleteBtn.className = 'neiki-img-toolbar-btn neiki-img-toolbar-btn-danger';
5336
6002
  deleteBtn.type = 'button';
5337
- deleteBtn.title = t('imageToolbar.deleteImage');
6003
+ deleteBtn.title = isVideo ? t('videoToolbar.deleteVideo') : t('imageToolbar.deleteImage');
5338
6004
  deleteBtn.innerHTML = Icons.trash;
5339
6005
  deleteBtn.addEventListener('click', (e) => {
5340
6006
  e.preventDefault();
@@ -5351,7 +6017,7 @@
5351
6017
  this.wrapper.appendChild(this.imgToolbar);
5352
6018
 
5353
6019
  this.positionImgToolbar();
5354
- this.selectWrapperRange();
6020
+ this.clearNativeSelection();
5355
6021
  }
5356
6022
 
5357
6023
  getImageBlock() {
@@ -5402,19 +6068,24 @@
5402
6068
 
5403
6069
  replaceImage() {
5404
6070
  if (!this.currentImg) return;
6071
+ const isVideo = this.currentImg.tagName === 'VIDEO';
5405
6072
  const input = document.createElement('input');
5406
6073
  input.type = 'file';
5407
- input.accept = 'image/*';
6074
+ input.accept = isVideo ? 'video/*' : 'image/*';
5408
6075
  input.addEventListener('change', async () => {
5409
6076
  const file = input.files[0];
5410
6077
  if (!file) return;
5411
- const hasUploadHandler = typeof this.editor.config.imageUploadHandler === 'function';
6078
+ const hasUploadHandler = isVideo
6079
+ ? typeof this.editor.config.videoUploadHandler === 'function'
6080
+ : typeof this.editor.config.imageUploadHandler === 'function';
5412
6081
  if (hasUploadHandler) {
5413
6082
  try {
5414
- const url = await this.editor.config.imageUploadHandler(file);
6083
+ const url = isVideo
6084
+ ? await this.editor.config.videoUploadHandler(file)
6085
+ : await this.editor.config.imageUploadHandler(file);
5415
6086
  if (url) this.currentImg.src = url;
5416
6087
  } catch (err) {
5417
- console.error('NeikiEditor: Image upload failed', err);
6088
+ console.error(`NeikiEditor: ${isVideo ? 'Video' : 'Image'} upload failed`, err);
5418
6089
  }
5419
6090
  } else {
5420
6091
  const reader = new FileReader();
@@ -5434,6 +6105,7 @@
5434
6105
  const img = this.currentImg;
5435
6106
  const wrapper = this.wrapper;
5436
6107
  const contentArea = this.editor.contentArea;
6108
+ if (this.editor.blockDragDrop) this.editor.blockDragDrop.hideGrip();
5437
6109
 
5438
6110
  // Save image dimensions
5439
6111
  const imgWidth = img.style.width;
@@ -5448,6 +6120,7 @@
5448
6120
  const ghostImg = img.cloneNode(true);
5449
6121
  ghostImg.style.width = '100%';
5450
6122
  ghostImg.style.height = 'auto';
6123
+ ghostImg.removeAttribute('controls');
5451
6124
  ghost.appendChild(ghostImg);
5452
6125
  document.body.appendChild(ghost);
5453
6126
 
@@ -5462,22 +6135,6 @@
5462
6135
  // Caret marker for drop position
5463
6136
  let dropRange = null;
5464
6137
 
5465
- const getCaretRange = (x, y) => {
5466
- if (document.caretRangeFromPoint) {
5467
- return document.caretRangeFromPoint(x, y);
5468
- }
5469
- if (document.caretPositionFromPoint) {
5470
- const pos = document.caretPositionFromPoint(x, y);
5471
- if (pos) {
5472
- const r = document.createRange();
5473
- r.setStart(pos.offsetNode, pos.offset);
5474
- r.collapse(true);
5475
- return r;
5476
- }
5477
- }
5478
- return null;
5479
- };
5480
-
5481
6138
  const onMove = (ev) => {
5482
6139
  let cx, cy;
5483
6140
  if (isTouch) {
@@ -5492,19 +6149,10 @@
5492
6149
  }
5493
6150
  ghost.style.left = (cx - offsetX) + 'px';
5494
6151
  ghost.style.top = (cy - offsetY) + 'px';
5495
- const range = getCaretRange(cx, cy);
5496
- if (range && contentArea.contains(range.startContainer)) {
5497
- // Avoid dropping inside the wrapper itself
5498
- let node = range.startContainer;
5499
- while (node && node !== contentArea) {
5500
- if (node === wrapper) return;
5501
- node = node.parentNode;
5502
- }
6152
+ const range = this.editor.getCaretRangeFromPoint(cx, cy);
6153
+ if (this.isSafeDropRange(range, wrapper)) {
5503
6154
  dropRange = range;
5504
- // Show caret position
5505
- const sel = window.getSelection();
5506
- sel.removeAllRanges();
5507
- sel.addRange(range);
6155
+ this.editor.showDropIndicator(range, cx, cy);
5508
6156
  }
5509
6157
  };
5510
6158
 
@@ -5534,7 +6182,7 @@
5534
6182
  this.sizeLabel = null;
5535
6183
  this.imgToolbar = null;
5536
6184
 
5537
- if (dropRange && contentArea.contains(dropRange.startContainer)) {
6185
+ if (this.isSafeDropRange(dropRange, wrapper)) {
5538
6186
  // Insert image at the caret drop position
5539
6187
  dropRange.insertNode(img);
5540
6188
  } else {
@@ -5546,6 +6194,7 @@
5546
6194
 
5547
6195
  // Clean up empty parent blocks left behind
5548
6196
  this.editor._ensureDefaultBlock();
6197
+ this.editor.removeStrayGripSvgs();
5549
6198
 
5550
6199
  // Re-select the image at its new position
5551
6200
  this.selectImage(img);
@@ -5564,14 +6213,23 @@
5564
6213
  }
5565
6214
  }
5566
6215
 
5567
- selectWrapperRange() {
5568
- if (!this.wrapper) return;
5569
- this.editor.focus();
6216
+ isSafeDropRange(range, wrapper) {
6217
+ if (!range || !this.editor.contentArea.contains(range.startContainer)) return false;
6218
+ let node = range.startContainer;
6219
+ while (node && node !== this.editor.contentArea) {
6220
+ if (node === wrapper) return false;
6221
+ if (node.nodeType === Node.ELEMENT_NODE && node.closest && node.closest('.neiki-block-grip, .neiki-img-toolbar, .neiki-img-resize-handle, .neiki-img-size-label, .neiki-table-col-resize-handle')) {
6222
+ return false;
6223
+ }
6224
+ this.editor.hideDropIndicator();
6225
+ node = node.parentNode;
6226
+ }
6227
+ return true;
6228
+ }
6229
+
6230
+ clearNativeSelection() {
5570
6231
  const sel = window.getSelection();
5571
- const range = document.createRange();
5572
- range.selectNode(this.wrapper);
5573
- sel.removeAllRanges();
5574
- sel.addRange(range);
6232
+ if (sel) sel.removeAllRanges();
5575
6233
  }
5576
6234
 
5577
6235
  getSelectedImageClipboardData() {
@@ -5906,7 +6564,8 @@
5906
6564
  }
5907
6565
  });
5908
6566
 
5909
- this.editor.contentArea.addEventListener('mouseleave', () => {
6567
+ this.editor.contentArea.addEventListener('mouseleave', (e) => {
6568
+ if (this.gripEl && e.relatedTarget && this.gripEl.contains(e.relatedTarget)) return;
5910
6569
  if (!this.isDragging) this.hideGrip();
5911
6570
  });
5912
6571
  }
@@ -5935,10 +6594,10 @@
5935
6594
  grip._block = block;
5936
6595
 
5937
6596
  // Position grip
5938
- const contentRect = this.editor.contentArea.getBoundingClientRect();
6597
+ const wrapperRect = this.editor.contentWrapper.getBoundingClientRect();
5939
6598
  const blockRect = block.getBoundingClientRect();
5940
- grip.style.top = (blockRect.top - contentRect.top + this.editor.contentArea.scrollTop) + 'px';
5941
- grip.style.left = '-28px';
6599
+ grip.style.top = (blockRect.top - wrapperRect.top) + 'px';
6600
+ grip.style.left = Math.max(2, blockRect.left - wrapperRect.left - 28) + 'px';
5942
6601
 
5943
6602
  grip.addEventListener('mousedown', (e) => {
5944
6603
  e.preventDefault();
@@ -5946,7 +6605,7 @@
5946
6605
  this.startDrag(e, block);
5947
6606
  });
5948
6607
 
5949
- this.editor.contentArea.appendChild(grip);
6608
+ this.editor.contentWrapper.appendChild(grip);
5950
6609
  this.gripEl = grip;
5951
6610
  }
5952
6611
 
@@ -5964,6 +6623,8 @@
5964
6623
 
5965
6624
  // Create ghost
5966
6625
  this.ghostEl = block.cloneNode(true);
6626
+ this.editor.removeStrayGripSvgs(this.ghostEl);
6627
+ this.ghostEl.querySelectorAll('.neiki-block-grip, .neiki-img-toolbar, .neiki-img-resize-handle, .neiki-img-size-label').forEach(el => el.remove());
5967
6628
  this.ghostEl.className = (this.ghostEl.className || '') + ' neiki-block-ghost';
5968
6629
  this.ghostEl.style.width = block.offsetWidth + 'px';
5969
6630
  document.body.appendChild(this.ghostEl);
@@ -6013,7 +6674,9 @@
6013
6674
  this.placeholder = null;
6014
6675
  this.ghostEl = null;
6015
6676
 
6677
+ this.editor.removeStrayGripSvgs();
6016
6678
  this.editor.history.record();
6679
+ this.editor.syncToOriginal();
6017
6680
  this.editor.triggerChange();
6018
6681
  };
6019
6682