fable-editor 1.0.1 → 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: {
@@ -136,7 +137,52 @@ const I18N = {
136
137
  insertimg: 'Insert',
137
138
  dropimage: 'Drop image here',
138
139
  uploading: 'Uploading…',
139
- uploadfailed: 'Upload failed — click to retry'
140
+ uploadfailed: 'Upload failed — click to retry',
141
+ template: 'Templates',
142
+ tplimgleft: 'Image left, text right',
143
+ tplimgright: 'Image right, text left',
144
+ tplimgtop: 'Image top, text below',
145
+ tplimgcenter: 'Text top, image center',
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'
140
186
  },
141
187
  ar: {
142
188
  dir: 'rtl',
@@ -272,7 +318,52 @@ const I18N = {
272
318
  insertimg: 'إدراج',
273
319
  dropimage: 'أسقط الصورة هنا',
274
320
  uploading: 'جارٍ الرفع…',
275
- uploadfailed: 'فشل الرفع — انقر لإعادة المحاولة'
321
+ uploadfailed: 'فشل الرفع — انقر لإعادة المحاولة',
322
+ template: 'قوالب',
323
+ tplimgleft: 'صورة يسار، نص يمين',
324
+ tplimgright: 'صورة يمين، نص يسار',
325
+ tplimgtop: 'صورة أعلى، نص أسفل',
326
+ tplimgcenter: 'نص أعلى، صورة وسط',
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: 'أعلام'
276
367
  }
277
368
  };
278
369
  function getStrings(lang) {
@@ -284,7 +375,7 @@ function getStrings(lang) {
284
375
  const DEFAULT_TOOLBAR = 'undo redo | preview print | importword revhistory | fontfamily fontsize | fontsizeincrease fontsizedecrease | ' +
285
376
  'bold italic underline strikethrough | forecolor backcolor | alignleft aligncenter alignright alignjustify | ' +
286
377
  'bullist numlist | outdent indent | link blockquote changecase lineheight wordspacing | removeformat | blocks | ' +
287
- 'ltr rtl | quickimage quicktable charmap | fullscreen sourcecode';
378
+ 'ltr rtl | quickimage quickvideo quicktable mathformula template charmap emoji | fullscreen sourcecode';
288
379
  /** Transcribed from the editor's built-in menu keys so that `menubar: true` (the
289
380
  * default) renders identically to the hard-coded original. */
290
381
  const DEFAULT_MENUBAR = 'file edit view insert format tools table help';
@@ -350,6 +441,147 @@ const COLORS = [
350
441
  '#000000',
351
442
  '#FFFFFF'
352
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
+ ];
353
585
  const CHARS = [
354
586
  '©',
355
587
  '®',
@@ -475,6 +707,11 @@ const IC = {
475
707
  wordic: S('<path d="M6 3h8l5 5v13H6z"/><path d="M14 3v5h5"/><text x="8" y="17" font-size="8.5" stroke="none" fill="#222f3e" font-family="Arial" font-weight="bold">W</text>'),
476
708
  draftic: G('M480-160q-134 0-227-93t-93-227q0-134 93-227t227-93q69 0 132 28.5T720-690v-110h80v280H520v-80h168q-32-56-87.5-88T480-720q-100 0-170 70t-70 170q0 100 70 170t170 70q77 0 139-44t87-116h84q-28 106-114 173t-196 67Z'),
477
709
  trash: S('<path d="M5 7h14M10 7V5h4v2M7.5 7l1 13h7l1-13"/>'),
710
+ templateic: S('<rect x="4" y="4" width="16" height="16" rx="1.5"/><rect x="6.5" y="6.5" width="5" height="5" rx=".5"/><path d="M14 7.5h3.5M14 10h3.5M6.5 14h11M6.5 17h11"/>'),
711
+ tplleft: S('<rect x="3" y="5" width="8" height="8" rx=".8"/><path d="M13.5 6.5H21M13.5 9.5H21M13.5 12.5H21M3 15.5h18M3 18.5h18"/>'),
712
+ tplright: S('<rect x="13" y="5" width="8" height="8" rx=".8"/><path d="M3 6.5h7.5M3 9.5h7.5M3 12.5h7.5M3 15.5h18M3 18.5h18"/>'),
713
+ tpltop: S('<rect x="5" y="3" width="14" height="9" rx=".8"/><path d="M3 15h18M3 18h18M3 21h12"/>'),
714
+ tplcenter: S('<path d="M3 4h18M3 7h18M3 10h12"/><rect x="5" y="12.5" width="14" height="9" rx=".8"/>'),
478
715
  rotateleft: S('<polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/>'),
479
716
  rotateright: S('<polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>'),
480
717
  fliphorizontal: S('<path d="M12 3v18"/><path d="M7 8l-4 4 4 4"/><path d="M17 8l4 4-4 4"/>'),
@@ -486,7 +723,11 @@ const IC = {
486
723
  ol_alpha: LIST(['a', 'b', 'c']),
487
724
  ol_roman: LIST(['i', 'ii', 'iii'], 6.5),
488
725
  ol_alphau: LIST(['A', 'B', 'C']),
489
- 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"/>')
490
731
  };
491
732
  const TXT = (html) => `<span class="txt">${html}</span>`;
492
733
 
@@ -1364,6 +1605,19 @@ async function importDocxToHtml(input) {
1364
1605
  }
1365
1606
 
1366
1607
  const IMG_CORNERS = ['nw', 'ne', 'sw', 'se'];
1608
+ const TPL_LAYOUTS = ['img-left', 'img-right', 'img-top', 'img-center'];
1609
+ const TPL_LABEL = {
1610
+ 'img-left': 'tplimgleft',
1611
+ 'img-right': 'tplimgright',
1612
+ 'img-top': 'tplimgtop',
1613
+ 'img-center': 'tplimgcenter'
1614
+ };
1615
+ const TPL_ICON = {
1616
+ 'img-left': IC.tplleft,
1617
+ 'img-right': IC.tplright,
1618
+ 'img-top': IC.tpltop,
1619
+ 'img-center': IC.tplcenter
1620
+ };
1367
1621
  class FableEditor {
1368
1622
  static instanceCounter = 0;
1369
1623
  options;
@@ -1402,7 +1656,22 @@ class FableEditor {
1402
1656
  imageUploadHandler;
1403
1657
  onImageUploadError;
1404
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;
1405
1672
  selCtx = null;
1673
+ tplActive = null;
1674
+ tplCtx = null;
1406
1675
  docInput;
1407
1676
  revisions = [];
1408
1677
  revTimer = null;
@@ -1424,10 +1693,13 @@ class FableEditor {
1424
1693
  readonly: options.readonly ?? false,
1425
1694
  draftKey: options.draftKey ?? (typeof location !== 'undefined' ? location.pathname : 'default'),
1426
1695
  fontFamilyFormats: options.fontFamilyFormats ?? FONTS,
1427
- 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']
1428
1698
  };
1429
1699
  this.imageUploadHandler = options.imageUploadHandler;
1430
1700
  this.onImageUploadError = options.onImageUploadError;
1701
+ this.videoUploadHandler = options.videoUploadHandler;
1702
+ this.onVideoUploadError = options.onVideoUploadError;
1431
1703
  this.contentStyle = options.contentStyle;
1432
1704
  this.lang = this.options.language;
1433
1705
  this.draftStorageKey = 'tmclone-draft:' + this.options.draftKey;
@@ -1494,6 +1766,11 @@ class FableEditor {
1494
1766
  this.imgInput.accept = this.options.imageFileTypes.join(',');
1495
1767
  this.imgInput.style.display = 'none';
1496
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);
1497
1774
  this.docInput = document.createElement('input');
1498
1775
  this.docInput.type = 'file';
1499
1776
  this.docInput.accept = '.docx,application/vnd.openxmlformats-officedocument.wordprocessingml.document';
@@ -1561,24 +1838,46 @@ class FableEditor {
1561
1838
  this.positionTableHandles();
1562
1839
  this.positionImageHandles();
1563
1840
  this.positionImgPhCtx();
1841
+ this.positionTplCtx();
1842
+ this.positionVphCtx();
1843
+ this.positionVidCtx();
1844
+ this.positionMathCtx();
1564
1845
  });
1565
1846
  this.on(this.ed, 'keydown', (e) => {
1566
1847
  if (e.altKey && e.key === '0') {
1567
1848
  e.preventDefault();
1568
1849
  this.helpDlg();
1569
1850
  }
1851
+ if ((e.key === 'Delete' || e.key === 'Backspace') && this.phActive) {
1852
+ e.preventDefault();
1853
+ this.removeImgPlaceholder();
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);
1570
1861
  });
1571
1862
  this.on(this.ed, 'paste', (e) => this.handlePaste(e));
1572
1863
  this.on(this.ed, 'drop', (e) => {
1573
1864
  /* content drops stay blocked (powerpaste_block_drop) — only an
1574
- image file dropped onto an upload placeholder is consumed */
1865
+ image/video file dropped onto its matching upload placeholder is consumed */
1575
1866
  e.preventDefault();
1576
- const ph = e.target.closest?.('.img-ph');
1577
- 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);
1578
1873
  return;
1579
- const file = Array.from(e.dataTransfer?.files || []).find((f) => f.type.startsWith('image/'));
1580
- if (file)
1581
- 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
+ }
1582
1881
  });
1583
1882
  this.on(this.ed, 'dragover', (e) => e.preventDefault());
1584
1883
  this.on(this.ed, 'mousedown', (e) => {
@@ -1588,14 +1887,29 @@ class FableEditor {
1588
1887
  this.selectTableForResize(table);
1589
1888
  else
1590
1889
  this.clearTableHandles();
1591
- const ph = target.closest?.('.img-ph');
1890
+ const ph = target.closest?.('.img-ph:not(.vid-ph)');
1592
1891
  if (ph && this.ed.contains(ph) && !this.options.readonly) {
1593
1892
  e.preventDefault();
1893
+ this.ed.focus();
1594
1894
  this.selectImgPlaceholder(ph);
1595
1895
  }
1596
1896
  else {
1597
1897
  this.clearImgPlaceholderSel();
1598
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
+ }
1908
+ const tpl = target.closest?.('.tpl');
1909
+ if (tpl && this.ed.contains(tpl) && !this.options.readonly)
1910
+ this.selectTemplate(tpl);
1911
+ else
1912
+ this.clearTplSel();
1599
1913
  const img = target.closest?.('img');
1600
1914
  if (img && this.ed.contains(img) && !this.options.readonly) {
1601
1915
  e.preventDefault();
@@ -1604,6 +1918,22 @@ class FableEditor {
1604
1918
  else {
1605
1919
  this.clearImageHandles();
1606
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
+ }
1607
1937
  });
1608
1938
  this.on(this.ed, 'mouseover', (e) => {
1609
1939
  if (this.options.readonly)
@@ -1630,17 +1960,29 @@ class FableEditor {
1630
1960
  this.positionTableHandles();
1631
1961
  this.positionImageHandles();
1632
1962
  this.positionImgPhCtx();
1963
+ this.positionTplCtx();
1964
+ this.positionVphCtx();
1965
+ this.positionVidCtx();
1966
+ this.positionMathCtx();
1633
1967
  });
1634
1968
  this.onWin('scroll', () => {
1635
1969
  this.positionTableHandles();
1636
1970
  this.positionImageHandles();
1637
1971
  this.positionImgPhCtx();
1972
+ this.positionTplCtx();
1973
+ this.positionVphCtx();
1974
+ this.positionVidCtx();
1975
+ this.positionMathCtx();
1638
1976
  this.repositionSelToolbar();
1639
1977
  }, true);
1640
1978
  this.onWin('resize', () => {
1641
1979
  this.positionTableHandles();
1642
1980
  this.positionImageHandles();
1643
1981
  this.positionImgPhCtx();
1982
+ this.positionTplCtx();
1983
+ this.positionVphCtx();
1984
+ this.positionVidCtx();
1985
+ this.positionMathCtx();
1644
1986
  this.repositionSelToolbar();
1645
1987
  });
1646
1988
  this.on(this.imgInput, 'change', () => {
@@ -1661,6 +2003,24 @@ class FableEditor {
1661
2003
  };
1662
2004
  fr.readAsDataURL(file);
1663
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
+ });
1664
2024
  this.on(this.docInput, 'change', () => {
1665
2025
  const file = this.docInput.files?.[0];
1666
2026
  if (!file)
@@ -1675,13 +2035,21 @@ class FableEditor {
1675
2035
  this.imgHandles.includes(e.target) ||
1676
2036
  (this.imgCtx && this.imgCtx.contains(e.target)) ||
1677
2037
  (this.phCtx && this.phCtx.contains(e.target)) ||
2038
+ (this.tplCtx && this.tplCtx.contains(e.target)) ||
1678
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)) ||
1679
2043
  (this.openPop && this.openPop.contains(e.target)) ||
1680
2044
  (this.openSubEl && this.openSubEl.contains(e.target)))
1681
2045
  return;
1682
2046
  this.clearTableHandles();
1683
2047
  this.clearImageHandles();
1684
2048
  this.clearImgPlaceholderSel();
2049
+ this.clearTplSel();
2050
+ this.clearVphPlaceholderSel();
2051
+ this.clearVidHandles();
2052
+ this.clearMathSel();
1685
2053
  this.clearSelToolbar();
1686
2054
  });
1687
2055
  }
@@ -1707,6 +2075,10 @@ class FableEditor {
1707
2075
  this.clearTableHandles();
1708
2076
  this.clearImageHandles();
1709
2077
  this.clearImgPlaceholderSel();
2078
+ this.clearTplSel();
2079
+ this.clearVphPlaceholderSel();
2080
+ this.clearVidHandles();
2081
+ this.clearMathSel();
1710
2082
  this.clearSelToolbar();
1711
2083
  this.onChange();
1712
2084
  }
@@ -1768,6 +2140,10 @@ class FableEditor {
1768
2140
  this.clearTableHandles();
1769
2141
  this.clearImageHandles();
1770
2142
  this.clearImgPlaceholderSel();
2143
+ this.clearTplSel();
2144
+ this.clearVphPlaceholderSel();
2145
+ this.clearVidHandles();
2146
+ this.clearMathSel();
1771
2147
  this.clearSelToolbar();
1772
2148
  this.listeners.forEach((fn) => fn());
1773
2149
  this.listeners = [];
@@ -1775,6 +2151,8 @@ class FableEditor {
1775
2151
  this.shell.parentNode.removeChild(this.shell);
1776
2152
  if (this.imgInput && this.imgInput.parentNode)
1777
2153
  this.imgInput.parentNode.removeChild(this.imgInput);
2154
+ if (this.vidInput && this.vidInput.parentNode)
2155
+ this.vidInput.parentNode.removeChild(this.vidInput);
1778
2156
  if (this.docInput && this.docInput.parentNode)
1779
2157
  this.docInput.parentNode.removeChild(this.docInput);
1780
2158
  this.contentStyleEl = null;
@@ -2576,7 +2954,7 @@ class FableEditor {
2576
2954
  * (the quick color chips); everything else yields one. */
2577
2955
  buildToolbarRegistry() {
2578
2956
  const tableBtn = this.tbtn(IC.tableic, this.t('quicktable'), () => this.tableGrid(tableBtn));
2579
- return {
2957
+ const registry = {
2580
2958
  undo: () => this.tbtn(IC.undo, this.t('undo'), () => this.exec('undo')),
2581
2959
  redo: () => this.tbtn(IC.redo, this.t('redo'), () => this.exec('redo')),
2582
2960
  preview: () => this.tbtn(IC.prevw, this.t('preview'), () => this.previewDlg()),
@@ -2611,11 +2989,22 @@ class FableEditor {
2611
2989
  ltr: () => this.tbtn(IC.ltr, this.t('ltr'), () => this.setDir('ltr'), 'ltr'),
2612
2990
  rtl: () => this.tbtn(IC.rtl, this.t('rtl'), () => this.setDir('rtl'), 'rtl'),
2613
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()),
2614
2994
  quicktable: () => tableBtn,
2995
+ template: () => this.menuTbtn(IC.templateic, this.t('template'), (b) => this.templateMenu(b)),
2615
2996
  charmap: () => this.tbtn(IC.charic, this.t('charmap'), () => this.charMap()),
2997
+ emoji: () => this.tbtn(IC.emojiic, this.t('emoji'), () => this.emojiMap()),
2616
2998
  fullscreen: () => this.tbtn(IC.fullscreen, this.t('fullscreen'), () => this.toggleFullscreen(), 'fullscreen'),
2617
2999
  sourcecode: () => this.tbtn(IC.srcic, this.t('sourcecode'), () => this.sourceDlg())
2618
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;
2619
3008
  }
2620
3009
  buildToolbar() {
2621
3010
  this.toolbar.innerHTML = '';
@@ -2682,10 +3071,14 @@ class FableEditor {
2682
3071
  insert: [
2683
3072
  this.mItem('link', IC.link, () => this.linkDlg()),
2684
3073
  this.mItem('image', IC.image, () => this.insertImagePlaceholder()),
3074
+ this.mItem('video', IC.video, () => this.videoDlg()),
3075
+ this.mItem('mathformula', IC.mathic, () => this.mathDlg()),
2685
3076
  { label: this.t('inserttable'), icon: IC.tableic, action: () => this.tableGrid(this.menubar.querySelector('[data-menu-key="table"]') || this.menubar) },
3077
+ { label: this.t('template'), icon: IC.templateic, subBuild: (el) => this.buildTemplatePickInto(el) },
2686
3078
  this.mItem('hr', IC.hric, () => this.exec('insertHorizontalRule')),
2687
3079
  '|',
2688
3080
  this.mItem('charmap', IC.charic, () => this.charMap()),
3081
+ this.mItem('emoji', IC.emojiic, () => this.emojiMap()),
2689
3082
  this.mItem('datetime', IC.dateic, () => this.exec('insertText', new Date().toLocaleString(this.lang === 'ar' ? 'ar-AE' : 'en-GB'))),
2690
3083
  '|',
2691
3084
  {
@@ -2755,7 +3148,7 @@ class FableEditor {
2755
3148
  this.tablePropsDlg();
2756
3149
  }
2757
3150
  },
2758
- { label: this.t('deltable'), icon: IC.tabledelete, action: () => this.tableOp('deltable') }
3151
+ { label: this.t('deltable'), icon: IC.trash, action: () => this.tableOp('deltable') }
2759
3152
  ],
2760
3153
  help: [this.mItem('helpttl', IC.helpic, () => this.helpDlg(), 'Alt+0')]
2761
3154
  };
@@ -3256,22 +3649,53 @@ class FableEditor {
3256
3649
  body.appendChild(box);
3257
3650
  });
3258
3651
  }
3259
- charMap() {
3260
- this.dialog(this.t('charmap').replace('…', ''), (body) => {
3261
- const g = document.createElement('div');
3262
- g.className = 'chgrid';
3263
- 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) => {
3264
3682
  const b = document.createElement('button');
3265
- b.textContent = ch;
3266
- b.onclick = () => {
3267
- this.closeDlg();
3268
- this.exec('insertText', ch);
3269
- };
3270
- g.appendChild(b);
3683
+ b.type = 'button';
3684
+ b.textContent = this.t(c.key);
3685
+ b.addEventListener('click', () => show(i));
3686
+ tabs.appendChild(b);
3271
3687
  });
3272
- body.appendChild(g);
3688
+ wrap.append(tabs, panel);
3689
+ body.appendChild(wrap);
3690
+ show(0);
3273
3691
  });
3274
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
+ }
3275
3699
  printDoc() {
3276
3700
  const w = window.open('', '_blank');
3277
3701
  if (!w)
@@ -3286,10 +3710,13 @@ class FableEditor {
3286
3710
  this.imgInput.click();
3287
3711
  }
3288
3712
  /* ---------------------------------------------- image upload placeholder */
3713
+ imgPhHTML(id) {
3714
+ return `<div class="img-ph"${id ? ` id="${id}"` : ''} contenteditable="false">${IC.image}<span>${this.t('dropimage')}</span></div>`;
3715
+ }
3289
3716
  insertImagePlaceholder() {
3290
3717
  this.restoreSel();
3291
3718
  const id = 'img-ph-' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
3292
- document.execCommand('insertHTML', false, `<div class="img-ph" id="${id}" contenteditable="false">${IC.image}<span>${this.t('dropimage')}</span></div>`);
3719
+ document.execCommand('insertHTML', false, this.imgPhHTML(id));
3293
3720
  const ph = this.ed.querySelector('#' + id);
3294
3721
  if (ph) {
3295
3722
  ph.removeAttribute('id');
@@ -3303,6 +3730,17 @@ class FableEditor {
3303
3730
  this.phActive?.classList.remove('active');
3304
3731
  this.phActive = null;
3305
3732
  }
3733
+ removeImgPlaceholder() {
3734
+ const ph = this.phActive;
3735
+ if (!ph)
3736
+ return;
3737
+ this.clearImgPlaceholderSel();
3738
+ /* the placeholder inside a template block IS the media slot — deleting
3739
+ it drops the slot entirely, leaving a text-only block */
3740
+ (ph.closest('.tpl-media') || ph).remove();
3741
+ this.refreshState();
3742
+ this.onChange();
3743
+ }
3306
3744
  selectImgPlaceholder(ph) {
3307
3745
  if (this.phActive === ph)
3308
3746
  return;
@@ -3324,8 +3762,33 @@ class FableEditor {
3324
3762
  this.phUploadTarget = this.phActive;
3325
3763
  this.pickImage();
3326
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()));
3327
3770
  this.positionImgPhCtx();
3328
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
+ }
3329
3792
  renderPhCtxUrlInput() {
3330
3793
  const el = this.phCtx;
3331
3794
  if (!el)
@@ -3448,6 +3911,829 @@ class FableEditor {
3448
3911
  this.phCtx.style.left = cx + 'px';
3449
3912
  this.phCtx.style.top = cy + 'px';
3450
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
+ }
4604
+ /* ---------------------------------------------------------- templates */
4605
+ templateMenu(anchor) {
4606
+ this.popup(anchor, (el) => this.buildTemplatePickInto(el));
4607
+ }
4608
+ /** Miniature div-based mockup of a layout, mirroring what the block looks
4609
+ like in the editor: grey box = image slot, bars = heading/paragraph. */
4610
+ tplPreviewHTML(layout) {
4611
+ const img = '<span class="pv-img"></span>';
4612
+ const head = '<span class="pv-h"></span>';
4613
+ const line = (w) => `<span class="pv-l" style="width:${w}%"></span>`;
4614
+ const col = (inner) => `<span class="pv-col">${inner}</span>`;
4615
+ const row = (inner) => `<span class="pv-row">${inner}</span>`;
4616
+ const textCol = col(head + line(100) + line(88));
4617
+ if (layout === 'img-left')
4618
+ return `<span class="pv">${row(img + textCol)}${line(100)}${line(72)}</span>`;
4619
+ if (layout === 'img-right')
4620
+ return `<span class="pv">${row(textCol + img)}${line(100)}${line(72)}</span>`;
4621
+ if (layout === 'img-top')
4622
+ return `<span class="pv pv-c">${img}${head}${line(100)}${line(72)}</span>`;
4623
+ return `<span class="pv pv-c">${head}${line(100)}${line(72)}${img}</span>`;
4624
+ }
4625
+ buildTemplatePickInto(el, after) {
4626
+ const wrap = document.createElement('div');
4627
+ wrap.className = 'tplpick';
4628
+ TPL_LAYOUTS.forEach((layout) => {
4629
+ const tile = document.createElement('button');
4630
+ tile.type = 'button';
4631
+ tile.className = 'tpltile';
4632
+ tile.title = this.t(TPL_LABEL[layout]);
4633
+ tile.innerHTML = this.tplPreviewHTML(layout) + `<span class="tpllbl">${this.t(TPL_LABEL[layout])}</span>`;
4634
+ tile.addEventListener('click', () => {
4635
+ this.closePop();
4636
+ this.insertTemplate(layout);
4637
+ after?.();
4638
+ });
4639
+ wrap.appendChild(tile);
4640
+ });
4641
+ el.appendChild(wrap);
4642
+ }
4643
+ insertTemplate(layout) {
4644
+ this.restoreSel();
4645
+ const media = `<div class="tpl-media" contenteditable="false">${this.imgPhHTML()}</div>`;
4646
+ const text = `<div class="tpl-text"><h2>${this.t('heading')}</h2><p>${this.t('para')}</p></div>`;
4647
+ /* text-above-image layout keeps DOM order = visual order so the caret
4648
+ travels naturally; every other layout has the media slot first */
4649
+ const inner = layout === 'img-center' ? text + media : media + text;
4650
+ document.execCommand('insertHTML', false, `<div class="tpl tpl-${layout}">${inner}</div><p><br></p>`);
4651
+ this.saveSel();
4652
+ this.onChange();
4653
+ }
4654
+ clearTplSel() {
4655
+ this.tplCtx?.remove();
4656
+ this.tplCtx = null;
4657
+ this.tplActive?.classList.remove('tpl-selected');
4658
+ this.tplActive = null;
4659
+ }
4660
+ selectTemplate(tpl) {
4661
+ if (this.tplActive === tpl)
4662
+ return;
4663
+ this.clearTplSel();
4664
+ this.tplActive = tpl;
4665
+ tpl.classList.add('tpl-selected');
4666
+ this.tplCtx = document.createElement('div');
4667
+ this.tplCtx.className = 'imgctx tplctx';
4668
+ this.tplCtx.dir = this.dir();
4669
+ document.body.appendChild(this.tplCtx);
4670
+ this.renderTplCtxButtons();
4671
+ }
4672
+ renderTplCtxButtons() {
4673
+ const el = this.tplCtx;
4674
+ const tpl = this.tplActive;
4675
+ if (!el || !tpl)
4676
+ return;
4677
+ el.innerHTML = '';
4678
+ TPL_LAYOUTS.forEach((layout) => {
4679
+ const b = this.ctxBtn(TPL_ICON[layout], this.t(TPL_LABEL[layout]), () => this.setTplLayout(layout));
4680
+ b.classList.toggle('on', tpl.classList.contains('tpl-' + layout));
4681
+ el.appendChild(b);
4682
+ });
4683
+ el.append(this.ctxSep(), this.ctxBtn(IC.trash, this.t('deltemplate'), () => this.removeTemplate()));
4684
+ this.positionTplCtx();
4685
+ }
4686
+ setTplLayout(layout) {
4687
+ const tpl = this.tplActive;
4688
+ if (!tpl)
4689
+ return;
4690
+ TPL_LAYOUTS.forEach((l) => tpl.classList.remove('tpl-' + l));
4691
+ tpl.classList.add('tpl-' + layout);
4692
+ /* keep DOM order = visual order (see insertTemplate) */
4693
+ const media = tpl.querySelector(':scope > .tpl-media');
4694
+ const text = tpl.querySelector(':scope > .tpl-text');
4695
+ if (media && text) {
4696
+ if (layout === 'img-center')
4697
+ tpl.appendChild(media);
4698
+ else
4699
+ tpl.insertBefore(media, text);
4700
+ }
4701
+ this.renderTplCtxButtons();
4702
+ this.positionImgPhCtx();
4703
+ this.positionImageHandles();
4704
+ this.onChange();
4705
+ }
4706
+ removeTemplate() {
4707
+ const tpl = this.tplActive;
4708
+ if (!tpl)
4709
+ return;
4710
+ this.clearTplSel();
4711
+ if (this.phActive && tpl.contains(this.phActive))
4712
+ this.clearImgPlaceholderSel();
4713
+ if (this.imgActive && tpl.contains(this.imgActive))
4714
+ this.clearImageHandles();
4715
+ tpl.remove();
4716
+ this.refreshState();
4717
+ this.onChange();
4718
+ }
4719
+ positionTplCtx() {
4720
+ if (this.tplActive && !document.body.contains(this.tplActive)) {
4721
+ this.clearTplSel();
4722
+ return;
4723
+ }
4724
+ if (!this.tplActive || !this.tplCtx)
4725
+ return;
4726
+ const r = this.tplActive.getBoundingClientRect();
4727
+ const cw = this.tplCtx.offsetWidth;
4728
+ const ch = this.tplCtx.offsetHeight;
4729
+ let cx = r.left + scrollX + (r.width - cw) / 2;
4730
+ cx = Math.max(8 + scrollX, Math.min(cx, scrollX + innerWidth - cw - 8));
4731
+ let cy = r.top + scrollY - ch - 8;
4732
+ if (cy < this.ctxMinTop())
4733
+ cy = r.bottom + scrollY + 8;
4734
+ this.tplCtx.style.left = cx + 'px';
4735
+ this.tplCtx.style.top = cy + 'px';
4736
+ }
3451
4737
  pickWordDoc() {
3452
4738
  this.docInput.value = '';
3453
4739
  this.docInput.click();
@@ -3554,7 +4840,7 @@ class FableEditor {
3554
4840
  b.addEventListener('mouseleave', () => this.setOpTarget(null));
3555
4841
  return b;
3556
4842
  };
3557
- el.append(hl(this.ctxBtn(IC.rowbefore, this.t('rowabove'), () => this.tableOp('rowabove')), 'row'), hl(this.ctxBtn(IC.rowafter, this.t('rowbelow'), () => this.tableOp('rowbelow')), 'row'), hl(this.ctxBtn(IC.rowdelete, this.t('delrow'), () => this.tableOp('delrow')), 'row'), this.ctxSep(), hl(this.ctxBtn(IC.colbefore, this.t('colbefore'), () => this.tableOp('colbefore')), 'col'), hl(this.ctxBtn(IC.colafter, this.t('colafter'), () => this.tableOp('colafter')), 'col'), hl(this.ctxBtn(IC.coldelete, this.t('delcol'), () => this.tableOp('delcol')), 'col'), this.ctxSep(), this.ctxBtn(IC.tabledelete, this.t('deltable'), () => this.tableOp('deltable')), this.ctxBtn(IC.tableic, this.t('tablepropsttl'), () => this.tablePropsDlg()));
4843
+ el.append(hl(this.ctxBtn(IC.rowbefore, this.t('rowabove'), () => this.tableOp('rowabove')), 'row'), hl(this.ctxBtn(IC.rowafter, this.t('rowbelow'), () => this.tableOp('rowbelow')), 'row'), hl(this.ctxBtn(IC.rowdelete, this.t('delrow'), () => this.tableOp('delrow')), 'row'), this.ctxSep(), hl(this.ctxBtn(IC.colbefore, this.t('colbefore'), () => this.tableOp('colbefore')), 'col'), hl(this.ctxBtn(IC.colafter, this.t('colafter'), () => this.tableOp('colafter')), 'col'), hl(this.ctxBtn(IC.coldelete, this.t('delcol'), () => this.tableOp('delcol')), 'col'), this.ctxSep(), this.ctxBtn(IC.trash, this.t('deltable'), () => this.tableOp('deltable')), this.ctxBtn(IC.tableic, this.t('tablepropsttl'), () => this.tablePropsDlg()));
3558
4844
  document.body.appendChild(el);
3559
4845
  return el;
3560
4846
  }
@@ -3761,7 +5047,13 @@ class FableEditor {
3761
5047
  this.flipImage(img, 'y');
3762
5048
  rebuild();
3763
5049
  }), this.ctxSep(), mkBtn(IC.trash, this.t('deleteimg'), false, () => {
3764
- img.remove();
5050
+ /* inside a template media slot the image swaps back to an
5051
+ upload placeholder so the slot is not lost */
5052
+ const slot = img.closest('.tpl-media');
5053
+ if (slot && this.ed.contains(slot))
5054
+ img.outerHTML = this.imgPhHTML();
5055
+ else
5056
+ img.remove();
3765
5057
  this.clearImageHandles();
3766
5058
  this.onChange();
3767
5059
  }));
@@ -4028,6 +5320,26 @@ class FableEditor {
4028
5320
  fr.readAsDataURL(imgItem.getAsFile());
4029
5321
  return;
4030
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
+ }
4031
5343
  if (html) {
4032
5344
  document.execCommand('insertHTML', false, cleanPastedHTML(html, this.dir()));
4033
5345
  }
@@ -4042,6 +5354,9 @@ class FableEditor {
4042
5354
  this.closeDlg();
4043
5355
  this.clearTableHandles();
4044
5356
  this.clearImgPlaceholderSel();
5357
+ this.clearVphPlaceholderSel();
5358
+ this.clearVidHandles();
5359
+ this.clearMathSel();
4045
5360
  this.shell.dir = this.dir();
4046
5361
  this.ed.dir = this.dir();
4047
5362
  if (this.options.menubar)