fable-editor 1.0.2 → 1.1.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,5 +1,6 @@
1
1
  import * as i0 from '@angular/core';
2
2
  import { EventEmitter, Output, Input, ViewChild, Component, NgModule } from '@angular/core';
3
+ import katex from 'katex';
3
4
 
4
5
  const I18N = {
5
6
  en: {
@@ -142,7 +143,46 @@ const I18N = {
142
143
  tplimgright: 'Image right, text left',
143
144
  tplimgtop: 'Image top, text below',
144
145
  tplimgcenter: 'Text top, image center',
145
- deltemplate: 'Delete template'
146
+ deltemplate: 'Delete template',
147
+ video: 'Video',
148
+ quickvideo: 'Insert video',
149
+ deletevideo: 'Delete video',
150
+ uploadvideo: 'Upload from computer',
151
+ videolink: 'Insert from URL',
152
+ videourlph: 'Paste video URL…',
153
+ insertvideo: 'Insert',
154
+ dropvideo: 'Drop video here',
155
+ mathformula: 'Insert math formula',
156
+ mathdlgttl: 'Insert Math Formula',
157
+ mathderivation: 'Multi-line derivation',
158
+ mathlatex: 'LaTeX',
159
+ editmath: 'Edit formula',
160
+ deletemath: 'Delete formula',
161
+ videodlgttl: 'Insert/edit video',
162
+ vidgeneral: 'General',
163
+ vidembed: 'Embed',
164
+ vidadvanced: 'Advanced',
165
+ vidsource: 'Source',
166
+ vidembedhint: 'Paste your embed code below:',
167
+ vidaltsource: 'Alternative source URL',
168
+ vidposter: 'Media poster (Image URL)',
169
+ emoji: 'Emojis…',
170
+ chcat_currency: 'Currency',
171
+ chcat_text: 'Text & punctuation',
172
+ chcat_math: 'Mathematical',
173
+ chcat_arrows: 'Arrows',
174
+ chcat_symbols: 'Symbols',
175
+ chcat_latin: 'Extended Latin',
176
+ chcat_arabic: 'Arabic',
177
+ emcat_smileys: 'Smileys & emotion',
178
+ emcat_people: 'People & body',
179
+ emcat_animals: 'Animals & nature',
180
+ emcat_food: 'Food & drink',
181
+ emcat_travel: 'Travel & places',
182
+ emcat_activities: 'Activities',
183
+ emcat_objects: 'Objects',
184
+ emcat_symbols: 'Symbols',
185
+ emcat_flags: 'Flags'
146
186
  },
147
187
  ar: {
148
188
  dir: 'rtl',
@@ -284,7 +324,46 @@ const I18N = {
284
324
  tplimgright: 'صورة يمين، نص يسار',
285
325
  tplimgtop: 'صورة أعلى، نص أسفل',
286
326
  tplimgcenter: 'نص أعلى، صورة وسط',
287
- deltemplate: 'حذف القالب'
327
+ deltemplate: 'حذف القالب',
328
+ video: 'فيديو',
329
+ quickvideo: 'إدراج فيديو',
330
+ deletevideo: 'حذف الفيديو',
331
+ uploadvideo: 'رفع من الجهاز',
332
+ videolink: 'إدراج من رابط',
333
+ videourlph: 'الصق رابط الفيديو…',
334
+ insertvideo: 'إدراج',
335
+ dropvideo: 'أسقط الفيديو هنا',
336
+ mathformula: 'إدراج معادلة رياضية',
337
+ mathdlgttl: 'إدراج معادلة رياضية',
338
+ mathderivation: 'اشتقاق متعدد الأسطر',
339
+ mathlatex: 'LaTeX',
340
+ editmath: 'تعديل المعادلة',
341
+ deletemath: 'حذف المعادلة',
342
+ videodlgttl: 'إدراج/تعديل فيديو',
343
+ vidgeneral: 'عام',
344
+ vidembed: 'تضمين',
345
+ vidadvanced: 'متقدم',
346
+ vidsource: 'المصدر',
347
+ vidembedhint: 'الصق كود التضمين أدناه:',
348
+ vidaltsource: 'رابط مصدر بديل',
349
+ vidposter: 'صورة الغلاف (رابط الصورة)',
350
+ emoji: 'رموز تعبيرية…',
351
+ chcat_currency: 'عملات',
352
+ chcat_text: 'نص وعلامات ترقيم',
353
+ chcat_math: 'رموز رياضية',
354
+ chcat_arrows: 'أسهم',
355
+ chcat_symbols: 'رموز',
356
+ chcat_latin: 'لاتينية موسعة',
357
+ chcat_arabic: 'عربية',
358
+ emcat_smileys: 'وجوه ومشاعر',
359
+ emcat_people: 'أشخاص وإيماءات',
360
+ emcat_animals: 'حيوانات وطبيعة',
361
+ emcat_food: 'طعام وشراب',
362
+ emcat_travel: 'سفر وأماكن',
363
+ emcat_activities: 'أنشطة',
364
+ emcat_objects: 'أشياء',
365
+ emcat_symbols: 'رموز',
366
+ emcat_flags: 'أعلام'
288
367
  }
289
368
  };
290
369
  function getStrings(lang) {
@@ -296,7 +375,7 @@ function getStrings(lang) {
296
375
  const DEFAULT_TOOLBAR = 'undo redo | preview print | importword revhistory | fontfamily fontsize | fontsizeincrease fontsizedecrease | ' +
297
376
  'bold italic underline strikethrough | forecolor backcolor | alignleft aligncenter alignright alignjustify | ' +
298
377
  'bullist numlist | outdent indent | link blockquote changecase lineheight wordspacing | removeformat | blocks | ' +
299
- 'ltr rtl | quickimage quicktable template charmap | fullscreen sourcecode';
378
+ 'ltr rtl | quickimage quickvideo quicktable mathformula template charmap emoji | fullscreen sourcecode';
300
379
  /** Transcribed from the editor's built-in menu keys so that `menubar: true` (the
301
380
  * default) renders identically to the hard-coded original. */
302
381
  const DEFAULT_MENUBAR = 'file edit view insert format tools table help';
@@ -362,6 +441,147 @@ const COLORS = [
362
441
  '#000000',
363
442
  '#FFFFFF'
364
443
  ];
444
+ /** Special-character map, grouped for the two-pane picker (tabs left, glyphs right).
445
+ * `CHARS` below is the legacy flat list and is kept exported for back-compat. */
446
+ const CHAR_CATEGORIES = [
447
+ {
448
+ key: 'chcat_currency',
449
+ chars: ['€', '£', '¥', '¢', '$', '₹', '₽', '₩', '₺', '₴', '₦', '฿', '₫', '₪', '﷼', '¤']
450
+ },
451
+ {
452
+ key: 'chcat_text',
453
+ chars: [
454
+ '§', '¶', '†', '‡', '•', '…', '′', '″', '‰', '–', '—', '¡', '¿', '·',
455
+ '‹', '›', '«', '»', '“', '”', '‘', '’', '„', '‚', '¦', 'ª', 'º'
456
+ ]
457
+ },
458
+ {
459
+ key: 'chcat_math',
460
+ chars: [
461
+ '°', '±', '×', '÷', '≈', '≠', '≤', '≥', '∞', 'µ', '√', '∑', '∫', '∂', '∆', '∏',
462
+ 'π', 'Ω', 'α', 'β', 'γ', 'θ', 'λ', 'σ', 'φ', 'ω', '∈', '∉', '∪', '∩', '⊂', '⊃',
463
+ '∀', '∃', '∅', '∝', '∴', '¬', '½', '⅓', '¼', '¾', '⅔', '⅛', '⅜', '⅝', '⅞', '¹', '²', '³'
464
+ ]
465
+ },
466
+ {
467
+ key: 'chcat_arrows',
468
+ chars: ['←', '→', '↑', '↓', '↔', '↕', '⇐', '⇒', '⇑', '⇓', '⇔', '↖', '↗', '↘', '↙', '↩', '↪', '⤴', '⤵']
469
+ },
470
+ {
471
+ key: 'chcat_symbols',
472
+ chars: [
473
+ '©', '®', '™', '℠', '№', '℮', '✓', '✗', '★', '☆', '✦', '♠', '♣', '♥', '♦',
474
+ '♪', '♫', '☀', '☁', '☂', '☾', '☹', '☺', '✉', '✂', '✎', '☞', '☜'
475
+ ]
476
+ },
477
+ {
478
+ key: 'chcat_latin',
479
+ chars: [
480
+ 'À', 'Á', 'Â', 'Ã', 'Ä', 'Å', 'Æ', 'Ç', 'È', 'É', 'Ê', 'Ë', 'Ì', 'Í', 'Î', 'Ï',
481
+ 'Ñ', 'Ò', 'Ó', 'Ô', 'Õ', 'Ö', 'Ø', 'Ù', 'Ú', 'Û', 'Ü', 'Ý', 'ß',
482
+ 'à', 'á', 'â', 'ã', 'ä', 'å', 'æ', 'ç', 'è', 'é', 'ê', 'ë', 'ì', 'í', 'î', 'ï',
483
+ 'ñ', 'ò', 'ó', 'ô', 'õ', 'ö', 'ø', 'ù', 'ú', 'û', 'ü', 'ý', 'ÿ'
484
+ ]
485
+ },
486
+ {
487
+ key: 'chcat_arabic',
488
+ chars: ['؟', '،', '؛', '٪', '٭', 'ـ', '٠', '١', '٢', '٣', '٤', '٥', '٦', '٧', '٨', '٩', '﴾', '﴿', '۞', '؎']
489
+ }
490
+ ];
491
+ /** Emoji map for the emoji picker — same two-pane dialog as the character map. */
492
+ const EMOJI_CATEGORIES = [
493
+ {
494
+ key: 'emcat_smileys',
495
+ chars: [
496
+ '😀', '😃', '😄', '😁', '😆', '😅', '😂', '🤣', '😊', '😇', '🙂', '🙃', '😉', '😌', '😍', '🥰',
497
+ '😘', '😋', '😛', '😜', '🤪', '🤨', '🧐', '🤓', '😎', '🥳', '😏', '😒', '😞', '😔', '😟', '😕',
498
+ '🙁', '😣', '😫', '😩', '🥺', '😢', '😭', '😤', '😠', '😡', '🤯', '😳', '🥵', '🥶', '😱', '😨',
499
+ '😥', '🤗', '🤔', '🤭', '🤫', '😐', '😑', '😬', '🙄', '😯', '😴', '😪', '😷', '🤒', '🤕', '🤢',
500
+ '🤧', '😈', '💀', '👻', '👽', '🤖', '💩', '😺', '😸', '😹', '😻', '🙀'
501
+ ]
502
+ },
503
+ {
504
+ key: 'emcat_people',
505
+ chars: [
506
+ '👋', '🤚', '✋', '🖖', '👌', '🤏', '✌', '🤞', '🤟', '🤘', '🤙', '👈', '👉', '👆', '👇', '☝',
507
+ '👍', '👎', '✊', '👊', '👏', '🙌', '👐', '🤲', '🤝', '🙏', '💪', '✍', '💅', '🤳', '👂', '👃',
508
+ '👀', '👁', '🧠', '👶', '🧒', '👦', '👧', '🧑', '👨', '👩', '🧓', '👴', '👵', '👮', '👷', '💂',
509
+ '🕵', '👸', '🤴', '👰', '🤵', '🤰', '👼', '🎅', '🦸', '🦹', '🧙', '🧚', '🧛', '🧟', '💃', '🕺',
510
+ '🚶', '🏃', '🧘'
511
+ ]
512
+ },
513
+ {
514
+ key: 'emcat_animals',
515
+ chars: [
516
+ '🐶', '🐱', '🐭', '🐹', '🐰', '🦊', '🐻', '🐼', '🐨', '🐯', '🦁', '🐮', '🐷', '🐸', '🐵', '🐔',
517
+ '🐧', '🐦', '🐤', '🦆', '🦅', '🦉', '🐺', '🐗', '🐴', '🦄', '🐝', '🐛', '🦋', '🐌', '🐞', '🐢',
518
+ '🐍', '🦎', '🦖', '🐙', '🦑', '🦀', '🐠', '🐟', '🐬', '🐳', '🦈', '🐊', '🐅', '🦓', '🦍', '🐘',
519
+ '🦏', '🐪', '🦒', '🦘', '🐄', '🐎', '🐖', '🐑', '🐐', '🦌', '🐕', '🐈', '🐓', '🦃', '🦚', '🦜',
520
+ '🦢', '🕊', '🐇', '🦔', '🌵', '🌲', '🌴', '🌱', '🌿', '☘', '🍀', '🍁', '🌸', '🌺', '🌻', '🌹',
521
+ '🌷', '💐'
522
+ ]
523
+ },
524
+ {
525
+ key: 'emcat_food',
526
+ chars: [
527
+ '🍏', '🍎', '🍐', '🍊', '🍋', '🍌', '🍉', '🍇', '🍓', '🍈', '🍒', '🍑', '🥭', '🍍', '🥥', '🥝',
528
+ '🍅', '🍆', '🥑', '🥦', '🥒', '🌶', '🌽', '🥕', '🥔', '🥐', '🍞', '🥖', '🥨', '🧀', '🥚', '🍳',
529
+ '🥞', '🥓', '🥩', '🍗', '🍖', '🌭', '🍔', '🍟', '🍕', '🥪', '🌮', '🌯', '🥗', '🍝', '🍜', '🍲',
530
+ '🍛', '🍣', '🍱', '🥟', '🍤', '🍚', '🍧', '🍨', '🍦', '🥧', '🧁', '🍰', '🎂', '🍮', '🍭', '🍬',
531
+ '🍫', '🍿', '🍩', '🍪', '🥜', '🍯', '🥛', '☕', '🍵', '🥤', '🍺', '🥂', '🍷', '🍸', '🍹'
532
+ ]
533
+ },
534
+ {
535
+ key: 'emcat_travel',
536
+ chars: [
537
+ '🚗', '🚕', '🚙', '🚌', '🏎', '🚓', '🚑', '🚒', '🚚', '🚜', '🛵', '🏍', '🚲', '🛴', '🚔', '🚃',
538
+ '🚄', '🚅', '🚂', '🚆', '🚇', '🚉', '✈', '🛫', '🛬', '💺', '🚁', '🚀', '🛸', '🚢', '⛵', '🚤',
539
+ '⚓', '⛽', '🚧', '🗺', '🗿', '🗽', '🗼', '🏰', '🏯', '🎡', '🎢', '🎠', '⛲', '🏖', '🏝', '🌋',
540
+ '⛰', '🏔', '🗻', '🏕', '⛺', '🏠', '🏡', '🏢', '🏬', '🏥', '🏦', '🏨', '🏪', '🏫', '💒', '⛪',
541
+ '🕌', '🕍', '🕋', '⛩', '🌅', '🌄', '🌇', '🌆', '🌃', '🌉', '🌌', '🌠'
542
+ ]
543
+ },
544
+ {
545
+ key: 'emcat_activities',
546
+ chars: [
547
+ '⚽', '🏀', '🏈', '⚾', '🎾', '🏐', '🏉', '🎱', '🏓', '🏸', '🏒', '🏑', '🏏', '⛳', '🏹', '🎣',
548
+ '🥊', '🥋', '⛸', '🎿', '⛷', '🏂', '🏋', '🤸', '⛹', '🤺', '🏇', '🧗', '🏄', '🏊', '🚣', '🚵',
549
+ '🚴', '🏆', '🥇', '🥈', '🥉', '🏅', '🎖', '🎫', '🎪', '🤹', '🎭', '🎨', '🎬', '🎤', '🎧', '🎼',
550
+ '🎹', '🥁', '🎷', '🎺', '🎸', '🎻', '🎲', '🎯', '🎳', '🎮', '🎰', '🧩'
551
+ ]
552
+ },
553
+ {
554
+ key: 'emcat_objects',
555
+ chars: [
556
+ '⌚', '📱', '💻', '⌨', '🖥', '🖨', '🖱', '🕹', '💽', '💾', '💿', '📀', '📷', '📸', '📹', '🎥',
557
+ '📞', '☎', '📺', '📻', '🎙', '⏱', '⏰', '⌛', '⏳', '📡', '🔋', '🔌', '💡', '🔦', '🕯', '💸',
558
+ '💵', '💰', '💳', '💎', '⚖', '🔧', '🔨', '⚒', '🛠', '⛏', '🔩', '⚙', '🧲', '🔫', '💣', '🔪',
559
+ '🛡', '🔮', '🧿', '🔭', '🔬', '💊', '💉', '🌡', '🧬', '🧪', '🧹', '🧺', '🛁', '🔑', '🗝', '🚪',
560
+ '🛋', '🛏', '🧸', '🖼', '🛍', '🛒', '🎁', '🎈', '🎀', '🎊', '🎉', '✉', '📩', '📦', '📜', '📄',
561
+ '📊', '📈', '📉', '📅', '📋', '📁', '📰', '📓', '📚', '📖', '🔖', '🔗', '📎', '📐', '📏', '📌',
562
+ '📍', '✂', '🖊', '✒', '🖌', '🖍', '📝', '✏', '🔍', '🔒', '🔓'
563
+ ]
564
+ },
565
+ {
566
+ key: 'emcat_symbols',
567
+ chars: [
568
+ '❤', '🧡', '💛', '💚', '💙', '💜', '🖤', '💔', '❣', '💕', '💞', '💓', '💗', '💖', '💘', '💝',
569
+ '☮', '✝', '☪', '🕉', '☸', '✡', '☯', '♈', '♉', '♊', '♋', '♌', '♍', '♎', '♏', '♐',
570
+ '♑', '♒', '♓', '⚛', '❌', '⭕', '🛑', '⛔', '🚫', '💯', '💢', '⚠', '🚸', '🔱', '⚜', '🔰',
571
+ '♻', '✅', '❇', '✳', '❎', '🌐', '💠', '🌀', '💤', '🏧', '♿', '🚭', '❗', '❕', '❓', '❔',
572
+ '‼', '⁉', '💲', '🔅', '🔆', '〽', 'ℹ', '🆗', '🆕', '🆓', '🆒', '🆙', '🈯', '🈚'
573
+ ]
574
+ },
575
+ {
576
+ key: 'emcat_flags',
577
+ chars: [
578
+ '🏁', '🚩', '🎌', '🏴', '🏳', '🇦🇪', '🇸🇦', '🇶🇦', '🇰🇼', '🇧🇭', '🇴🇲', '🇪🇬', '🇯🇴', '🇱🇧', '🇮🇳', '🇵🇰',
579
+ '🇺🇸', '🇬🇧', '🇨🇦', '🇦🇺', '🇫🇷', '🇩🇪', '🇮🇹', '🇪🇸', '🇵🇹', '🇳🇱', '🇧🇪', '🇨🇭', '🇸🇪', '🇳🇴', '🇩🇰', '🇫🇮',
580
+ '🇮🇪', '🇬🇷', '🇹🇷', '🇷🇺', '🇺🇦', '🇵🇱', '🇯🇵', '🇰🇷', '🇨🇳', '🇸🇬', '🇲🇾', '🇮🇩', '🇹🇭', '🇵🇭', '🇻🇳', '🇧🇷',
581
+ '🇦🇷', '🇲🇽', '🇿🇦', '🇳🇬', '🇰🇪', '🇲🇦'
582
+ ]
583
+ }
584
+ ];
365
585
  const CHARS = [
366
586
  '©',
367
587
  '®',
@@ -503,7 +723,11 @@ const IC = {
503
723
  ol_alpha: LIST(['a', 'b', 'c']),
504
724
  ol_roman: LIST(['i', 'ii', 'iii'], 6.5),
505
725
  ol_alphau: LIST(['A', 'B', 'C']),
506
- ol_romanu: LIST(['I', 'II', 'III'], 6.5)
726
+ ol_romanu: LIST(['I', 'II', 'III'], 6.5),
727
+ video: S('<rect x="3" y="6" width="13" height="12" rx="1.5"/><path d="M16 10l5-3v10l-5-3z"/>'),
728
+ editic: S('<path d="M4 20l1-4.5L15.5 5 19 8.5 8.5 19 4 20z"/><path d="M13.5 6.5l4 4"/>'),
729
+ mathic: S('<text x="2" y="18" font-size="17" stroke="none" fill="#222f3e" font-family="Georgia, \'Times New Roman\', serif" font-style="italic">&#8721;</text>'),
730
+ emojiic: S('<circle cx="12" cy="12" r="8.5"/><path d="M8.6 14.3a4.6 4.6 0 0 0 6.8 0"/><path d="M9.2 9.6v.7M14.8 9.6v.7"/>')
507
731
  };
508
732
  const TXT = (html) => `<span class="txt">${html}</span>`;
509
733
 
@@ -1432,6 +1656,19 @@ class FableEditor {
1432
1656
  imageUploadHandler;
1433
1657
  onImageUploadError;
1434
1658
  contentStyle;
1659
+ vidInput;
1660
+ /* a <video> element or a .video-embed iframe wrapper — both share the
1661
+ same selection outline + align/delete context toolbar */
1662
+ vidActive = null;
1663
+ vidCtx = null;
1664
+ vidHideTimer = null;
1665
+ vphActive = null;
1666
+ vphCtx = null;
1667
+ vphUploadTarget = null;
1668
+ videoUploadHandler;
1669
+ onVideoUploadError;
1670
+ mathActive = null;
1671
+ mathCtx = null;
1435
1672
  selCtx = null;
1436
1673
  tplActive = null;
1437
1674
  tplCtx = null;
@@ -1456,10 +1693,13 @@ class FableEditor {
1456
1693
  readonly: options.readonly ?? false,
1457
1694
  draftKey: options.draftKey ?? (typeof location !== 'undefined' ? location.pathname : 'default'),
1458
1695
  fontFamilyFormats: options.fontFamilyFormats ?? FONTS,
1459
- imageFileTypes: options.imageFileTypes ?? ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/bmp', 'image/webp']
1696
+ imageFileTypes: options.imageFileTypes ?? ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/bmp', 'image/webp'],
1697
+ videoFileTypes: options.videoFileTypes ?? ['video/mp4', 'video/webm', 'video/ogg']
1460
1698
  };
1461
1699
  this.imageUploadHandler = options.imageUploadHandler;
1462
1700
  this.onImageUploadError = options.onImageUploadError;
1701
+ this.videoUploadHandler = options.videoUploadHandler;
1702
+ this.onVideoUploadError = options.onVideoUploadError;
1463
1703
  this.contentStyle = options.contentStyle;
1464
1704
  this.lang = this.options.language;
1465
1705
  this.draftStorageKey = 'tmclone-draft:' + this.options.draftKey;
@@ -1526,6 +1766,11 @@ class FableEditor {
1526
1766
  this.imgInput.accept = this.options.imageFileTypes.join(',');
1527
1767
  this.imgInput.style.display = 'none';
1528
1768
  target.appendChild(this.imgInput);
1769
+ this.vidInput = document.createElement('input');
1770
+ this.vidInput.type = 'file';
1771
+ this.vidInput.accept = this.options.videoFileTypes.join(',');
1772
+ this.vidInput.style.display = 'none';
1773
+ target.appendChild(this.vidInput);
1529
1774
  this.docInput = document.createElement('input');
1530
1775
  this.docInput.type = 'file';
1531
1776
  this.docInput.accept = '.docx,application/vnd.openxmlformats-officedocument.wordprocessingml.document';
@@ -1594,6 +1839,9 @@ class FableEditor {
1594
1839
  this.positionImageHandles();
1595
1840
  this.positionImgPhCtx();
1596
1841
  this.positionTplCtx();
1842
+ this.positionVphCtx();
1843
+ this.positionVidCtx();
1844
+ this.positionMathCtx();
1597
1845
  });
1598
1846
  this.on(this.ed, 'keydown', (e) => {
1599
1847
  if (e.altKey && e.key === '0') {
@@ -1604,18 +1852,32 @@ class FableEditor {
1604
1852
  e.preventDefault();
1605
1853
  this.removeImgPlaceholder();
1606
1854
  }
1855
+ if ((e.key === 'Delete' || e.key === 'Backspace') && this.vphActive) {
1856
+ e.preventDefault();
1857
+ this.removeVphPlaceholder();
1858
+ }
1859
+ if (e.key === '$' && !e.ctrlKey && !e.metaKey && !e.altKey)
1860
+ this.tryMathTyping(e);
1607
1861
  });
1608
1862
  this.on(this.ed, 'paste', (e) => this.handlePaste(e));
1609
1863
  this.on(this.ed, 'drop', (e) => {
1610
1864
  /* content drops stay blocked (powerpaste_block_drop) — only an
1611
- image file dropped onto an upload placeholder is consumed */
1865
+ image/video file dropped onto its matching upload placeholder is consumed */
1612
1866
  e.preventDefault();
1613
- const ph = e.target.closest?.('.img-ph');
1614
- if (!ph || !this.ed.contains(ph) || this.options.readonly)
1867
+ const target = e.target;
1868
+ const ph = target.closest?.('.img-ph:not(.vid-ph)');
1869
+ if (ph && this.ed.contains(ph) && !this.options.readonly) {
1870
+ const file = Array.from(e.dataTransfer?.files || []).find((f) => f.type.startsWith('image/'));
1871
+ if (file)
1872
+ this.readImageFileInto(file, ph);
1615
1873
  return;
1616
- const file = Array.from(e.dataTransfer?.files || []).find((f) => f.type.startsWith('image/'));
1617
- if (file)
1618
- this.readImageFileInto(file, ph);
1874
+ }
1875
+ const vph = target.closest?.('.vid-ph');
1876
+ if (vph && this.ed.contains(vph) && !this.options.readonly) {
1877
+ const file = Array.from(e.dataTransfer?.files || []).find((f) => f.type.startsWith('video/'));
1878
+ if (file)
1879
+ this.readVideoFileInto(file, vph);
1880
+ }
1619
1881
  });
1620
1882
  this.on(this.ed, 'dragover', (e) => e.preventDefault());
1621
1883
  this.on(this.ed, 'mousedown', (e) => {
@@ -1625,7 +1887,7 @@ class FableEditor {
1625
1887
  this.selectTableForResize(table);
1626
1888
  else
1627
1889
  this.clearTableHandles();
1628
- const ph = target.closest?.('.img-ph');
1890
+ const ph = target.closest?.('.img-ph:not(.vid-ph)');
1629
1891
  if (ph && this.ed.contains(ph) && !this.options.readonly) {
1630
1892
  e.preventDefault();
1631
1893
  this.ed.focus();
@@ -1634,6 +1896,15 @@ class FableEditor {
1634
1896
  else {
1635
1897
  this.clearImgPlaceholderSel();
1636
1898
  }
1899
+ const vph = target.closest?.('.vid-ph');
1900
+ if (vph && this.ed.contains(vph) && !this.options.readonly) {
1901
+ e.preventDefault();
1902
+ this.ed.focus();
1903
+ this.selectVphPlaceholder(vph);
1904
+ }
1905
+ else {
1906
+ this.clearVphPlaceholderSel();
1907
+ }
1637
1908
  const tpl = target.closest?.('.tpl');
1638
1909
  if (tpl && this.ed.contains(tpl) && !this.options.readonly)
1639
1910
  this.selectTemplate(tpl);
@@ -1647,6 +1918,22 @@ class FableEditor {
1647
1918
  else {
1648
1919
  this.clearImageHandles();
1649
1920
  }
1921
+ const vid = target.closest?.('video, .video-embed');
1922
+ if (vid && this.ed.contains(vid) && !this.options.readonly) {
1923
+ e.preventDefault();
1924
+ this.selectVideo(vid);
1925
+ }
1926
+ else {
1927
+ this.clearVidHandles();
1928
+ }
1929
+ const mathEl = target.closest?.('.math-fable');
1930
+ if (mathEl && this.ed.contains(mathEl) && !this.options.readonly) {
1931
+ e.preventDefault();
1932
+ this.selectMathEl(mathEl);
1933
+ }
1934
+ else {
1935
+ this.clearMathSel();
1936
+ }
1650
1937
  });
1651
1938
  this.on(this.ed, 'mouseover', (e) => {
1652
1939
  if (this.options.readonly)
@@ -1674,12 +1961,18 @@ class FableEditor {
1674
1961
  this.positionImageHandles();
1675
1962
  this.positionImgPhCtx();
1676
1963
  this.positionTplCtx();
1964
+ this.positionVphCtx();
1965
+ this.positionVidCtx();
1966
+ this.positionMathCtx();
1677
1967
  });
1678
1968
  this.onWin('scroll', () => {
1679
1969
  this.positionTableHandles();
1680
1970
  this.positionImageHandles();
1681
1971
  this.positionImgPhCtx();
1682
1972
  this.positionTplCtx();
1973
+ this.positionVphCtx();
1974
+ this.positionVidCtx();
1975
+ this.positionMathCtx();
1683
1976
  this.repositionSelToolbar();
1684
1977
  }, true);
1685
1978
  this.onWin('resize', () => {
@@ -1687,6 +1980,9 @@ class FableEditor {
1687
1980
  this.positionImageHandles();
1688
1981
  this.positionImgPhCtx();
1689
1982
  this.positionTplCtx();
1983
+ this.positionVphCtx();
1984
+ this.positionVidCtx();
1985
+ this.positionMathCtx();
1690
1986
  this.repositionSelToolbar();
1691
1987
  });
1692
1988
  this.on(this.imgInput, 'change', () => {
@@ -1707,6 +2003,24 @@ class FableEditor {
1707
2003
  };
1708
2004
  fr.readAsDataURL(file);
1709
2005
  });
2006
+ this.on(this.vidInput, 'change', () => {
2007
+ const file = this.vidInput.files?.[0];
2008
+ if (!file)
2009
+ return;
2010
+ const vph = this.vphUploadTarget;
2011
+ this.vphUploadTarget = null;
2012
+ if (vph && this.ed.contains(vph)) {
2013
+ this.readVideoFileInto(file, vph);
2014
+ return;
2015
+ }
2016
+ const fr = new FileReader();
2017
+ fr.onload = () => {
2018
+ this.restoreSel();
2019
+ document.execCommand('insertHTML', false, `<video controls src="${fr.result}" title="${file.name.replace(/"/g, '')}"></video>`);
2020
+ this.onChange();
2021
+ };
2022
+ fr.readAsDataURL(file);
2023
+ });
1710
2024
  this.on(this.docInput, 'change', () => {
1711
2025
  const file = this.docInput.files?.[0];
1712
2026
  if (!file)
@@ -1723,6 +2037,9 @@ class FableEditor {
1723
2037
  (this.phCtx && this.phCtx.contains(e.target)) ||
1724
2038
  (this.tplCtx && this.tplCtx.contains(e.target)) ||
1725
2039
  (this.selCtx && this.selCtx.contains(e.target)) ||
2040
+ (this.vphCtx && this.vphCtx.contains(e.target)) ||
2041
+ (this.vidCtx && this.vidCtx.contains(e.target)) ||
2042
+ (this.mathCtx && this.mathCtx.contains(e.target)) ||
1726
2043
  (this.openPop && this.openPop.contains(e.target)) ||
1727
2044
  (this.openSubEl && this.openSubEl.contains(e.target)))
1728
2045
  return;
@@ -1730,6 +2047,9 @@ class FableEditor {
1730
2047
  this.clearImageHandles();
1731
2048
  this.clearImgPlaceholderSel();
1732
2049
  this.clearTplSel();
2050
+ this.clearVphPlaceholderSel();
2051
+ this.clearVidHandles();
2052
+ this.clearMathSel();
1733
2053
  this.clearSelToolbar();
1734
2054
  });
1735
2055
  }
@@ -1756,6 +2076,9 @@ class FableEditor {
1756
2076
  this.clearImageHandles();
1757
2077
  this.clearImgPlaceholderSel();
1758
2078
  this.clearTplSel();
2079
+ this.clearVphPlaceholderSel();
2080
+ this.clearVidHandles();
2081
+ this.clearMathSel();
1759
2082
  this.clearSelToolbar();
1760
2083
  this.onChange();
1761
2084
  }
@@ -1818,6 +2141,9 @@ class FableEditor {
1818
2141
  this.clearImageHandles();
1819
2142
  this.clearImgPlaceholderSel();
1820
2143
  this.clearTplSel();
2144
+ this.clearVphPlaceholderSel();
2145
+ this.clearVidHandles();
2146
+ this.clearMathSel();
1821
2147
  this.clearSelToolbar();
1822
2148
  this.listeners.forEach((fn) => fn());
1823
2149
  this.listeners = [];
@@ -1825,6 +2151,8 @@ class FableEditor {
1825
2151
  this.shell.parentNode.removeChild(this.shell);
1826
2152
  if (this.imgInput && this.imgInput.parentNode)
1827
2153
  this.imgInput.parentNode.removeChild(this.imgInput);
2154
+ if (this.vidInput && this.vidInput.parentNode)
2155
+ this.vidInput.parentNode.removeChild(this.vidInput);
1828
2156
  if (this.docInput && this.docInput.parentNode)
1829
2157
  this.docInput.parentNode.removeChild(this.docInput);
1830
2158
  this.contentStyleEl = null;
@@ -2626,7 +2954,7 @@ class FableEditor {
2626
2954
  * (the quick color chips); everything else yields one. */
2627
2955
  buildToolbarRegistry() {
2628
2956
  const tableBtn = this.tbtn(IC.tableic, this.t('quicktable'), () => this.tableGrid(tableBtn));
2629
- return {
2957
+ const registry = {
2630
2958
  undo: () => this.tbtn(IC.undo, this.t('undo'), () => this.exec('undo')),
2631
2959
  redo: () => this.tbtn(IC.redo, this.t('redo'), () => this.exec('redo')),
2632
2960
  preview: () => this.tbtn(IC.prevw, this.t('preview'), () => this.previewDlg()),
@@ -2661,12 +2989,22 @@ class FableEditor {
2661
2989
  ltr: () => this.tbtn(IC.ltr, this.t('ltr'), () => this.setDir('ltr'), 'ltr'),
2662
2990
  rtl: () => this.tbtn(IC.rtl, this.t('rtl'), () => this.setDir('rtl'), 'rtl'),
2663
2991
  quickimage: () => this.tbtn(IC.image, this.t('quickimage'), () => this.insertImagePlaceholder()),
2992
+ quickvideo: () => this.tbtn(IC.video, this.t('quickvideo'), () => this.videoDlg()),
2993
+ mathformula: () => this.tbtn(IC.mathic, this.t('mathformula'), () => this.mathDlg()),
2664
2994
  quicktable: () => tableBtn,
2665
2995
  template: () => this.menuTbtn(IC.templateic, this.t('template'), (b) => this.templateMenu(b)),
2666
2996
  charmap: () => this.tbtn(IC.charic, this.t('charmap'), () => this.charMap()),
2997
+ emoji: () => this.tbtn(IC.emojiic, this.t('emoji'), () => this.emojiMap()),
2667
2998
  fullscreen: () => this.tbtn(IC.fullscreen, this.t('fullscreen'), () => this.toggleFullscreen(), 'fullscreen'),
2668
2999
  sourcecode: () => this.tbtn(IC.srcic, this.t('sourcecode'), () => this.sourceDlg())
2669
3000
  };
3001
+ /* TinyMCE-compatible aliases so common TinyMCE toolbar strings
3002
+ (e.g. "undo redo | styles | ... | link image media") work as-is */
3003
+ registry.styles = registry.blocks;
3004
+ registry.image = registry.quickimage;
3005
+ registry.media = registry.quickvideo;
3006
+ registry.table = registry.quicktable;
3007
+ return registry;
2670
3008
  }
2671
3009
  buildToolbar() {
2672
3010
  this.toolbar.innerHTML = '';
@@ -2733,11 +3071,14 @@ class FableEditor {
2733
3071
  insert: [
2734
3072
  this.mItem('link', IC.link, () => this.linkDlg()),
2735
3073
  this.mItem('image', IC.image, () => this.insertImagePlaceholder()),
3074
+ this.mItem('video', IC.video, () => this.videoDlg()),
3075
+ this.mItem('mathformula', IC.mathic, () => this.mathDlg()),
2736
3076
  { label: this.t('inserttable'), icon: IC.tableic, action: () => this.tableGrid(this.menubar.querySelector('[data-menu-key="table"]') || this.menubar) },
2737
3077
  { label: this.t('template'), icon: IC.templateic, subBuild: (el) => this.buildTemplatePickInto(el) },
2738
3078
  this.mItem('hr', IC.hric, () => this.exec('insertHorizontalRule')),
2739
3079
  '|',
2740
3080
  this.mItem('charmap', IC.charic, () => this.charMap()),
3081
+ this.mItem('emoji', IC.emojiic, () => this.emojiMap()),
2741
3082
  this.mItem('datetime', IC.dateic, () => this.exec('insertText', new Date().toLocaleString(this.lang === 'ar' ? 'ar-AE' : 'en-GB'))),
2742
3083
  '|',
2743
3084
  {
@@ -3308,22 +3649,53 @@ class FableEditor {
3308
3649
  body.appendChild(box);
3309
3650
  });
3310
3651
  }
3311
- charMap() {
3312
- this.dialog(this.t('charmap').replace('…', ''), (body) => {
3313
- const g = document.createElement('div');
3314
- g.className = 'chgrid';
3315
- CHARS.forEach((ch) => {
3652
+ /** Two-pane picker shared by the special-character map and the emoji map:
3653
+ * fixed category tabs on the left, the active category's glyphs on the right. */
3654
+ glyphPickerDlg(title, cats, emoji) {
3655
+ this.saveSel();
3656
+ this.dialog(title, (body) => {
3657
+ const wrap = document.createElement('div');
3658
+ wrap.className = 'chmap';
3659
+ const tabs = document.createElement('div');
3660
+ tabs.className = 'chtabs';
3661
+ const panel = document.createElement('div');
3662
+ panel.className = 'chpanel';
3663
+ const grid = document.createElement('div');
3664
+ grid.className = 'chgrid' + (emoji ? ' emgrid' : '');
3665
+ panel.appendChild(grid);
3666
+ const show = (i) => {
3667
+ tabs.querySelectorAll('button').forEach((b, bi) => b.classList.toggle('on', bi === i));
3668
+ grid.innerHTML = '';
3669
+ cats[i].chars.forEach((ch) => {
3670
+ const b = document.createElement('button');
3671
+ b.type = 'button';
3672
+ b.textContent = ch;
3673
+ b.onclick = () => {
3674
+ this.closeDlg();
3675
+ this.exec('insertText', ch);
3676
+ };
3677
+ grid.appendChild(b);
3678
+ });
3679
+ panel.scrollTop = 0;
3680
+ };
3681
+ cats.forEach((c, i) => {
3316
3682
  const b = document.createElement('button');
3317
- b.textContent = ch;
3318
- b.onclick = () => {
3319
- this.closeDlg();
3320
- this.exec('insertText', ch);
3321
- };
3322
- g.appendChild(b);
3683
+ b.type = 'button';
3684
+ b.textContent = this.t(c.key);
3685
+ b.addEventListener('click', () => show(i));
3686
+ tabs.appendChild(b);
3323
3687
  });
3324
- body.appendChild(g);
3688
+ wrap.append(tabs, panel);
3689
+ body.appendChild(wrap);
3690
+ show(0);
3325
3691
  });
3326
3692
  }
3693
+ charMap() {
3694
+ this.glyphPickerDlg(this.t('charmap').replace('…', ''), CHAR_CATEGORIES, false);
3695
+ }
3696
+ emojiMap() {
3697
+ this.glyphPickerDlg(this.t('emoji').replace('…', ''), EMOJI_CATEGORIES, true);
3698
+ }
3327
3699
  printDoc() {
3328
3700
  const w = window.open('', '_blank');
3329
3701
  if (!w)
@@ -3389,9 +3761,34 @@ class FableEditor {
3389
3761
  el.append(this.ctxBtn(IC.uploadic, this.t('uploadimg'), () => {
3390
3762
  this.phUploadTarget = this.phActive;
3391
3763
  this.pickImage();
3392
- }), this.ctxBtn(IC.link, this.t('imagelink'), () => this.renderPhCtxUrlInput()), this.ctxSep(), this.ctxBtn(IC.trash, this.t('deleteimg'), () => this.removeImgPlaceholder()));
3764
+ }), this.ctxBtn(IC.link, this.t('imagelink'), () => this.renderPhCtxUrlInput()));
3765
+ /* a template's media slot is image-first, but can host a video instead */
3766
+ if (this.phActive?.closest('.tpl-media')) {
3767
+ el.append(this.ctxBtn(IC.video, this.t('quickvideo'), () => this.swapPlaceholderKind('video')));
3768
+ }
3769
+ el.append(this.ctxSep(), this.ctxBtn(IC.trash, this.t('deleteimg'), () => this.removeImgPlaceholder()));
3393
3770
  this.positionImgPhCtx();
3394
3771
  }
3772
+ /** Swaps a template slot's placeholder between image and video kinds, so the
3773
+ * same media slot can be filled with either. */
3774
+ swapPlaceholderKind(to) {
3775
+ const ph = to === 'video' ? this.phActive : this.vphActive;
3776
+ if (!ph)
3777
+ return;
3778
+ const tmp = document.createElement('div');
3779
+ tmp.innerHTML = to === 'video' ? this.vidPhHTML() : this.imgPhHTML();
3780
+ const next = tmp.firstElementChild;
3781
+ if (to === 'video')
3782
+ this.clearImgPlaceholderSel();
3783
+ else
3784
+ this.clearVphPlaceholderSel();
3785
+ ph.replaceWith(next);
3786
+ if (to === 'video')
3787
+ this.selectVphPlaceholder(next);
3788
+ else
3789
+ this.selectImgPlaceholder(next);
3790
+ this.onChange();
3791
+ }
3395
3792
  renderPhCtxUrlInput() {
3396
3793
  const el = this.phCtx;
3397
3794
  if (!el)
@@ -3514,6 +3911,696 @@ class FableEditor {
3514
3911
  this.phCtx.style.left = cx + 'px';
3515
3912
  this.phCtx.style.top = cy + 'px';
3516
3913
  }
3914
+ /* ---------------------------------------------------------- video upload placeholder */
3915
+ pickVideo() {
3916
+ this.vidInput.value = '';
3917
+ this.vidInput.click();
3918
+ }
3919
+ vidPhHTML(id) {
3920
+ return `<div class="img-ph vid-ph"${id ? ` id="${id}"` : ''} contenteditable="false">${IC.video}<span>${this.t('dropvideo')}</span></div>`;
3921
+ }
3922
+ insertVideoPlaceholder() {
3923
+ this.restoreSel();
3924
+ const id = 'vid-ph-' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
3925
+ document.execCommand('insertHTML', false, this.vidPhHTML(id));
3926
+ const ph = this.ed.querySelector('#' + id);
3927
+ if (ph) {
3928
+ ph.removeAttribute('id');
3929
+ this.selectVphPlaceholder(ph);
3930
+ }
3931
+ this.onChange();
3932
+ }
3933
+ clearVphPlaceholderSel() {
3934
+ this.vphCtx?.remove();
3935
+ this.vphCtx = null;
3936
+ this.vphActive?.classList.remove('active');
3937
+ this.vphActive = null;
3938
+ }
3939
+ removeVphPlaceholder() {
3940
+ const ph = this.vphActive;
3941
+ if (!ph)
3942
+ return;
3943
+ this.clearVphPlaceholderSel();
3944
+ /* like the image variant: a placeholder inside a template block IS the
3945
+ media slot — deleting it drops the slot, leaving a text-only block */
3946
+ (ph.closest('.tpl-media') || ph).remove();
3947
+ this.refreshState();
3948
+ this.onChange();
3949
+ }
3950
+ selectVphPlaceholder(ph) {
3951
+ if (this.vphActive === ph)
3952
+ return;
3953
+ this.clearVphPlaceholderSel();
3954
+ this.vphActive = ph;
3955
+ ph.classList.add('active');
3956
+ this.vphCtx = document.createElement('div');
3957
+ this.vphCtx.className = 'imgctx img-ph-ctx';
3958
+ this.vphCtx.dir = this.dir();
3959
+ document.body.appendChild(this.vphCtx);
3960
+ this.renderVphCtxButtons();
3961
+ }
3962
+ renderVphCtxButtons() {
3963
+ const el = this.vphCtx;
3964
+ if (!el)
3965
+ return;
3966
+ el.innerHTML = '';
3967
+ el.append(this.ctxBtn(IC.uploadic, this.t('uploadvideo'), () => {
3968
+ this.vphUploadTarget = this.vphActive;
3969
+ this.pickVideo();
3970
+ }), this.ctxBtn(IC.link, this.t('videolink'), () => this.renderVphCtxUrlInput()));
3971
+ /* inside a template slot, offer switching the slot back to an image */
3972
+ if (this.vphActive?.closest('.tpl-media')) {
3973
+ el.append(this.ctxBtn(IC.image, this.t('quickimage'), () => this.swapPlaceholderKind('image')));
3974
+ }
3975
+ el.append(this.ctxSep(), this.ctxBtn(IC.trash, this.t('deletevideo'), () => this.removeVphPlaceholder()));
3976
+ this.positionVphCtx();
3977
+ }
3978
+ renderVphCtxUrlInput() {
3979
+ const el = this.vphCtx;
3980
+ if (!el)
3981
+ return;
3982
+ el.innerHTML = '';
3983
+ const inp = document.createElement('input');
3984
+ inp.type = 'url';
3985
+ inp.className = 'urlinp';
3986
+ inp.placeholder = this.t('videourlph');
3987
+ const ok = document.createElement('button');
3988
+ ok.type = 'button';
3989
+ ok.className = 'txtbtn';
3990
+ ok.textContent = this.t('insertvideo');
3991
+ const submit = () => {
3992
+ const url = inp.value.trim();
3993
+ const ph = this.vphActive;
3994
+ if (!ph)
3995
+ return;
3996
+ if (!/^(https?:|data:video\/|blob:)/i.test(url)) {
3997
+ inp.classList.add('err');
3998
+ inp.focus();
3999
+ return;
4000
+ }
4001
+ /* a page URL from a known video host can't play in a <video> tag —
4002
+ swap in the host's iframe player instead of probing it */
4003
+ const embed = this.videoEmbedUrl(url);
4004
+ if (embed) {
4005
+ this.replacePlaceholderWithEmbed(ph, embed);
4006
+ return;
4007
+ }
4008
+ ok.disabled = true;
4009
+ inp.disabled = true;
4010
+ /* only place the video once its metadata actually loads, so the user
4011
+ gets a working preview instead of a broken player */
4012
+ const probe = document.createElement('video');
4013
+ probe.preload = 'metadata';
4014
+ probe.onloadedmetadata = () => this.replacePlaceholderWithVideo(ph, url, '');
4015
+ probe.onerror = () => {
4016
+ ok.disabled = false;
4017
+ inp.disabled = false;
4018
+ inp.classList.add('err');
4019
+ inp.focus();
4020
+ };
4021
+ probe.src = url;
4022
+ };
4023
+ ok.addEventListener('click', submit);
4024
+ inp.addEventListener('input', () => inp.classList.remove('err'));
4025
+ inp.addEventListener('keydown', (e) => {
4026
+ if (e.key === 'Enter') {
4027
+ e.preventDefault();
4028
+ submit();
4029
+ }
4030
+ else if (e.key === 'Escape') {
4031
+ e.stopPropagation();
4032
+ this.renderVphCtxButtons();
4033
+ }
4034
+ });
4035
+ el.append(inp, ok);
4036
+ this.positionVphCtx();
4037
+ inp.focus();
4038
+ }
4039
+ readVideoFileInto(file, ph) {
4040
+ if (ph.classList.contains('uploading'))
4041
+ return;
4042
+ if (this.videoUploadHandler) {
4043
+ this.uploadVideoFile(file, ph);
4044
+ return;
4045
+ }
4046
+ const fr = new FileReader();
4047
+ fr.onload = () => {
4048
+ if (this.ed.contains(ph))
4049
+ this.replacePlaceholderWithVideo(ph, fr.result, file.name.replace(/"/g, ''));
4050
+ };
4051
+ fr.readAsDataURL(file);
4052
+ }
4053
+ /** Runs the host-supplied videoUploadHandler instead of inlining base64,
4054
+ * so apps can route the file to S3 / Azure Blob / their own server. */
4055
+ uploadVideoFile(file, ph) {
4056
+ const title = file.name.replace(/"/g, '');
4057
+ ph.classList.remove('upload-error');
4058
+ ph.classList.add('uploading');
4059
+ const span = ph.querySelector('span');
4060
+ if (span)
4061
+ span.textContent = this.t('uploading');
4062
+ this.videoUploadHandler(file)
4063
+ .then((url) => {
4064
+ if (!this.ed.contains(ph))
4065
+ return;
4066
+ this.replacePlaceholderWithVideo(ph, url, title);
4067
+ })
4068
+ .catch((err) => {
4069
+ if (!this.ed.contains(ph))
4070
+ return;
4071
+ ph.classList.remove('uploading');
4072
+ ph.classList.add('upload-error');
4073
+ if (span)
4074
+ span.textContent = this.t('uploadfailed');
4075
+ this.onVideoUploadError?.(err, file);
4076
+ });
4077
+ }
4078
+ replacePlaceholderWithVideo(ph, src, title) {
4079
+ const vid = document.createElement('video');
4080
+ vid.controls = true;
4081
+ vid.src = src;
4082
+ if (title)
4083
+ vid.title = title;
4084
+ ph.replaceWith(vid);
4085
+ if (this.vphActive === ph)
4086
+ this.clearVphPlaceholderSel();
4087
+ this.refreshState();
4088
+ this.onChange();
4089
+ }
4090
+ /** Swaps a video placeholder for a hosted player iframe (YouTube & co.). */
4091
+ replacePlaceholderWithEmbed(ph, embedUrl) {
4092
+ const tmp = document.createElement('div');
4093
+ tmp.innerHTML = this.videoEmbedHTML(embedUrl);
4094
+ ph.replaceWith(tmp.firstElementChild);
4095
+ if (this.vphActive === ph)
4096
+ this.clearVphPlaceholderSel();
4097
+ this.refreshState();
4098
+ this.onChange();
4099
+ }
4100
+ positionVphCtx() {
4101
+ if (this.vphActive && !document.body.contains(this.vphActive)) {
4102
+ this.clearVphPlaceholderSel();
4103
+ return;
4104
+ }
4105
+ if (!this.vphActive || !this.vphCtx)
4106
+ return;
4107
+ const r = this.vphActive.getBoundingClientRect();
4108
+ const cw = this.vphCtx.offsetWidth;
4109
+ const ch = this.vphCtx.offsetHeight;
4110
+ let cx = r.left + scrollX + (r.width - cw) / 2;
4111
+ cx = Math.max(8 + scrollX, Math.min(cx, scrollX + innerWidth - cw - 8));
4112
+ let cy = r.bottom + scrollY + 8;
4113
+ if (r.bottom + 8 + ch > innerHeight - 4)
4114
+ cy = Math.max(this.ctxMinTop(), r.top + scrollY - ch - 8);
4115
+ this.vphCtx.style.left = cx + 'px';
4116
+ this.vphCtx.style.top = cy + 'px';
4117
+ }
4118
+ /* ---------------------------------------------------------- video toolbar (post-insert) */
4119
+ cancelVidHide() {
4120
+ if (this.vidHideTimer != null) {
4121
+ window.clearTimeout(this.vidHideTimer);
4122
+ this.vidHideTimer = null;
4123
+ }
4124
+ }
4125
+ clearVidHandles() {
4126
+ this.cancelVidHide();
4127
+ this.vidCtx?.remove();
4128
+ this.vidCtx = null;
4129
+ this.vidActive?.classList.remove('vid-selected');
4130
+ this.vidActive = null;
4131
+ }
4132
+ selectVideo(vid) {
4133
+ if (this.vidActive === vid)
4134
+ return;
4135
+ this.clearVidHandles();
4136
+ this.vidActive = vid;
4137
+ vid.classList.add('vid-selected');
4138
+ this.vidCtx = this.buildVideoCtxToolbar(vid);
4139
+ this.positionVidCtx();
4140
+ }
4141
+ buildVideoCtxToolbar(vid) {
4142
+ const el = document.createElement('div');
4143
+ el.className = 'imgctx';
4144
+ el.dir = this.dir();
4145
+ const rebuild = () => {
4146
+ const rebuilt = this.buildVideoCtxToolbar(vid);
4147
+ this.vidCtx?.replaceWith(rebuilt);
4148
+ this.vidCtx = rebuilt;
4149
+ this.positionVidCtx();
4150
+ };
4151
+ const align = (a) => {
4152
+ this.setVideoAlign(vid, a);
4153
+ rebuild();
4154
+ };
4155
+ const isLeft = vid.style.float === 'left';
4156
+ const isRight = vid.style.float === 'right';
4157
+ const isCenter = !isLeft && !isRight && vid.style.marginLeft === 'auto' && vid.style.marginRight === 'auto';
4158
+ const mkBtn = (icon, tip, on, fn) => {
4159
+ const b = this.ctxBtn(icon, tip, fn);
4160
+ b.classList.toggle('on', on);
4161
+ return b;
4162
+ };
4163
+ el.append(mkBtn(IC.alignleft, this.t('alignleft'), isLeft, () => align('left')), mkBtn(IC.aligncenter, this.t('aligncenter'), isCenter, () => align('center')), mkBtn(IC.alignright, this.t('alignright'), isRight, () => align('right')), this.ctxSep(), mkBtn(IC.trash, this.t('deletevideo'), false, () => {
4164
+ vid.remove();
4165
+ this.clearVidHandles();
4166
+ this.onChange();
4167
+ }));
4168
+ el.addEventListener('mousedown', (e) => e.preventDefault());
4169
+ el.addEventListener('mouseenter', () => this.cancelVidHide());
4170
+ el.addEventListener('mouseleave', () => {
4171
+ this.cancelVidHide();
4172
+ this.vidHideTimer = window.setTimeout(() => this.clearVidHandles(), 250);
4173
+ });
4174
+ document.body.appendChild(el);
4175
+ return el;
4176
+ }
4177
+ setVideoAlign(vid, align) {
4178
+ vid.style.float = '';
4179
+ vid.style.display = '';
4180
+ vid.style.marginLeft = '';
4181
+ vid.style.marginRight = '';
4182
+ vid.style.marginBottom = '';
4183
+ if (align === 'left') {
4184
+ vid.style.float = 'left';
4185
+ vid.style.marginInlineEnd = '1em';
4186
+ vid.style.marginBottom = '0.5em';
4187
+ }
4188
+ else if (align === 'right') {
4189
+ vid.style.float = 'right';
4190
+ vid.style.marginInlineStart = '1em';
4191
+ vid.style.marginBottom = '0.5em';
4192
+ }
4193
+ else {
4194
+ vid.style.display = 'block';
4195
+ vid.style.marginLeft = 'auto';
4196
+ vid.style.marginRight = 'auto';
4197
+ }
4198
+ this.onChange();
4199
+ }
4200
+ positionVidCtx() {
4201
+ if (!this.vidActive || !document.body.contains(this.vidActive)) {
4202
+ this.clearVidHandles();
4203
+ return;
4204
+ }
4205
+ const r = this.vidActive.getBoundingClientRect();
4206
+ if (!this.vidCtx)
4207
+ return;
4208
+ const cw = this.vidCtx.offsetWidth;
4209
+ const ch = this.vidCtx.offsetHeight;
4210
+ let cx = r.left + scrollX + (r.width - cw) / 2;
4211
+ cx = Math.max(8 + scrollX, Math.min(cx, scrollX + innerWidth - cw - 8));
4212
+ let cy = r.top + scrollY - ch - 8;
4213
+ if (cy < this.ctxMinTop())
4214
+ cy = r.bottom + scrollY + 8;
4215
+ this.vidCtx.style.left = cx + 'px';
4216
+ this.vidCtx.style.top = cy + 'px';
4217
+ }
4218
+ /* ---------------------------------------------------------- video dialog / embeds */
4219
+ /** Maps a page URL from a known video host to its iframe player URL. Returns
4220
+ * null for anything else (e.g. direct .mp4 file URLs). */
4221
+ videoEmbedUrl(url) {
4222
+ let m = url.match(/^https?:\/\/(?:www\.|m\.)?youtube\.com\/watch\?(?:[^\s#]*[?&])?v=([\w-]{5,})/i);
4223
+ if (!m)
4224
+ m = url.match(/^https?:\/\/(?:www\.)?(?:youtu\.be|youtube\.com\/(?:embed|shorts|live))\/([\w-]{5,})/i);
4225
+ if (m)
4226
+ return 'https://www.youtube.com/embed/' + m[1];
4227
+ m = url.match(/^https?:\/\/(?:www\.)?vimeo\.com\/(\d+)/i);
4228
+ if (m)
4229
+ return 'https://player.vimeo.com/video/' + m[1];
4230
+ m = url.match(/^https?:\/\/(?:www\.)?dailymotion\.com\/video\/(\w+)/i);
4231
+ if (m)
4232
+ return 'https://www.dailymotion.com/embed/video/' + m[1];
4233
+ return null;
4234
+ }
4235
+ videoEmbedHTML(src, w = 560, h = 314) {
4236
+ return `<span class="video-embed" contenteditable="false"><iframe src="${src}" width="${w}" height="${h}" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe></span>`;
4237
+ }
4238
+ /** Insert/edit-video dialog with General (source/width/height), Embed (embed
4239
+ * code) and Advanced (alternative source, poster image) tabs. Hosted page
4240
+ * URLs (YouTube & co.) become iframe players; anything else becomes a
4241
+ * <video> tag. The General tab also offers the same upload path as before
4242
+ * (videoUploadHandler when configured, else inline base64). */
4243
+ videoDlg() {
4244
+ this.saveSel();
4245
+ let srcInp;
4246
+ let wInp;
4247
+ let hInp;
4248
+ let embedTa;
4249
+ let altInp;
4250
+ let posterInp;
4251
+ const field = (labelKey, input) => {
4252
+ const f = document.createElement('div');
4253
+ f.className = 'dfield';
4254
+ const l = document.createElement('label');
4255
+ l.textContent = this.t(labelKey);
4256
+ f.append(l, input);
4257
+ return f;
4258
+ };
4259
+ const urlInput = () => {
4260
+ const i = document.createElement('input');
4261
+ i.type = 'url';
4262
+ return i;
4263
+ };
4264
+ this.dialog(this.t('videodlgttl'), (body) => {
4265
+ const tabs = document.createElement('div');
4266
+ tabs.className = 'dtabs';
4267
+ const panels = [];
4268
+ const mkTab = (labelKey, build) => {
4269
+ const b = document.createElement('button');
4270
+ b.type = 'button';
4271
+ b.textContent = this.t(labelKey);
4272
+ const p = document.createElement('div');
4273
+ p.className = 'dtabpanel';
4274
+ build(p);
4275
+ const i = panels.length;
4276
+ panels.push(p);
4277
+ b.addEventListener('click', () => show(i));
4278
+ tabs.appendChild(b);
4279
+ };
4280
+ const show = (i) => {
4281
+ tabs.querySelectorAll('button').forEach((x, xi) => x.classList.toggle('on', xi === i));
4282
+ panels.forEach((x, xi) => (x.style.display = xi === i ? '' : 'none'));
4283
+ };
4284
+ mkTab('vidgeneral', (p) => {
4285
+ srcInp = urlInput();
4286
+ srcInp.placeholder = this.t('videourlph');
4287
+ const srcRow = document.createElement('div');
4288
+ srcRow.className = 'drow';
4289
+ const upBtn = document.createElement('button');
4290
+ upBtn.type = 'button';
4291
+ upBtn.className = 'txtbtn';
4292
+ upBtn.textContent = this.t('uploadvideo');
4293
+ const file = document.createElement('input');
4294
+ file.type = 'file';
4295
+ file.accept = this.options.videoFileTypes.join(',');
4296
+ file.style.display = 'none';
4297
+ upBtn.addEventListener('click', () => {
4298
+ file.value = '';
4299
+ file.click();
4300
+ });
4301
+ file.addEventListener('change', () => {
4302
+ const f = file.files?.[0];
4303
+ if (!f)
4304
+ return;
4305
+ upBtn.disabled = true;
4306
+ upBtn.textContent = this.t('uploading');
4307
+ const done = (url) => {
4308
+ srcInp.value = url;
4309
+ upBtn.disabled = false;
4310
+ upBtn.textContent = this.t('uploadvideo');
4311
+ };
4312
+ if (this.videoUploadHandler) {
4313
+ this.videoUploadHandler(f)
4314
+ .then(done)
4315
+ .catch((err) => {
4316
+ upBtn.disabled = false;
4317
+ upBtn.textContent = this.t('uploadfailed');
4318
+ this.onVideoUploadError?.(err, f);
4319
+ });
4320
+ }
4321
+ else {
4322
+ const fr = new FileReader();
4323
+ fr.onload = () => done(fr.result);
4324
+ fr.readAsDataURL(f);
4325
+ }
4326
+ });
4327
+ srcRow.append(srcInp, upBtn, file);
4328
+ srcInp.style.flex = '1';
4329
+ p.appendChild(field('vidsource', srcRow));
4330
+ const dims = document.createElement('div');
4331
+ dims.className = 'drow';
4332
+ wInp = document.createElement('input');
4333
+ wInp.type = 'number';
4334
+ wInp.value = '560';
4335
+ hInp = document.createElement('input');
4336
+ hInp.type = 'number';
4337
+ hInp.value = '314';
4338
+ dims.append(field('tblwidth', wInp), field('tblheight', hInp));
4339
+ p.appendChild(dims);
4340
+ });
4341
+ mkTab('vidembed', (p) => {
4342
+ const hint = document.createElement('p');
4343
+ hint.textContent = this.t('vidembedhint');
4344
+ hint.style.cssText = 'margin:0 0 8px;color:#556;font-size:13.5px';
4345
+ embedTa = document.createElement('textarea');
4346
+ embedTa.style.cssText = 'width:100%;box-sizing:border-box;height:160px';
4347
+ p.append(hint, embedTa);
4348
+ });
4349
+ mkTab('vidadvanced', (p) => {
4350
+ altInp = urlInput();
4351
+ posterInp = urlInput();
4352
+ p.append(field('vidaltsource', altInp), field('vidposter', posterInp));
4353
+ });
4354
+ const wrap = document.createElement('div');
4355
+ wrap.className = 'dtabbody';
4356
+ panels.forEach((p) => wrap.appendChild(p));
4357
+ body.append(tabs, wrap);
4358
+ show(0);
4359
+ setTimeout(() => srcInp.focus(), 0);
4360
+ }, [
4361
+ { label: this.t('cancel'), action: () => this.closeDlg() },
4362
+ {
4363
+ label: this.t('save'),
4364
+ primary: true,
4365
+ action: () => {
4366
+ const embedCode = embedTa.value.trim();
4367
+ const src = srcInp.value.trim();
4368
+ const w = parseInt(wInp.value, 10) || 560;
4369
+ const h = parseInt(hInp.value, 10) || 314;
4370
+ this.closeDlg();
4371
+ if (!embedCode && !src)
4372
+ return;
4373
+ this.restoreSel();
4374
+ if (embedCode) {
4375
+ document.execCommand('insertHTML', false, `<span class="video-embed" contenteditable="false">${embedCode}</span>`);
4376
+ this.onChange();
4377
+ return;
4378
+ }
4379
+ const embed = this.videoEmbedUrl(src);
4380
+ if (embed) {
4381
+ document.execCommand('insertHTML', false, this.videoEmbedHTML(embed, w, h));
4382
+ }
4383
+ else {
4384
+ const alt = altInp.value.trim();
4385
+ const poster = posterInp.value.trim();
4386
+ document.execCommand('insertHTML', false, `<video controls width="${w}" height="${h}"${poster ? ` poster="${this.escapeAttr(poster)}"` : ''}>` +
4387
+ `<source src="${this.escapeAttr(src)}">${alt ? `<source src="${this.escapeAttr(alt)}">` : ''}</video>`);
4388
+ }
4389
+ this.onChange();
4390
+ }
4391
+ }
4392
+ ]);
4393
+ }
4394
+ /* ---------------------------------------------------------- math / LaTeX formulas */
4395
+ renderMathHTML(latex, block) {
4396
+ try {
4397
+ return katex.renderToString(latex, { throwOnError: false, displayMode: block, strict: 'ignore' });
4398
+ }
4399
+ catch {
4400
+ return '';
4401
+ }
4402
+ }
4403
+ /** Shared by the math dialog, math paste, and math typing: renders `src` with
4404
+ * KaTeX and inserts it at the caret as the standard math element (inline span /
4405
+ * block div carrying its LaTeX source in data-latex). Block formulas get the
4406
+ * same \begin{aligned} wrapping the dialog applies, so pasted derivations align
4407
+ * their steps and re-open correctly in the edit dialog. */
4408
+ insertMathElement(src, block) {
4409
+ const latex = block ? `\\begin{aligned}${src}\\end{aligned}` : src;
4410
+ const rendered = this.renderMathHTML(latex, block);
4411
+ const tag = block ? 'div' : 'span';
4412
+ document.execCommand('insertHTML', false, `<${tag} class="math-fable${block ? ' math-fable-block' : ''}" contenteditable="false" data-latex="${this.escapeAttr(src)}">${rendered}</${tag}>`);
4413
+ }
4414
+ /** Recognizes a plain-text clipboard payload that is entirely one math formula:
4415
+ * $$…$$ / \[…\] (block), \(…\) (inline), a \begin{…}…\end{…} environment (block),
4416
+ * or $…$ (inline — only when the inside looks like LaTeX, so "$50" stays text).
4417
+ * Multi-line block content (a derivation pasted as separate lines) gets its lines
4418
+ * joined with \\ so each step lands on its own line. Returns null for anything
4419
+ * else, letting the normal paste pipeline run unchanged. */
4420
+ mathFromClipboard(text) {
4421
+ const t = text.trim();
4422
+ if (!t)
4423
+ return null;
4424
+ const block = t.match(/^\$\$([\s\S]+)\$\$$/) || t.match(/^\\\[([\s\S]+)\\\]$/);
4425
+ if (block) {
4426
+ const src = block[1].trim();
4427
+ if (!src || src.includes('$'))
4428
+ return null;
4429
+ return { latex: this.joinDerivationLines(src), block: true };
4430
+ }
4431
+ if (/^\\begin\{[a-zA-Z*]+\}[\s\S]+\\end\{[a-zA-Z*]+\}$/.test(t)) {
4432
+ return { latex: this.joinDerivationLines(t), block: true };
4433
+ }
4434
+ const inline = t.match(/^\\\(([\s\S]+)\\\)$/) || t.match(/^\$([^$\n]+)\$$/);
4435
+ if (inline) {
4436
+ const src = inline[1].trim();
4437
+ if (!src || src.includes('$'))
4438
+ return null;
4439
+ if (t.startsWith('$') && !/[\\^_{}=]/.test(src))
4440
+ return null;
4441
+ return { latex: src, block: false };
4442
+ }
4443
+ return null;
4444
+ }
4445
+ /** A derivation pasted as separate lines has no explicit \\ row breaks — add them.
4446
+ * Content that already breaks rows itself (\\ present, or a \begin environment
4447
+ * managing its own layout) only has its newlines collapsed to spaces. */
4448
+ joinDerivationLines(src) {
4449
+ if (!/\n/.test(src))
4450
+ return src;
4451
+ if (/\\\\/.test(src) || /\\begin\{/.test(src))
4452
+ return src.replace(/\s*\n\s*/g, ' ');
4453
+ return src
4454
+ .split(/\n+/)
4455
+ .map((l) => l.trim())
4456
+ .filter(Boolean)
4457
+ .join(' \\\\ ');
4458
+ }
4459
+ /** Typing "$…$" converts to an inline formula the moment the closing "$" is typed,
4460
+ * so math can be written in place without opening the dialog. Only kicks in when
4461
+ * the run since the opening "$" parses as valid LaTeX and contains LaTeX-ish
4462
+ * syntax (\ ^ _ { } =) — a literal "$5 and 3$" stays plain text. */
4463
+ tryMathTyping(e) {
4464
+ const sel = window.getSelection();
4465
+ if (!sel || !sel.isCollapsed || !sel.anchorNode)
4466
+ return;
4467
+ const node = sel.anchorNode;
4468
+ if (node.nodeType !== Node.TEXT_NODE || !this.ed.contains(node))
4469
+ return;
4470
+ if (node.parentElement?.closest('.math-fable, pre, code'))
4471
+ return;
4472
+ const before = (node.textContent || '').slice(0, sel.anchorOffset);
4473
+ const m = before.match(/(?:^|[^\\$])\$([^$]+)$/);
4474
+ if (!m)
4475
+ return;
4476
+ const src = m[1];
4477
+ if (/^\s|\s$/.test(src) || !/[\\^_{}=]/.test(src))
4478
+ return;
4479
+ const rendered = this.renderMathHTML(src, false);
4480
+ if (!rendered || rendered.includes('katex-error'))
4481
+ return;
4482
+ e.preventDefault();
4483
+ const range = document.createRange();
4484
+ range.setStart(node, sel.anchorOffset - src.length - 1);
4485
+ range.setEnd(node, sel.anchorOffset);
4486
+ sel.removeAllRanges();
4487
+ sel.addRange(range);
4488
+ this.insertMathElement(src, false);
4489
+ this.onChange();
4490
+ }
4491
+ mathDlg(existing) {
4492
+ this.saveSel();
4493
+ const isBlock = existing?.classList.contains('math-fable-block') ?? false;
4494
+ const initialLatex = existing?.dataset.latex ? this.unescapeAttr(existing.dataset.latex) : '';
4495
+ let ta;
4496
+ let derivChk;
4497
+ let preview;
4498
+ const updatePreview = () => {
4499
+ const src = ta.value || '';
4500
+ const block = derivChk.checked;
4501
+ const latex = block ? `\\begin{aligned}${src}\\end{aligned}` : src;
4502
+ preview.innerHTML = this.renderMathHTML(latex, block);
4503
+ };
4504
+ this.dialog(this.t('mathdlgttl'), (body) => {
4505
+ ta = document.createElement('textarea');
4506
+ ta.value = initialLatex;
4507
+ ta.placeholder = 'e.g. x = \\frac{-b \\pm \\sqrt{b^2-4ac}}{2a}';
4508
+ ta.style.cssText = 'width:360px;max-width:100%;min-height:80px;font-family:monospace;font-size:13px';
4509
+ const chkRow = document.createElement('label');
4510
+ chkRow.style.cssText = 'display:flex;align-items:center;gap:6px;margin:8px 0;font-size:13.5px;color:#445';
4511
+ derivChk = document.createElement('input');
4512
+ derivChk.type = 'checkbox';
4513
+ derivChk.checked = isBlock;
4514
+ chkRow.append(derivChk, document.createTextNode(this.t('mathderivation')));
4515
+ preview = document.createElement('div');
4516
+ preview.className = 'math-preview';
4517
+ preview.style.cssText = 'min-height:40px;margin-top:8px;padding:10px;border:1px solid #e1e5ea;border-radius:6px;overflow:auto';
4518
+ ta.addEventListener('input', updatePreview);
4519
+ derivChk.addEventListener('change', updatePreview);
4520
+ body.append(ta, chkRow, preview);
4521
+ setTimeout(() => {
4522
+ ta.focus();
4523
+ updatePreview();
4524
+ }, 0);
4525
+ }, [
4526
+ { label: this.t('cancel'), action: () => this.closeDlg() },
4527
+ {
4528
+ label: this.t('save'),
4529
+ primary: true,
4530
+ action: () => {
4531
+ const src = ta.value.trim();
4532
+ this.closeDlg();
4533
+ if (!src)
4534
+ return;
4535
+ const block = derivChk.checked;
4536
+ if (existing) {
4537
+ const latex = block ? `\\begin{aligned}${src}\\end{aligned}` : src;
4538
+ existing.className = block ? 'math-fable math-fable-block' : 'math-fable';
4539
+ existing.dataset.latex = this.escapeAttr(src);
4540
+ existing.innerHTML = this.renderMathHTML(latex, block);
4541
+ this.clearMathSel();
4542
+ this.onChange();
4543
+ return;
4544
+ }
4545
+ this.restoreSel();
4546
+ this.insertMathElement(src, block);
4547
+ this.onChange();
4548
+ }
4549
+ }
4550
+ ]);
4551
+ }
4552
+ escapeAttr(s) {
4553
+ return s.replace(/&/g, '&amp;').replace(/"/g, '&quot;');
4554
+ }
4555
+ unescapeAttr(s) {
4556
+ return s.replace(/&quot;/g, '"').replace(/&amp;/g, '&');
4557
+ }
4558
+ clearMathSel() {
4559
+ this.mathCtx?.remove();
4560
+ this.mathCtx = null;
4561
+ this.mathActive?.classList.remove('math-selected');
4562
+ this.mathActive = null;
4563
+ }
4564
+ selectMathEl(el) {
4565
+ if (this.mathActive === el)
4566
+ return;
4567
+ this.clearMathSel();
4568
+ this.mathActive = el;
4569
+ el.classList.add('math-selected');
4570
+ this.mathCtx = this.buildMathCtxToolbar(el);
4571
+ this.positionMathCtx();
4572
+ }
4573
+ buildMathCtxToolbar(el) {
4574
+ const ctx = document.createElement('div');
4575
+ ctx.className = 'imgctx';
4576
+ ctx.dir = this.dir();
4577
+ ctx.append(this.ctxBtn(IC.editic, this.t('editmath'), () => this.mathDlg(el)), this.ctxSep(), this.ctxBtn(IC.trash, this.t('deletemath'), () => {
4578
+ el.remove();
4579
+ this.clearMathSel();
4580
+ this.onChange();
4581
+ }));
4582
+ ctx.addEventListener('mousedown', (e) => e.preventDefault());
4583
+ document.body.appendChild(ctx);
4584
+ return ctx;
4585
+ }
4586
+ positionMathCtx() {
4587
+ if (!this.mathActive || !document.body.contains(this.mathActive)) {
4588
+ this.clearMathSel();
4589
+ return;
4590
+ }
4591
+ const r = this.mathActive.getBoundingClientRect();
4592
+ if (!this.mathCtx)
4593
+ return;
4594
+ const cw = this.mathCtx.offsetWidth;
4595
+ const ch = this.mathCtx.offsetHeight;
4596
+ let cx = r.left + scrollX + (r.width - cw) / 2;
4597
+ cx = Math.max(8 + scrollX, Math.min(cx, scrollX + innerWidth - cw - 8));
4598
+ let cy = r.top + scrollY - ch - 8;
4599
+ if (cy < this.ctxMinTop())
4600
+ cy = r.bottom + scrollY + 8;
4601
+ this.mathCtx.style.left = cx + 'px';
4602
+ this.mathCtx.style.top = cy + 'px';
4603
+ }
3517
4604
  /* ---------------------------------------------------------- templates */
3518
4605
  templateMenu(anchor) {
3519
4606
  this.popup(anchor, (el) => this.buildTemplatePickInto(el));
@@ -4233,6 +5320,26 @@ class FableEditor {
4233
5320
  fr.readAsDataURL(imgItem.getAsFile());
4234
5321
  return;
4235
5322
  }
5323
+ if (!html) {
5324
+ /* plain-text pastes get two additive pre-checks before the normal text
5325
+ pipeline runs (the paste engine itself is untouched): a clipboard that
5326
+ is entirely one LaTeX formula/derivation becomes a rendered math
5327
+ element, and a bare video page URL (YouTube & co.) becomes an
5328
+ embedded player. Anything else falls through unchanged. */
5329
+ const text = cd.getData('text/plain');
5330
+ const math = this.mathFromClipboard(text);
5331
+ if (math) {
5332
+ this.insertMathElement(math.latex, math.block);
5333
+ this.onChange();
5334
+ return;
5335
+ }
5336
+ const embed = /^\S+$/.test(text.trim()) ? this.videoEmbedUrl(text.trim()) : null;
5337
+ if (embed) {
5338
+ document.execCommand('insertHTML', false, this.videoEmbedHTML(embed));
5339
+ this.onChange();
5340
+ return;
5341
+ }
5342
+ }
4236
5343
  if (html) {
4237
5344
  document.execCommand('insertHTML', false, cleanPastedHTML(html, this.dir()));
4238
5345
  }
@@ -4247,6 +5354,9 @@ class FableEditor {
4247
5354
  this.closeDlg();
4248
5355
  this.clearTableHandles();
4249
5356
  this.clearImgPlaceholderSel();
5357
+ this.clearVphPlaceholderSel();
5358
+ this.clearVidHandles();
5359
+ this.clearMathSel();
4250
5360
  this.shell.dir = this.dir();
4251
5361
  this.ed.dir = this.dir();
4252
5362
  if (this.options.menubar)