neiki-editor 2.10.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * NeikiEditor - A Modern WYSIWYG Editor
3
- * Version: 2.10.0
3
+ * Version: 3.0.0
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,19 +1443,20 @@
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
 
1372
1452
  const currentParent = () => stack[stack.length - 1];
1373
1453
 
1374
- const decodeEntities = (text) => {
1375
- const el = document.createElement('span');
1376
- el.innerHTML = text;
1377
- return el.textContent;
1378
- };
1454
+ const ENTITY_MAP = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: '\u00A0' };
1455
+ const decodeEntities = (text) => text.replace(/&(?:#x([0-9a-fA-F]+)|#([0-9]+)|([a-zA-Z]+));/g, (m, hex, dec, named) => {
1456
+ if (hex) return String.fromCodePoint(parseInt(hex, 16));
1457
+ if (dec) return String.fromCodePoint(parseInt(dec, 10));
1458
+ return ENTITY_MAP[named] || m;
1459
+ });
1379
1460
 
1380
1461
  while (index < input.length) {
1381
1462
  const tagStart = input.indexOf('<', index);
@@ -1442,7 +1523,8 @@
1442
1523
  const attrValue = attr.value.trim();
1443
1524
 
1444
1525
  if (!Utils.isSafeHTMLAttribute(attrName)) return;
1445
- 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;
1446
1528
  if (attrName === 'style' && !Utils.isSafeStyleValue(attrValue)) return;
1447
1529
 
1448
1530
  el.setAttribute(attr.name, attr.value);
@@ -1583,7 +1665,7 @@
1583
1665
  html += ' ' + attr.name + '="' + Utils.escapeHTML(attr.value) + '"';
1584
1666
  });
1585
1667
 
1586
- if (new Set(['br', 'col', 'hr', 'img']).has(tagName)) {
1668
+ if (new Set(['br', 'col', 'hr', 'img', 'source']).has(tagName)) {
1587
1669
  html += '>';
1588
1670
  } else {
1589
1671
  html += '>' + Utils.serializeHTML(child) + '</' + tagName + '>';
@@ -1593,9 +1675,10 @@
1593
1675
  return html;
1594
1676
  },
1595
1677
 
1596
- isSafeUrl(value, allowImageData = false) {
1678
+ isSafeUrl(value, dataMediaType = null) {
1597
1679
  if (!value) return true;
1598
1680
  if (value.startsWith('#') || value.startsWith('/') || value.startsWith('./') || value.startsWith('../')) return true;
1681
+ const mediaType = dataMediaType === true ? 'image' : dataMediaType;
1599
1682
 
1600
1683
  try {
1601
1684
  const parsed = new URL(value, window.location.href);
@@ -1604,7 +1687,8 @@
1604
1687
  protocol === 'https:' ||
1605
1688
  protocol === 'mailto:' ||
1606
1689
  protocol === 'tel:' ||
1607
- (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));
1608
1692
  } catch (e) {
1609
1693
  return false;
1610
1694
  }
@@ -1731,6 +1815,7 @@
1731
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>',
1732
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>',
1733
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>',
1734
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>',
1735
1820
  quote: '<svg viewBox="0 0 24 24"><path d="M6 17h3l2-4V7H5v6h3zm8 0h3l2-4V7h-6v6h3z"/></svg>',
1736
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>',
@@ -1756,7 +1841,7 @@
1756
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>',
1757
1842
  'chevron-down': '<svg viewBox="0 0 24 24"><path d="M7 10l5 5 5-5z"/></svg>',
1758
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>',
1759
- 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>',
1760
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>',
1761
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>',
1762
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>'
@@ -1913,7 +1998,10 @@
1913
1998
  }
1914
1999
 
1915
2000
  createOverlay() {
1916
- if (this.overlay) return this.overlay;
2001
+ if (this.overlay) {
2002
+ this.syncThemeClasses();
2003
+ return this.overlay;
2004
+ }
1917
2005
 
1918
2006
  this.overlay = Utils.createElement('div', {
1919
2007
  className: 'neiki-modal-overlay',
@@ -1925,9 +2013,18 @@
1925
2013
  });
1926
2014
 
1927
2015
  document.body.appendChild(this.overlay);
2016
+ this.syncThemeClasses();
1928
2017
  return this.overlay;
1929
2018
  }
1930
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
+
1931
2028
  open(type, data = {}) {
1932
2029
  if (this.editor.saveCurrentSelection) {
1933
2030
  this.editor.saveCurrentSelection();
@@ -1944,6 +2041,9 @@
1944
2041
  case 'image':
1945
2042
  modal = this.createImageModal(data);
1946
2043
  break;
2044
+ case 'video':
2045
+ modal = this.createVideoModal(data);
2046
+ break;
1947
2047
  case 'table':
1948
2048
  modal = this.createTableModal(data);
1949
2049
  break;
@@ -2262,6 +2362,179 @@
2262
2362
  return modal;
2263
2363
  }
2264
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
+
2265
2538
  createTableModal(data) {
2266
2539
  const modal = Utils.createElement('div', { className: 'neiki-modal' });
2267
2540
 
@@ -2528,7 +2801,7 @@
2528
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;">
2529
2802
  <div style="font-size: 14px; line-height: 2; color: var(--neiki-text-primary);">
2530
2803
  <div><strong>${Utils.escapeHTML(t('help.author'))}:</strong> neikiri (Jindřich Stoklasa)</div>
2531
- <div><strong>${Utils.escapeHTML(t('help.version'))}:</strong> 2.10.0</div>
2804
+ <div><strong>${Utils.escapeHTML(t('help.version'))}:</strong> 3.0.0</div>
2532
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>
2533
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>
2534
2807
  </div>
@@ -3292,6 +3565,19 @@
3292
3565
  this.editor.triggerChange();
3293
3566
  }
3294
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
+
3295
3581
  insertTable(rows, cols, hasHeader = true) {
3296
3582
  let html = '<table class="neiki-table">';
3297
3583
 
@@ -3381,10 +3667,11 @@
3381
3667
 
3382
3668
  // Load theme preference
3383
3669
  const savedTheme = StorageManager.getGlobal('theme', this.config.theme);
3384
- this.config.theme = savedTheme;
3670
+ this.config.theme = THEMES.includes(savedTheme) ? savedTheme : 'light';
3385
3671
 
3386
3672
  this.createStructure();
3387
3673
  this.createToolbar();
3674
+ this.applyTheme(this.config.theme);
3388
3675
  this.createContentArea();
3389
3676
  this.createStatusBar();
3390
3677
 
@@ -3403,6 +3690,7 @@
3403
3690
 
3404
3691
  this.bindEvents();
3405
3692
  this.initDragDrop();
3693
+ this.initSelectionDragDrop();
3406
3694
  this.initPlugins();
3407
3695
 
3408
3696
  // Sync restored content to original element
@@ -3432,7 +3720,7 @@
3432
3720
  createStructure() {
3433
3721
  const langClass = _currentLanguage !== 'en' ? `neiki-lang-${_currentLanguage}` : '';
3434
3722
  this.container = Utils.createElement('div', {
3435
- className: `neiki-editor ${this.config.theme === 'dark' ? 'neiki-dark' : ''} ${langClass}`.trim(),
3723
+ className: `neiki-editor ${this.getThemeClasses(this.config.theme)} ${langClass}`.trim(),
3436
3724
  id: this.id
3437
3725
  });
3438
3726
 
@@ -3440,6 +3728,37 @@
3440
3728
  this.originalElement.parentNode.insertBefore(this.container, this.originalElement);
3441
3729
  }
3442
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
+
3443
3762
  createAutosaveStorageId(element) {
3444
3763
  const customKey = this.config.autosaveKey ||
3445
3764
  this.originalElement.getAttribute('data-neiki-autosave-key');
@@ -3755,6 +4074,39 @@
3755
4074
  return;
3756
4075
  }
3757
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
+
3758
4110
  // Handle Insert dropdown
3759
4111
  if (config.type === 'insertDropdown') {
3760
4112
  const btn = Utils.createElement('button', {
@@ -3775,6 +4127,10 @@
3775
4127
  key: 'image', icon: Icons.image, labelKey: 'insert.image',
3776
4128
  action: () => { this.saveCurrentSelection(); this.modal.open('image', {}); }
3777
4129
  },
4130
+ {
4131
+ key: 'video', icon: Icons.video, labelKey: 'insert.video',
4132
+ action: () => { this.saveCurrentSelection(); this.modal.open('video', {}); }
4133
+ },
3778
4134
  {
3779
4135
  key: 'table', icon: Icons.table, labelKey: 'insert.table',
3780
4136
  action: () => { this.saveCurrentSelection(); this.modal.open('table', {}); }
@@ -3851,7 +4207,7 @@
3851
4207
  { key: 'autosave', icon: Icons.save, labelKey: 'menu.autosave', action: () => this.toggleAutosave(), toggle: true },
3852
4208
  { key: 'divider' },
3853
4209
  { key: 'clearAll', icon: Icons.trash, labelKey: 'menu.clearAll', action: () => this.clearAll(), danger: true },
3854
- { key: 'themeToggle', icon: Icons.sun, labelKey: 'menu.toggleTheme', action: () => { this.toggleTheme(); this._updateThemeMenuItem(); } },
4210
+ { key: 'themeSelect', icon: Icons.sun, labelKey: 'menu.toggleTheme' },
3855
4211
  { key: 'fullscreen', icon: Icons.fullscreen, labelKey: 'menu.fullscreen', action: () => this.toggleFullscreen() }
3856
4212
  ];
3857
4213
 
@@ -3865,6 +4221,35 @@
3865
4221
  dropdown.appendChild(Utils.createElement('div', { className: 'neiki-dropdown-divider' }));
3866
4222
  return;
3867
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
+
3868
4253
  const menuItem = Utils.createElement('div', {
3869
4254
  className: 'neiki-dropdown-item' + (danger ? ' neiki-dropdown-item-danger' : '')
3870
4255
  });
@@ -3878,11 +4263,6 @@
3878
4263
  this._autosaveBadge = badge;
3879
4264
  }
3880
4265
 
3881
- if (key === 'themeToggle') {
3882
- this._themeMenuItem = menuItem;
3883
- this._themeMenuIcon = menuItem.querySelector('.neiki-dropdown-item-icon');
3884
- }
3885
-
3886
4266
  menuItem.addEventListener('click', (e) => {
3887
4267
  e.preventDefault();
3888
4268
  e.stopPropagation();
@@ -4006,8 +4386,20 @@
4006
4386
  className: 'neiki-code-view-textarea',
4007
4387
  spellcheck: 'false'
4008
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
+ });
4009
4401
  this.codeView.appendChild(codeViewHeader);
4010
- this.codeView.appendChild(this.codeViewTextarea);
4402
+ this.codeView.appendChild(this.codeViewEditor);
4011
4403
  this.contentWrapper.appendChild(this.codeView);
4012
4404
 
4013
4405
  this.container.appendChild(this.contentWrapper);
@@ -4016,6 +4408,7 @@
4016
4408
  bindEvents() {
4017
4409
  // Content changes
4018
4410
  this.contentArea.addEventListener('input', Utils.debounce(() => {
4411
+ this.removeStrayGripSvgs();
4019
4412
  this._ensureDefaultBlock();
4020
4413
  this.history.record();
4021
4414
  this.syncToOriginal();
@@ -4327,6 +4720,9 @@
4327
4720
  }
4328
4721
  }
4329
4722
  break;
4723
+ case 'themeToggle':
4724
+ isActive = this.config.theme === 'dark' || this.config.theme === 'dark-blue';
4725
+ break;
4330
4726
  }
4331
4727
  } catch (e) {
4332
4728
  // queryCommandState can throw in some browsers
@@ -4397,28 +4793,14 @@
4397
4793
  }
4398
4794
 
4399
4795
  toggleTheme() {
4400
- const isDark = this.container.classList.contains('neiki-dark');
4401
- const newTheme = isDark ? 'light' : 'dark';
4402
-
4403
- this.container.classList.toggle('neiki-dark', !isDark);
4404
- this.config.theme = newTheme;
4405
-
4406
- // Persist theme choice
4407
- StorageManager.setGlobal('theme', newTheme);
4408
-
4409
- // Update button icon and active state
4410
- if (this.toolbarButtons.themeToggle) {
4411
- this.toolbarButtons.themeToggle.innerHTML = isDark ? Icons.sun : Icons.moon;
4412
- this.toolbarButtons.themeToggle.classList.toggle('active', !isDark);
4413
- this.toolbarButtons.themeToggle.title = isDark ? 'Switch to Dark Mode' : 'Switch to Light Mode';
4414
- }
4415
- this._updateThemeMenuItem();
4796
+ const currentIndex = THEMES.indexOf(this.config.theme);
4797
+ const nextTheme = THEMES[(currentIndex + 1) % THEMES.length];
4798
+ this.applyTheme(nextTheme);
4416
4799
  }
4417
4800
 
4418
4801
  _updateThemeMenuItem() {
4419
- if (this._themeMenuIcon) {
4420
- const isDark = this.container.classList.contains('neiki-dark');
4421
- this._themeMenuIcon.innerHTML = isDark ? Icons.moon : Icons.sun;
4802
+ if (this._themeMenuSelect) {
4803
+ this._themeMenuSelect.value = this.config.theme;
4422
4804
  }
4423
4805
  }
4424
4806
 
@@ -4583,15 +4965,30 @@
4583
4965
  const clone = this.contentArea.cloneNode(true);
4584
4966
  // Unwrap image resizer wrappers
4585
4967
  clone.querySelectorAll('.neiki-img-resizable').forEach(wrapper => {
4586
- const img = wrapper.querySelector('img');
4587
- if (img) wrapper.parentNode.insertBefore(img, wrapper);
4968
+ const media = wrapper.querySelector('img, video');
4969
+ if (media) wrapper.parentNode.insertBefore(media, wrapper);
4588
4970
  wrapper.remove();
4589
4971
  });
4590
4972
  // Remove grip handles, placeholders, resize handles
4591
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);
4592
4975
  return clone.innerHTML;
4593
4976
  }
4594
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
+
4595
4992
  setContent(html) {
4596
4993
  if (this.imageResizer) this.imageResizer.deselect();
4597
4994
  this.savedSelectionRange = null;
@@ -4667,9 +5064,7 @@
4667
5064
  }
4668
5065
 
4669
5066
  setTheme(theme) {
4670
- this.config.theme = theme;
4671
- this.container.classList.toggle('neiki-dark', theme === 'dark');
4672
- StorageManager.setGlobal('theme', theme);
5067
+ this.applyTheme(theme);
4673
5068
  }
4674
5069
 
4675
5070
  createStatusBar() {
@@ -4726,9 +5121,85 @@
4726
5121
  return 'p';
4727
5122
  }
4728
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 = input.match(/<!--[\s\S]*?-->|<\/?[^>]+>|[^<]+/g) || [];
5131
+ const lines = [];
5132
+ let indent = 0;
5133
+ let inlineDepth = 0;
5134
+ let hasInlineContent = false;
5135
+
5136
+ tokens.forEach(token => {
5137
+ const trimmed = token.trim();
5138
+ if (!trimmed) return;
5139
+ const textPrefix = !/^</.test(trimmed) && /^\s/.test(token) ? ' ' : '';
5140
+
5141
+ const tagMatch = trimmed.match(/^<\/?\s*([a-zA-Z0-9:-]+)/);
5142
+ const tagName = tagMatch ? tagMatch[1].toLowerCase() : '';
5143
+ const isClosing = /^<\//.test(trimmed);
5144
+ const isTag = /^</.test(trimmed);
5145
+ const isSelfClosing = /\/>$/.test(trimmed) || voidTags.has(tagName);
5146
+ const isBlock = blockTags.has(tagName);
5147
+
5148
+ if (isTag && isClosing) {
5149
+ if (!isBlock && lines.length > 0) {
5150
+ lines[lines.length - 1] += trimmed;
5151
+ inlineDepth = Math.max(inlineDepth - 1, 0);
5152
+ return;
5153
+ }
5154
+ indent = Math.max(indent - 1, 0);
5155
+ }
5156
+
5157
+ const prefix = ' '.repeat(indent);
5158
+ if (!isTag && (inlineDepth > 0 || hasInlineContent) && lines.length > 0) {
5159
+ lines[lines.length - 1] += (textPrefix || (lines[lines.length - 1].endsWith('>') ? '' : ' ')) + trimmed;
5160
+ } else if (!isTag || isBlock || lines.length === 0 || isClosing) {
5161
+ lines.push(prefix + trimmed);
5162
+ } else {
5163
+ lines[lines.length - 1] += trimmed;
5164
+ }
5165
+
5166
+ if (isTag && !isClosing && !isSelfClosing && isBlock) {
5167
+ indent++;
5168
+ hasInlineContent = false;
5169
+ } else if (isTag && !isClosing && !isSelfClosing && !isBlock) {
5170
+ inlineDepth++;
5171
+ hasInlineContent = true;
5172
+ } else if (!isTag) {
5173
+ hasInlineContent = true;
5174
+ }
5175
+ });
5176
+
5177
+ return lines.join('\n');
5178
+ }
5179
+
5180
+ renderCodeViewHighlight() {
5181
+ if (!this.codeViewHighlight || !this.codeViewTextarea) return;
5182
+ const source = this.codeViewTextarea.value;
5183
+ const html = Utils.escapeHTML(source).replace(/(&lt;!--[\s\S]*?--&gt;)|(&lt;\/?[\s\S]*?&gt;)/g, (match, comment) => {
5184
+ if (comment) return `<span class="neiki-html-comment">${match}</span>`;
5185
+
5186
+ const tagParts = match.match(/^(&lt;\/?)([a-zA-Z0-9:-]+)([\s\S]*?)(\/?&gt;)$/);
5187
+ if (!tagParts) return match;
5188
+
5189
+ const attrs = tagParts[3].replace(/(\s+)([a-zA-Z_:][-a-zA-Z0-9_:.]*)(=)(&quot;.*?&quot;|&#039;.*?&#039;|[^\s&]+)?/g, (attrMatch, space, name, eq, value) => {
5190
+ return `${space}<span class="neiki-html-attr">${name}</span>${eq}<span class="neiki-html-string">${value || ''}</span>`;
5191
+ });
5192
+
5193
+ 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>`;
5194
+ });
5195
+
5196
+ this.codeViewHighlight.innerHTML = html + (source.endsWith('\n') ? ' ' : '');
5197
+ }
5198
+
4729
5199
  toggleCodeView() {
4730
5200
  if (!this.isCodeViewOpen) {
4731
- this.codeViewTextarea.value = this.contentArea.innerHTML;
5201
+ this.codeViewTextarea.value = this.formatHTMLSource(this.contentArea.innerHTML);
5202
+ this.renderCodeViewHighlight();
4732
5203
  this.codeView.classList.add('show');
4733
5204
  this.isCodeViewOpen = true;
4734
5205
  this.codeViewTextarea.focus();
@@ -5044,6 +5515,131 @@
5044
5515
  // DRAG & DROP
5045
5516
  // ============================================
5046
5517
 
5518
+ getCaretRangeFromPoint(x, y) {
5519
+ if (document.caretRangeFromPoint) {
5520
+ return document.caretRangeFromPoint(x, y);
5521
+ }
5522
+ if (document.caretPositionFromPoint) {
5523
+ const pos = document.caretPositionFromPoint(x, y);
5524
+ if (pos) {
5525
+ const range = document.createRange();
5526
+ range.setStart(pos.offsetNode, pos.offset);
5527
+ range.collapse(true);
5528
+ return range;
5529
+ }
5530
+ }
5531
+ return null;
5532
+ }
5533
+
5534
+ showDropIndicator(range, x, y) {
5535
+ if (!this.dropIndicator) {
5536
+ this.dropIndicator = document.createElement('div');
5537
+ this.dropIndicator.className = 'neiki-drop-indicator';
5538
+ this.dropIndicator.setAttribute('aria-hidden', 'true');
5539
+ this.contentWrapper.appendChild(this.dropIndicator);
5540
+ }
5541
+
5542
+ const wrapperRect = this.contentWrapper.getBoundingClientRect();
5543
+ let rect = null;
5544
+
5545
+ if (range) {
5546
+ const rects = range.getClientRects();
5547
+ rect = rects.length ? rects[0] : range.getBoundingClientRect();
5548
+ }
5549
+
5550
+ const left = rect && rect.left ? rect.left - wrapperRect.left : x - wrapperRect.left;
5551
+ const top = rect && rect.top ? rect.top - wrapperRect.top : y - wrapperRect.top;
5552
+ const height = rect && rect.height ? Math.max(rect.height, 18) : 22;
5553
+
5554
+ this.dropIndicator.style.left = Math.max(6, Math.min(left, wrapperRect.width - 6)) + 'px';
5555
+ this.dropIndicator.style.top = Math.max(6, Math.min(top, wrapperRect.height - 6)) + 'px';
5556
+ this.dropIndicator.style.height = height + 'px';
5557
+ this.dropIndicator.classList.add('show');
5558
+ }
5559
+
5560
+ hideDropIndicator() {
5561
+ if (this.dropIndicator) {
5562
+ this.dropIndicator.classList.remove('show');
5563
+ }
5564
+ }
5565
+
5566
+ initSelectionDragDrop() {
5567
+ this.draggedSelectionRange = null;
5568
+
5569
+ this.contentArea.addEventListener('dragstart', (e) => {
5570
+ const sel = window.getSelection();
5571
+ if (!sel || !sel.rangeCount || sel.isCollapsed) return;
5572
+ if (!this.contentArea.contains(sel.anchorNode) || !this.contentArea.contains(sel.focusNode)) return;
5573
+
5574
+ const range = sel.getRangeAt(0);
5575
+ const targetNode = e.target.nodeType === Node.TEXT_NODE ? e.target.parentNode : e.target;
5576
+ try {
5577
+ if (targetNode && !range.intersectsNode(targetNode)) return;
5578
+ } catch (err) {
5579
+ return;
5580
+ }
5581
+
5582
+ const container = this.cleanDraggedFragment(range.cloneContents());
5583
+
5584
+ this.draggedSelectionRange = range.cloneRange();
5585
+ e.dataTransfer.effectAllowed = 'move';
5586
+ e.dataTransfer.setData('application/x-neiki-selection', '1');
5587
+ e.dataTransfer.setData('text/html', container.innerHTML);
5588
+ e.dataTransfer.setData('text/plain', sel.toString());
5589
+ });
5590
+
5591
+ this.contentArea.addEventListener('dragend', () => {
5592
+ this.draggedSelectionRange = null;
5593
+ this.hideDropIndicator();
5594
+ });
5595
+ }
5596
+
5597
+ cleanDraggedFragment(fragment) {
5598
+ const container = document.createElement('div');
5599
+ container.appendChild(fragment);
5600
+ 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());
5601
+ this.removeStrayGripSvgs(container);
5602
+ return container;
5603
+ }
5604
+
5605
+ handleSelectionDrop(e) {
5606
+ if (!this.draggedSelectionRange || !e.dataTransfer.types.includes('application/x-neiki-selection')) {
5607
+ return false;
5608
+ }
5609
+
5610
+ const dropRange = this.getCaretRangeFromPoint(e.clientX, e.clientY);
5611
+ if (!dropRange || !this.contentArea.contains(dropRange.startContainer)) return false;
5612
+ this.hideDropIndicator();
5613
+
5614
+ const sourceRange = this.draggedSelectionRange;
5615
+ const container = this.cleanDraggedFragment(sourceRange.cloneContents());
5616
+ const fragment = document.createDocumentFragment();
5617
+ while (container.firstChild) fragment.appendChild(container.firstChild);
5618
+ const marker = document.createTextNode('');
5619
+ dropRange.insertNode(marker);
5620
+
5621
+ try {
5622
+ if (sourceRange.intersectsNode(marker)) {
5623
+ marker.remove();
5624
+ this.draggedSelectionRange = null;
5625
+ this.hideDropIndicator();
5626
+ return true;
5627
+ }
5628
+ } catch (err) {}
5629
+
5630
+ sourceRange.deleteContents();
5631
+ marker.parentNode.insertBefore(fragment, marker);
5632
+ marker.remove();
5633
+
5634
+ this.draggedSelectionRange = null;
5635
+ this._ensureDefaultBlock();
5636
+ this.history.record();
5637
+ this.syncToOriginal();
5638
+ this.triggerChange();
5639
+ this.updateStatusBar();
5640
+ return true;
5641
+ }
5642
+
5047
5643
  initDragDrop() {
5048
5644
  let dragCounter = 0;
5049
5645
 
@@ -5060,28 +5656,39 @@
5060
5656
  dragCounter--;
5061
5657
  if (dragCounter === 0) {
5062
5658
  this.contentArea.classList.remove('neiki-drag-over');
5659
+ this.hideDropIndicator();
5063
5660
  }
5064
5661
  });
5065
5662
 
5066
5663
  this.contentArea.addEventListener('dragover', (e) => {
5067
5664
  e.preventDefault();
5665
+ if (e.dataTransfer.types.includes('Files') || e.dataTransfer.types.includes('application/x-neiki-selection')) {
5666
+ const range = this.getCaretRangeFromPoint(e.clientX, e.clientY);
5667
+ if (range && this.contentArea.contains(range.startContainer)) {
5668
+ this.showDropIndicator(range, e.clientX, e.clientY);
5669
+ }
5670
+ }
5068
5671
  });
5069
5672
 
5070
5673
  this.contentArea.addEventListener('drop', (e) => {
5071
5674
  e.preventDefault();
5072
5675
  dragCounter = 0;
5073
5676
  this.contentArea.classList.remove('neiki-drag-over');
5677
+ this.hideDropIndicator();
5678
+
5679
+ if (this.handleSelectionDrop(e)) return;
5074
5680
 
5075
5681
  const files = Array.from(e.dataTransfer.files);
5076
5682
  const imageFiles = files.filter(file => file.type.startsWith('image/'));
5683
+ const videoFiles = files.filter(file => file.type.startsWith('video/'));
5077
5684
 
5078
- if (imageFiles.length > 0) {
5685
+ if (imageFiles.length > 0 || videoFiles.length > 0) {
5079
5686
  // Get cursor position from drop event
5080
5687
  const dropX = e.clientX;
5081
5688
  const dropY = e.clientY;
5082
5689
 
5083
5690
  const setCursorAtDrop = () => {
5084
- const range = document.caretRangeFromPoint(dropX, dropY);
5691
+ const range = this.getCaretRangeFromPoint(dropX, dropY);
5085
5692
  if (range) {
5086
5693
  const sel = window.getSelection();
5087
5694
  sel.removeAllRanges();
@@ -5089,30 +5696,53 @@
5089
5696
  }
5090
5697
  };
5091
5698
 
5092
- const hasUploadHandler = typeof this.config.imageUploadHandler === 'function';
5699
+ const insertFile = async (file, type) => {
5700
+ setCursorAtDrop();
5701
+ const isImage = type === 'image';
5702
+ const handler = isImage ? this.config.imageUploadHandler : this.config.videoUploadHandler;
5703
+ const hasUploadHandler = typeof handler === 'function';
5093
5704
 
5094
- if (hasUploadHandler) {
5705
+ if (hasUploadHandler) {
5706
+ const url = await handler(file);
5707
+ if (url) {
5708
+ if (isImage) this.commands.insertImage(url, file.name, '');
5709
+ else this.commands.insertVideo(url, file.name, '');
5710
+ }
5711
+ return;
5712
+ }
5713
+
5714
+ await new Promise((resolve) => {
5715
+ const reader = new FileReader();
5716
+ reader.onload = (readerEvent) => {
5717
+ setCursorAtDrop();
5718
+ if (isImage) this.commands.insertImage(readerEvent.target.result, file.name, '');
5719
+ else this.commands.insertVideo(readerEvent.target.result, file.name, '');
5720
+ resolve();
5721
+ };
5722
+ reader.readAsDataURL(file);
5723
+ });
5724
+ };
5725
+
5726
+ if (typeof this.config.imageUploadHandler === 'function' || typeof this.config.videoUploadHandler === 'function') {
5095
5727
  (async () => {
5096
5728
  for (const file of imageFiles) {
5097
5729
  try {
5098
- setCursorAtDrop();
5099
- const url = await this.config.imageUploadHandler(file);
5100
- if (url) {
5101
- this.commands.insertImage(url, file.name, '');
5102
- }
5730
+ await insertFile(file, 'image');
5103
5731
  } catch (err) {
5104
5732
  console.error('NeikiEditor: Image upload failed', err);
5105
5733
  }
5106
5734
  }
5735
+ for (const file of videoFiles) {
5736
+ try {
5737
+ await insertFile(file, 'video');
5738
+ } catch (err) {
5739
+ console.error('NeikiEditor: Video upload failed', err);
5740
+ }
5741
+ }
5107
5742
  })();
5108
5743
  } else {
5109
- imageFiles.forEach(file => {
5110
- const reader = new FileReader();
5111
- reader.onload = (readerEvent) => {
5112
- setCursorAtDrop();
5113
- this.commands.insertImage(readerEvent.target.result, file.name, '');
5114
- };
5115
- reader.readAsDataURL(file);
5744
+ [...imageFiles.map(file => [file, 'image']), ...videoFiles.map(file => [file, 'video'])].forEach(([file, type]) => {
5745
+ insertFile(file, type);
5116
5746
  });
5117
5747
  }
5118
5748
  }
@@ -5145,8 +5775,8 @@
5145
5775
  this._dragStarted = false;
5146
5776
 
5147
5777
  this.editor.contentArea.addEventListener('mousedown', (e) => {
5148
- const img = e.target.closest('img');
5149
- if (!img || !this.editor.contentArea.contains(img)) return;
5778
+ const img = e.target.closest('img, video');
5779
+ if (!img || !this.editor.contentArea.contains(img) || this.isEditorUiElement(img)) return;
5150
5780
  if (e.target.closest('.neiki-img-resize-handle') || e.target.closest('.neiki-img-toolbar')) return;
5151
5781
 
5152
5782
  e.preventDefault();
@@ -5191,8 +5821,8 @@
5191
5821
 
5192
5822
  this.editor.contentArea.addEventListener('click', (e) => {
5193
5823
  if (this._dragStarted) return;
5194
- const img = e.target.closest('img');
5195
- if (img && this.editor.contentArea.contains(img)) {
5824
+ const img = e.target.closest('img, video');
5825
+ if (img && this.editor.contentArea.contains(img) && !this.isEditorUiElement(img)) {
5196
5826
  e.preventDefault();
5197
5827
  if (!this.wrapper || this.currentImg !== img) {
5198
5828
  this.selectImage(img);
@@ -5204,8 +5834,8 @@
5204
5834
 
5205
5835
  // Touch: tap image to select (drag only via grip handle in img toolbar)
5206
5836
  this.editor.contentArea.addEventListener('touchend', (e) => {
5207
- const img = e.target.closest('img');
5208
- if (!img || !this.editor.contentArea.contains(img)) return;
5837
+ const img = e.target.closest('img, video');
5838
+ if (!img || !this.editor.contentArea.contains(img) || this.isEditorUiElement(img)) return;
5209
5839
  if (e.target.closest('.neiki-img-resize-handle') || e.target.closest('.neiki-img-toolbar')) return;
5210
5840
  e.preventDefault();
5211
5841
  if (!this.wrapper || this.currentImg !== img) {
@@ -5215,7 +5845,7 @@
5215
5845
 
5216
5846
  // Prevent native image drag inside editor (causes duplicate on drop)
5217
5847
  this.editor.contentArea.addEventListener('dragstart', (e) => {
5218
- if (e.target.tagName === 'IMG') {
5848
+ if (e.target.tagName === 'IMG' || e.target.tagName === 'VIDEO') {
5219
5849
  e.preventDefault();
5220
5850
  }
5221
5851
  });
@@ -5233,9 +5863,14 @@
5233
5863
  });
5234
5864
  }
5235
5865
 
5866
+ isEditorUiElement(node) {
5867
+ 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'));
5868
+ }
5869
+
5236
5870
  selectImage(img) {
5237
5871
  this.deselect();
5238
5872
  this.currentImg = img;
5873
+ const isVideo = img.tagName === 'VIDEO';
5239
5874
 
5240
5875
  // Create wrapper around image
5241
5876
  this.wrapper = document.createElement('span');
@@ -5322,7 +5957,7 @@
5322
5957
  const replaceBtn = document.createElement('button');
5323
5958
  replaceBtn.className = 'neiki-img-toolbar-btn';
5324
5959
  replaceBtn.type = 'button';
5325
- replaceBtn.title = t('imageToolbar.replaceImage');
5960
+ replaceBtn.title = isVideo ? t('videoToolbar.replaceVideo') : t('imageToolbar.replaceImage');
5326
5961
  replaceBtn.innerHTML = Icons.replaceImage;
5327
5962
  replaceBtn.addEventListener('click', (e) => {
5328
5963
  e.preventDefault();
@@ -5333,7 +5968,7 @@
5333
5968
  const deleteBtn = document.createElement('button');
5334
5969
  deleteBtn.className = 'neiki-img-toolbar-btn neiki-img-toolbar-btn-danger';
5335
5970
  deleteBtn.type = 'button';
5336
- deleteBtn.title = t('imageToolbar.deleteImage');
5971
+ deleteBtn.title = isVideo ? t('videoToolbar.deleteVideo') : t('imageToolbar.deleteImage');
5337
5972
  deleteBtn.innerHTML = Icons.trash;
5338
5973
  deleteBtn.addEventListener('click', (e) => {
5339
5974
  e.preventDefault();
@@ -5350,7 +5985,7 @@
5350
5985
  this.wrapper.appendChild(this.imgToolbar);
5351
5986
 
5352
5987
  this.positionImgToolbar();
5353
- this.selectWrapperRange();
5988
+ this.clearNativeSelection();
5354
5989
  }
5355
5990
 
5356
5991
  getImageBlock() {
@@ -5401,19 +6036,24 @@
5401
6036
 
5402
6037
  replaceImage() {
5403
6038
  if (!this.currentImg) return;
6039
+ const isVideo = this.currentImg.tagName === 'VIDEO';
5404
6040
  const input = document.createElement('input');
5405
6041
  input.type = 'file';
5406
- input.accept = 'image/*';
6042
+ input.accept = isVideo ? 'video/*' : 'image/*';
5407
6043
  input.addEventListener('change', async () => {
5408
6044
  const file = input.files[0];
5409
6045
  if (!file) return;
5410
- const hasUploadHandler = typeof this.editor.config.imageUploadHandler === 'function';
6046
+ const hasUploadHandler = isVideo
6047
+ ? typeof this.editor.config.videoUploadHandler === 'function'
6048
+ : typeof this.editor.config.imageUploadHandler === 'function';
5411
6049
  if (hasUploadHandler) {
5412
6050
  try {
5413
- const url = await this.editor.config.imageUploadHandler(file);
6051
+ const url = isVideo
6052
+ ? await this.editor.config.videoUploadHandler(file)
6053
+ : await this.editor.config.imageUploadHandler(file);
5414
6054
  if (url) this.currentImg.src = url;
5415
6055
  } catch (err) {
5416
- console.error('NeikiEditor: Image upload failed', err);
6056
+ console.error(`NeikiEditor: ${isVideo ? 'Video' : 'Image'} upload failed`, err);
5417
6057
  }
5418
6058
  } else {
5419
6059
  const reader = new FileReader();
@@ -5433,6 +6073,7 @@
5433
6073
  const img = this.currentImg;
5434
6074
  const wrapper = this.wrapper;
5435
6075
  const contentArea = this.editor.contentArea;
6076
+ if (this.editor.blockDragDrop) this.editor.blockDragDrop.hideGrip();
5436
6077
 
5437
6078
  // Save image dimensions
5438
6079
  const imgWidth = img.style.width;
@@ -5447,6 +6088,7 @@
5447
6088
  const ghostImg = img.cloneNode(true);
5448
6089
  ghostImg.style.width = '100%';
5449
6090
  ghostImg.style.height = 'auto';
6091
+ ghostImg.removeAttribute('controls');
5450
6092
  ghost.appendChild(ghostImg);
5451
6093
  document.body.appendChild(ghost);
5452
6094
 
@@ -5461,22 +6103,6 @@
5461
6103
  // Caret marker for drop position
5462
6104
  let dropRange = null;
5463
6105
 
5464
- const getCaretRange = (x, y) => {
5465
- if (document.caretRangeFromPoint) {
5466
- return document.caretRangeFromPoint(x, y);
5467
- }
5468
- if (document.caretPositionFromPoint) {
5469
- const pos = document.caretPositionFromPoint(x, y);
5470
- if (pos) {
5471
- const r = document.createRange();
5472
- r.setStart(pos.offsetNode, pos.offset);
5473
- r.collapse(true);
5474
- return r;
5475
- }
5476
- }
5477
- return null;
5478
- };
5479
-
5480
6106
  const onMove = (ev) => {
5481
6107
  let cx, cy;
5482
6108
  if (isTouch) {
@@ -5491,19 +6117,10 @@
5491
6117
  }
5492
6118
  ghost.style.left = (cx - offsetX) + 'px';
5493
6119
  ghost.style.top = (cy - offsetY) + 'px';
5494
- const range = getCaretRange(cx, cy);
5495
- if (range && contentArea.contains(range.startContainer)) {
5496
- // Avoid dropping inside the wrapper itself
5497
- let node = range.startContainer;
5498
- while (node && node !== contentArea) {
5499
- if (node === wrapper) return;
5500
- node = node.parentNode;
5501
- }
6120
+ const range = this.editor.getCaretRangeFromPoint(cx, cy);
6121
+ if (this.isSafeDropRange(range, wrapper)) {
5502
6122
  dropRange = range;
5503
- // Show caret position
5504
- const sel = window.getSelection();
5505
- sel.removeAllRanges();
5506
- sel.addRange(range);
6123
+ this.editor.showDropIndicator(range, cx, cy);
5507
6124
  }
5508
6125
  };
5509
6126
 
@@ -5533,7 +6150,7 @@
5533
6150
  this.sizeLabel = null;
5534
6151
  this.imgToolbar = null;
5535
6152
 
5536
- if (dropRange && contentArea.contains(dropRange.startContainer)) {
6153
+ if (this.isSafeDropRange(dropRange, wrapper)) {
5537
6154
  // Insert image at the caret drop position
5538
6155
  dropRange.insertNode(img);
5539
6156
  } else {
@@ -5545,6 +6162,7 @@
5545
6162
 
5546
6163
  // Clean up empty parent blocks left behind
5547
6164
  this.editor._ensureDefaultBlock();
6165
+ this.editor.removeStrayGripSvgs();
5548
6166
 
5549
6167
  // Re-select the image at its new position
5550
6168
  this.selectImage(img);
@@ -5563,14 +6181,23 @@
5563
6181
  }
5564
6182
  }
5565
6183
 
5566
- selectWrapperRange() {
5567
- if (!this.wrapper) return;
5568
- this.editor.focus();
6184
+ isSafeDropRange(range, wrapper) {
6185
+ if (!range || !this.editor.contentArea.contains(range.startContainer)) return false;
6186
+ let node = range.startContainer;
6187
+ while (node && node !== this.editor.contentArea) {
6188
+ if (node === wrapper) return false;
6189
+ 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')) {
6190
+ return false;
6191
+ }
6192
+ this.editor.hideDropIndicator();
6193
+ node = node.parentNode;
6194
+ }
6195
+ return true;
6196
+ }
6197
+
6198
+ clearNativeSelection() {
5569
6199
  const sel = window.getSelection();
5570
- const range = document.createRange();
5571
- range.selectNode(this.wrapper);
5572
- sel.removeAllRanges();
5573
- sel.addRange(range);
6200
+ if (sel) sel.removeAllRanges();
5574
6201
  }
5575
6202
 
5576
6203
  getSelectedImageClipboardData() {
@@ -5905,7 +6532,8 @@
5905
6532
  }
5906
6533
  });
5907
6534
 
5908
- this.editor.contentArea.addEventListener('mouseleave', () => {
6535
+ this.editor.contentArea.addEventListener('mouseleave', (e) => {
6536
+ if (this.gripEl && e.relatedTarget && this.gripEl.contains(e.relatedTarget)) return;
5909
6537
  if (!this.isDragging) this.hideGrip();
5910
6538
  });
5911
6539
  }
@@ -5934,10 +6562,10 @@
5934
6562
  grip._block = block;
5935
6563
 
5936
6564
  // Position grip
5937
- const contentRect = this.editor.contentArea.getBoundingClientRect();
6565
+ const wrapperRect = this.editor.contentWrapper.getBoundingClientRect();
5938
6566
  const blockRect = block.getBoundingClientRect();
5939
- grip.style.top = (blockRect.top - contentRect.top + this.editor.contentArea.scrollTop) + 'px';
5940
- grip.style.left = '-28px';
6567
+ grip.style.top = (blockRect.top - wrapperRect.top) + 'px';
6568
+ grip.style.left = Math.max(2, blockRect.left - wrapperRect.left - 28) + 'px';
5941
6569
 
5942
6570
  grip.addEventListener('mousedown', (e) => {
5943
6571
  e.preventDefault();
@@ -5945,7 +6573,7 @@
5945
6573
  this.startDrag(e, block);
5946
6574
  });
5947
6575
 
5948
- this.editor.contentArea.appendChild(grip);
6576
+ this.editor.contentWrapper.appendChild(grip);
5949
6577
  this.gripEl = grip;
5950
6578
  }
5951
6579
 
@@ -5963,6 +6591,8 @@
5963
6591
 
5964
6592
  // Create ghost
5965
6593
  this.ghostEl = block.cloneNode(true);
6594
+ this.editor.removeStrayGripSvgs(this.ghostEl);
6595
+ this.ghostEl.querySelectorAll('.neiki-block-grip, .neiki-img-toolbar, .neiki-img-resize-handle, .neiki-img-size-label').forEach(el => el.remove());
5966
6596
  this.ghostEl.className = (this.ghostEl.className || '') + ' neiki-block-ghost';
5967
6597
  this.ghostEl.style.width = block.offsetWidth + 'px';
5968
6598
  document.body.appendChild(this.ghostEl);
@@ -6012,7 +6642,9 @@
6012
6642
  this.placeholder = null;
6013
6643
  this.ghostEl = null;
6014
6644
 
6645
+ this.editor.removeStrayGripSvgs();
6015
6646
  this.editor.history.record();
6647
+ this.editor.syncToOriginal();
6016
6648
  this.editor.triggerChange();
6017
6649
  };
6018
6650