fable-editor 1.0.2 → 1.2.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.
@@ -41,9 +41,14 @@ const I18N = {
41
41
  rowabove: 'Insert row before',
42
42
  rowbelow: 'Insert row after',
43
43
  delrow: 'Delete row',
44
+ moverowup: 'Move row up',
45
+ moverowdown: 'Move row down',
44
46
  colbefore: 'Insert column before',
45
47
  colafter: 'Insert column after',
46
48
  delcol: 'Delete column',
49
+ movecolleft: 'Move column left',
50
+ movecolright: 'Move column right',
51
+ cellbg: 'Cell background',
47
52
  deltable: 'Delete table',
48
53
  ltr: 'Left to right',
49
54
  rtl: 'Right to left',
@@ -90,6 +95,9 @@ const I18N = {
90
95
  tblcellpadding: 'Cell padding',
91
96
  tblborder: 'Border width',
92
97
  tblalign: 'Alignment',
98
+ tblalignleft: 'Align table left',
99
+ tblaligncenter: 'Center table',
100
+ tblalignright: 'Align table right',
93
101
  alignnone: 'None',
94
102
  quote: 'Blockquote',
95
103
  link: 'Link…',
@@ -98,6 +106,7 @@ const I18N = {
98
106
  unlink: 'Remove link',
99
107
  lineheight: 'Line height',
100
108
  wordspacing: 'Word spacing',
109
+ letterspacing: 'Letter spacing',
101
110
  changecase: 'Change case',
102
111
  lowercase: 'Lowercase',
103
112
  uppercase: 'Uppercase',
@@ -142,7 +151,47 @@ const I18N = {
142
151
  tplimgright: 'Image right, text left',
143
152
  tplimgtop: 'Image top, text below',
144
153
  tplimgcenter: 'Text top, image center',
145
- deltemplate: 'Delete template'
154
+ deltemplate: 'Delete template',
155
+ video: 'Video',
156
+ quickvideo: 'Insert video',
157
+ deletevideo: 'Delete video',
158
+ uploadvideo: 'Upload from computer',
159
+ videolink: 'Insert from URL',
160
+ videourlph: 'Paste video URL…',
161
+ insertvideo: 'Insert',
162
+ dropvideo: 'Drop video here',
163
+ videodlgttl: 'Insert/edit video',
164
+ vidgeneral: 'General',
165
+ vidembed: 'Embed',
166
+ vidadvanced: 'Advanced',
167
+ vidsource: 'Source',
168
+ vidembedhint: 'Paste your embed code below:',
169
+ vidaltsource: 'Alternative source URL',
170
+ vidposter: 'Media poster (Image URL)',
171
+ emoji: 'Emojis…',
172
+ code: 'Inline code',
173
+ codesample: 'Code sample…',
174
+ codedlgttl: 'Insert/edit code sample',
175
+ codelang: 'Language',
176
+ editcode: 'Edit code sample',
177
+ deletecode: 'Delete code sample',
178
+ copycode: 'Copy code',
179
+ chcat_currency: 'Currency',
180
+ chcat_text: 'Text & punctuation',
181
+ chcat_math: 'Mathematical',
182
+ chcat_arrows: 'Arrows',
183
+ chcat_symbols: 'Symbols',
184
+ chcat_latin: 'Extended Latin',
185
+ chcat_arabic: 'Arabic',
186
+ emcat_smileys: 'Smileys & emotion',
187
+ emcat_people: 'People & body',
188
+ emcat_animals: 'Animals & nature',
189
+ emcat_food: 'Food & drink',
190
+ emcat_travel: 'Travel & places',
191
+ emcat_activities: 'Activities',
192
+ emcat_objects: 'Objects',
193
+ emcat_symbols: 'Symbols',
194
+ emcat_flags: 'Flags'
146
195
  },
147
196
  ar: {
148
197
  dir: 'rtl',
@@ -183,9 +232,14 @@ const I18N = {
183
232
  rowabove: 'إدراج صف قبل',
184
233
  rowbelow: 'إدراج صف بعد',
185
234
  delrow: 'حذف الصف',
235
+ moverowup: 'نقل الصف لأعلى',
236
+ moverowdown: 'نقل الصف لأسفل',
186
237
  colbefore: 'إدراج عمود قبل',
187
238
  colafter: 'إدراج عمود بعد',
188
239
  delcol: 'حذف العمود',
240
+ movecolleft: 'نقل العمود لليسار',
241
+ movecolright: 'نقل العمود لليمين',
242
+ cellbg: 'خلفية الخلية',
189
243
  deltable: 'حذف الجدول',
190
244
  ltr: 'من اليسار إلى اليمين',
191
245
  rtl: 'من اليمين إلى اليسار',
@@ -232,6 +286,9 @@ const I18N = {
232
286
  tblcellpadding: 'حشو الخلايا',
233
287
  tblborder: 'عرض الحدود',
234
288
  tblalign: 'المحاذاة',
289
+ tblalignleft: 'محاذاة الجدول لليسار',
290
+ tblaligncenter: 'توسيط الجدول',
291
+ tblalignright: 'محاذاة الجدول لليمين',
235
292
  alignnone: 'بدون',
236
293
  quote: 'اقتباس',
237
294
  link: 'رابط…',
@@ -240,6 +297,7 @@ const I18N = {
240
297
  unlink: 'إزالة الرابط',
241
298
  lineheight: 'تباعد الأسطر',
242
299
  wordspacing: 'تباعد الكلمات',
300
+ letterspacing: 'تباعد الأحرف',
243
301
  changecase: 'تغيير حالة الأحرف',
244
302
  lowercase: 'أحرف صغيرة',
245
303
  uppercase: 'أحرف كبيرة',
@@ -284,7 +342,47 @@ const I18N = {
284
342
  tplimgright: 'صورة يمين، نص يسار',
285
343
  tplimgtop: 'صورة أعلى، نص أسفل',
286
344
  tplimgcenter: 'نص أعلى، صورة وسط',
287
- deltemplate: 'حذف القالب'
345
+ deltemplate: 'حذف القالب',
346
+ video: 'فيديو',
347
+ quickvideo: 'إدراج فيديو',
348
+ deletevideo: 'حذف الفيديو',
349
+ uploadvideo: 'رفع من الجهاز',
350
+ videolink: 'إدراج من رابط',
351
+ videourlph: 'الصق رابط الفيديو…',
352
+ insertvideo: 'إدراج',
353
+ dropvideo: 'أسقط الفيديو هنا',
354
+ videodlgttl: 'إدراج/تعديل فيديو',
355
+ vidgeneral: 'عام',
356
+ vidembed: 'تضمين',
357
+ vidadvanced: 'متقدم',
358
+ vidsource: 'المصدر',
359
+ vidembedhint: 'الصق كود التضمين أدناه:',
360
+ vidaltsource: 'رابط مصدر بديل',
361
+ vidposter: 'صورة الغلاف (رابط الصورة)',
362
+ emoji: 'رموز تعبيرية…',
363
+ code: 'كود مضمّن',
364
+ codesample: 'عينة كود…',
365
+ codedlgttl: 'إدراج/تعديل عينة كود',
366
+ codelang: 'اللغة',
367
+ editcode: 'تعديل عينة الكود',
368
+ deletecode: 'حذف عينة الكود',
369
+ copycode: 'نسخ الكود',
370
+ chcat_currency: 'عملات',
371
+ chcat_text: 'نص وعلامات ترقيم',
372
+ chcat_math: 'رموز رياضية',
373
+ chcat_arrows: 'أسهم',
374
+ chcat_symbols: 'رموز',
375
+ chcat_latin: 'لاتينية موسعة',
376
+ chcat_arabic: 'عربية',
377
+ emcat_smileys: 'وجوه ومشاعر',
378
+ emcat_people: 'أشخاص وإيماءات',
379
+ emcat_animals: 'حيوانات وطبيعة',
380
+ emcat_food: 'طعام وشراب',
381
+ emcat_travel: 'سفر وأماكن',
382
+ emcat_activities: 'أنشطة',
383
+ emcat_objects: 'أشياء',
384
+ emcat_symbols: 'رموز',
385
+ emcat_flags: 'أعلام'
288
386
  }
289
387
  };
290
388
  function getStrings(lang) {
@@ -295,8 +393,8 @@ function getStrings(lang) {
295
393
  * `toolbar: true` (the default) renders identically to the hard-coded original. */
296
394
  const DEFAULT_TOOLBAR = 'undo redo | preview print | importword revhistory | fontfamily fontsize | fontsizeincrease fontsizedecrease | ' +
297
395
  'bold italic underline strikethrough | forecolor backcolor | alignleft aligncenter alignright alignjustify | ' +
298
- 'bullist numlist | outdent indent | link blockquote changecase lineheight wordspacing | removeformat | blocks | ' +
299
- 'ltr rtl | quickimage quicktable template charmap | fullscreen sourcecode';
396
+ 'bullist numlist | outdent indent | link blockquote code changecase lineheight wordspacing letterspacing | removeformat | blocks | ' +
397
+ 'ltr rtl | quickimage quickvideo quicktable codesample template charmap emoji | fullscreen sourcecode';
300
398
  /** Transcribed from the editor's built-in menu keys so that `menubar: true` (the
301
399
  * default) renders identically to the hard-coded original. */
302
400
  const DEFAULT_MENUBAR = 'file edit view insert format tools table help';
@@ -326,6 +424,26 @@ const MIN_FONT_PX = 1;
326
424
  const MAX_FONT_PX = 100;
327
425
  const LINE_HEIGHTS = ['1', '1.15', '1.4', '1.5', '1.75', '2', '2.5', '3'];
328
426
  const WORD_SPACINGS = ['1px', '2px', '4px', '6px', '8px', '12px'];
427
+ const LETTER_SPACINGS = ['0.5px', '1px', '1.5px', '2px', '3px', '4px'];
428
+ /** Languages offered by the code-sample dialog: [value stored in data-lang, label]. */
429
+ const CODE_LANGS = [
430
+ ['plain', 'Plain text'],
431
+ ['html', 'HTML/XML'],
432
+ ['css', 'CSS'],
433
+ ['javascript', 'JavaScript'],
434
+ ['typescript', 'TypeScript'],
435
+ ['json', 'JSON'],
436
+ ['python', 'Python'],
437
+ ['java', 'Java'],
438
+ ['csharp', 'C#'],
439
+ ['cpp', 'C/C++'],
440
+ ['php', 'PHP'],
441
+ ['ruby', 'Ruby'],
442
+ ['go', 'Go'],
443
+ ['rust', 'Rust'],
444
+ ['sql', 'SQL'],
445
+ ['bash', 'Bash/Shell']
446
+ ];
329
447
  const BLOCKS = [
330
448
  ['p', 'para'],
331
449
  ['h1', 'heading1'],
@@ -338,29 +456,200 @@ const BLOCKS = [
338
456
  ];
339
457
  /** Quick-access text color chips shown directly in the toolbar. */
340
458
  const QUICK_COLORS = ['#F76707', '#4263EB', '#CC5DE8'];
459
+ /** 25 swatches — a 5×5 grid in the picker (pastels, brights, darks, grays,
460
+ * then pink/brown/cyan + black/white). Shared by fore/back color and cell fill. */
341
461
  const COLORS = [
342
- '#BFEDD2',
343
- '#FBEEB8',
344
- '#F8CAC6',
345
- '#ECCAFA',
346
- '#C2E0F4',
347
- '#2DC26B',
348
- '#F1C40F',
349
- '#E03E2D',
350
- '#B96AD9',
351
- '#3598DB',
352
- '#169179',
353
- '#E67E23',
354
- '#BA372A',
355
- '#843FA1',
356
- '#236FA1',
357
- '#ECF0F1',
358
- '#CED4D9',
359
- '#95A5A6',
360
- '#7E8C8D',
361
- '#34495E',
462
+ '#FFFFFF',
463
+ '#F4F4F4',
464
+ '#E8EAED',
465
+ '#DADCE0',
362
466
  '#000000',
363
- '#FFFFFF'
467
+ '#F28B82',
468
+ '#FBBC04',
469
+ '#FFF475',
470
+ '#CCFF90',
471
+ '#A7FFEB',
472
+ '#CBF0F8',
473
+ '#AECBFA',
474
+ '#D7AEFB',
475
+ '#FDCFE8',
476
+ '#E6C9A8',
477
+ '#EA4335',
478
+ '#FB8C00',
479
+ '#FDD835',
480
+ '#34A853',
481
+ '#00ACC1',
482
+ '#4285F4',
483
+ '#5E35B1',
484
+ '#8E24AA',
485
+ '#C2185B',
486
+ '#6D4C41'
487
+ ];
488
+ /** Special-character map, grouped for the two-pane picker (tabs left, glyphs right).
489
+ * `CHARS` below is the legacy flat list and is kept exported for back-compat. */
490
+ const CHAR_CATEGORIES = [
491
+ {
492
+ key: 'chcat_currency',
493
+ chars: [
494
+ '€', '£', '¥', '¢', '$', '₹', '₽', '₩', '₺', '₴', '₦', '฿', '₫', '₪', '﷼', '¤',
495
+ '₡', '₱', '₲', '₵', '₭', '₮', '₸', '₼', '₾', '₿'
496
+ ]
497
+ },
498
+ {
499
+ key: 'chcat_text',
500
+ chars: [
501
+ '§', '¶', '†', '‡', '•', '…', '′', '″', '‰', '‱', '–', '—', '―', '¡', '¿', '·',
502
+ '‹', '›', '«', '»', '“', '”', '‘', '’', '„', '‚', '¦', 'ª', 'º',
503
+ '‽', '※', '❝', '❞', '❮', '❯'
504
+ ]
505
+ },
506
+ {
507
+ key: 'chcat_math',
508
+ chars: [
509
+ '°', '±', '×', '÷', '≈', '≠', '≤', '≥', '∞', 'µ', '√', '∑', '∫', '∂', '∆', '∏',
510
+ 'π', 'Ω', 'α', 'β', 'γ', 'δ', 'ε', 'θ', 'λ', 'σ', 'φ', 'ψ', 'ω',
511
+ '∈', '∉', '∪', '∩', '⊂', '⊃', '⊆', '⊇', '∀', '∃', '∅', '∝', '∴', '∵', '¬', '∧', '∨',
512
+ '½', '⅓', '¼', '¾', '⅔', '⅛', '⅜', '⅝', '⅞',
513
+ '¹', '²', '³', '⁴', '⁵', '⁶', '⁷', '⁸', '⁹', '⁰',
514
+ '₁', '₂', '₃', '₄', '₅', '₆', '₇', '₈', '₉', '₀'
515
+ ]
516
+ },
517
+ {
518
+ key: 'chcat_arrows',
519
+ chars: [
520
+ '←', '→', '↑', '↓', '↔', '↕', '⇐', '⇒', '⇑', '⇓', '⇔',
521
+ '↖', '↗', '↘', '↙', '↩', '↪', '↺', '↻',
522
+ '⤴', '⤵', '➜', '➝', '➞', '➟', '⇦', '⇨', '⇧', '⇩'
523
+ ]
524
+ },
525
+ {
526
+ key: 'chcat_symbols',
527
+ chars: [
528
+ '©', '®', '™', '℠', '№', '℮',
529
+ '✓', '✔', '✗', '✘',
530
+ '★', '☆', '✦', '✪',
531
+ '♠', '♣', '♥', '♦',
532
+ '♪', '♫', '♬',
533
+ '☀', '☁', '☂', '☃', '☾',
534
+ '☹', '☺', '☻',
535
+ '✉', '✂', '✎', '✏',
536
+ '☎', '☏', '⌛', '⌚',
537
+ '☞', '☜', '☝', '☟'
538
+ ]
539
+ },
540
+ {
541
+ key: 'chcat_latin',
542
+ chars: [
543
+ 'À', 'Á', 'Â', 'Ã', 'Ä', 'Å', 'Æ', 'Ç', 'È', 'É', 'Ê', 'Ë', 'Ì', 'Í', 'Î', 'Ï',
544
+ 'Ð', 'Ñ', 'Ò', 'Ó', 'Ô', 'Õ', 'Ö', 'Ø', 'Ù', 'Ú', 'Û', 'Ü', 'Ý', 'Þ', 'ß',
545
+ 'à', 'á', 'â', 'ã', 'ä', 'å', 'æ', 'ç', 'è', 'é', 'ê', 'ë', 'ì', 'í', 'î', 'ï',
546
+ 'ð', 'ñ', 'ò', 'ó', 'ô', 'õ', 'ö', 'ø', 'ù', 'ú', 'û', 'ü', 'ý', 'þ', 'ÿ',
547
+ 'Œ', 'œ', 'Š', 'š', 'Ž', 'ž', 'Ł', 'ł'
548
+ ]
549
+ },
550
+ {
551
+ key: 'chcat_arabic',
552
+ chars: [
553
+ '؟', '،', '؛', '٪', '٭', 'ـ',
554
+ '٠', '١', '٢', '٣', '٤', '٥', '٦', '٧', '٨', '٩',
555
+ '﴾', '﴿', '۞', '؎',
556
+ 'ء', 'آ', 'أ', 'ؤ', 'إ', 'ئ', 'ى', 'ة'
557
+ ]
558
+ }
559
+ ];
560
+ /** Emoji map for the emoji picker — same two-pane dialog as the character map. */
561
+ const EMOJI_CATEGORIES = [
562
+ {
563
+ key: 'emcat_smileys',
564
+ chars: [
565
+ '😀', '😃', '😄', '😁', '😆', '😅', '😂', '🤣', '😊', '😇', '🙂', '🙃', '😉', '😌', '😍', '🥰',
566
+ '😘', '😋', '😛', '😜', '🤪', '🤨', '🧐', '🤓', '😎', '🥳', '😏', '😒', '😞', '😔', '😟', '😕',
567
+ '🙁', '😣', '😫', '😩', '🥺', '😢', '😭', '😤', '😠', '😡', '🤯', '😳', '🥵', '🥶', '😱', '😨',
568
+ '😥', '🤗', '🤔', '🤭', '🤫', '😐', '😑', '😬', '🙄', '😯', '😴', '😪', '😷', '🤒', '🤕', '🤢',
569
+ '🤧', '😈', '💀', '👻', '👽', '🤖', '💩', '😺', '😸', '😹', '😻', '🙀'
570
+ ]
571
+ },
572
+ {
573
+ key: 'emcat_people',
574
+ chars: [
575
+ '👋', '🤚', '✋', '🖖', '👌', '🤏', '✌', '🤞', '🤟', '🤘', '🤙', '👈', '👉', '👆', '👇', '☝',
576
+ '👍', '👎', '✊', '👊', '👏', '🙌', '👐', '🤲', '🤝', '🙏', '💪', '✍', '💅', '🤳', '👂', '👃',
577
+ '👀', '👁', '🧠', '👶', '🧒', '👦', '👧', '🧑', '👨', '👩', '🧓', '👴', '👵', '👮', '👷', '💂',
578
+ '🕵', '👸', '🤴', '👰', '🤵', '🤰', '👼', '🎅', '🦸', '🦹', '🧙', '🧚', '🧛', '🧟', '💃', '🕺',
579
+ '🚶', '🏃', '🧘'
580
+ ]
581
+ },
582
+ {
583
+ key: 'emcat_animals',
584
+ chars: [
585
+ '🐶', '🐱', '🐭', '🐹', '🐰', '🦊', '🐻', '🐼', '🐨', '🐯', '🦁', '🐮', '🐷', '🐸', '🐵', '🐔',
586
+ '🐧', '🐦', '🐤', '🦆', '🦅', '🦉', '🐺', '🐗', '🐴', '🦄', '🐝', '🐛', '🦋', '🐌', '🐞', '🐢',
587
+ '🐍', '🦎', '🦖', '🐙', '🦑', '🦀', '🐠', '🐟', '🐬', '🐳', '🦈', '🐊', '🐅', '🦓', '🦍', '🐘',
588
+ '🦏', '🐪', '🦒', '🦘', '🐄', '🐎', '🐖', '🐑', '🐐', '🦌', '🐕', '🐈', '🐓', '🦃', '🦚', '🦜',
589
+ '🦢', '🕊', '🐇', '🦔', '🌵', '🌲', '🌴', '🌱', '🌿', '☘', '🍀', '🍁', '🌸', '🌺', '🌻', '🌹',
590
+ '🌷', '💐'
591
+ ]
592
+ },
593
+ {
594
+ key: 'emcat_food',
595
+ chars: [
596
+ '🍏', '🍎', '🍐', '🍊', '🍋', '🍌', '🍉', '🍇', '🍓', '🍈', '🍒', '🍑', '🥭', '🍍', '🥥', '🥝',
597
+ '🍅', '🍆', '🥑', '🥦', '🥒', '🌶', '🌽', '🥕', '🥔', '🥐', '🍞', '🥖', '🥨', '🧀', '🥚', '🍳',
598
+ '🥞', '🥓', '🥩', '🍗', '🍖', '🌭', '🍔', '🍟', '🍕', '🥪', '🌮', '🌯', '🥗', '🍝', '🍜', '🍲',
599
+ '🍛', '🍣', '🍱', '🥟', '🍤', '🍚', '🍧', '🍨', '🍦', '🥧', '🧁', '🍰', '🎂', '🍮', '🍭', '🍬',
600
+ '🍫', '🍿', '🍩', '🍪', '🥜', '🍯', '🥛', '☕', '🍵', '🥤', '🍺', '🥂', '🍷', '🍸', '🍹'
601
+ ]
602
+ },
603
+ {
604
+ key: 'emcat_travel',
605
+ chars: [
606
+ '🚗', '🚕', '🚙', '🚌', '🏎', '🚓', '🚑', '🚒', '🚚', '🚜', '🛵', '🏍', '🚲', '🛴', '🚔', '🚃',
607
+ '🚄', '🚅', '🚂', '🚆', '🚇', '🚉', '✈', '🛫', '🛬', '💺', '🚁', '🚀', '🛸', '🚢', '⛵', '🚤',
608
+ '⚓', '⛽', '🚧', '🗺', '🗿', '🗽', '🗼', '🏰', '🏯', '🎡', '🎢', '🎠', '⛲', '🏖', '🏝', '🌋',
609
+ '⛰', '🏔', '🗻', '🏕', '⛺', '🏠', '🏡', '🏢', '🏬', '🏥', '🏦', '🏨', '🏪', '🏫', '💒', '⛪',
610
+ '🕌', '🕍', '🕋', '⛩', '🌅', '🌄', '🌇', '🌆', '🌃', '🌉', '🌌', '🌠'
611
+ ]
612
+ },
613
+ {
614
+ key: 'emcat_activities',
615
+ chars: [
616
+ '⚽', '🏀', '🏈', '⚾', '🎾', '🏐', '🏉', '🎱', '🏓', '🏸', '🏒', '🏑', '🏏', '⛳', '🏹', '🎣',
617
+ '🥊', '🥋', '⛸', '🎿', '⛷', '🏂', '🏋', '🤸', '⛹', '🤺', '🏇', '🧗', '🏄', '🏊', '🚣', '🚵',
618
+ '🚴', '🏆', '🥇', '🥈', '🥉', '🏅', '🎖', '🎫', '🎪', '🤹', '🎭', '🎨', '🎬', '🎤', '🎧', '🎼',
619
+ '🎹', '🥁', '🎷', '🎺', '🎸', '🎻', '🎲', '🎯', '🎳', '🎮', '🎰', '🧩'
620
+ ]
621
+ },
622
+ {
623
+ key: 'emcat_objects',
624
+ chars: [
625
+ '⌚', '📱', '💻', '⌨', '🖥', '🖨', '🖱', '🕹', '💽', '💾', '💿', '📀', '📷', '📸', '📹', '🎥',
626
+ '📞', '☎', '📺', '📻', '🎙', '⏱', '⏰', '⌛', '⏳', '📡', '🔋', '🔌', '💡', '🔦', '🕯', '💸',
627
+ '💵', '💰', '💳', '💎', '⚖', '🔧', '🔨', '⚒', '🛠', '⛏', '🔩', '⚙', '🧲', '🔫', '💣', '🔪',
628
+ '🛡', '🔮', '🧿', '🔭', '🔬', '💊', '💉', '🌡', '🧬', '🧪', '🧹', '🧺', '🛁', '🔑', '🗝', '🚪',
629
+ '🛋', '🛏', '🧸', '🖼', '🛍', '🛒', '🎁', '🎈', '🎀', '🎊', '🎉', '✉', '📩', '📦', '📜', '📄',
630
+ '📊', '📈', '📉', '📅', '📋', '📁', '📰', '📓', '📚', '📖', '🔖', '🔗', '📎', '📐', '📏', '📌',
631
+ '📍', '✂', '🖊', '✒', '🖌', '🖍', '📝', '✏', '🔍', '🔒', '🔓'
632
+ ]
633
+ },
634
+ {
635
+ key: 'emcat_symbols',
636
+ chars: [
637
+ '❤', '🧡', '💛', '💚', '💙', '💜', '🖤', '💔', '❣', '💕', '💞', '💓', '💗', '💖', '💘', '💝',
638
+ '☮', '✝', '☪', '🕉', '☸', '✡', '☯', '♈', '♉', '♊', '♋', '♌', '♍', '♎', '♏', '♐',
639
+ '♑', '♒', '♓', '⚛', '❌', '⭕', '🛑', '⛔', '🚫', '💯', '💢', '⚠', '🚸', '🔱', '⚜', '🔰',
640
+ '♻', '✅', '❇', '✳', '❎', '🌐', '💠', '🌀', '💤', '🏧', '♿', '🚭', '❗', '❕', '❓', '❔',
641
+ '‼', '⁉', '💲', '🔅', '🔆', '〽', 'ℹ', '🆗', '🆕', '🆓', '🆒', '🆙', '🈯', '🈚'
642
+ ]
643
+ },
644
+ {
645
+ key: 'emcat_flags',
646
+ chars: [
647
+ '🏁', '🚩', '🎌', '🏴', '🏳', '🇦🇪', '🇸🇦', '🇶🇦', '🇰🇼', '🇧🇭', '🇴🇲', '🇪🇬', '🇯🇴', '🇱🇧', '🇮🇳', '🇵🇰',
648
+ '🇺🇸', '🇬🇧', '🇨🇦', '🇦🇺', '🇫🇷', '🇩🇪', '🇮🇹', '🇪🇸', '🇵🇹', '🇳🇱', '🇧🇪', '🇨🇭', '🇸🇪', '🇳🇴', '🇩🇰', '🇫🇮',
649
+ '🇮🇪', '🇬🇷', '🇹🇷', '🇷🇺', '🇺🇦', '🇵🇱', '🇯🇵', '🇰🇷', '🇨🇳', '🇸🇬', '🇲🇾', '🇮🇩', '🇹🇭', '🇵🇭', '🇻🇳', '🇧🇷',
650
+ '🇦🇷', '🇲🇽', '🇿🇦', '🇳🇬', '🇰🇪', '🇲🇦'
651
+ ]
652
+ }
364
653
  ];
365
654
  const CHARS = [
366
655
  '©',
@@ -503,7 +792,17 @@ const IC = {
503
792
  ol_alpha: LIST(['a', 'b', 'c']),
504
793
  ol_roman: LIST(['i', 'ii', 'iii'], 6.5),
505
794
  ol_alphau: LIST(['A', 'B', 'C']),
506
- ol_romanu: LIST(['I', 'II', 'III'], 6.5)
795
+ ol_romanu: LIST(['I', 'II', 'III'], 6.5),
796
+ video: S('<rect x="3" y="6" width="13" height="12" rx="1.5"/><path d="M16 10l5-3v10l-5-3z"/>'),
797
+ 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"/>'),
798
+ letterspacing: S('<text x="2.5" y="13" font-size="12" stroke="none" fill="#222f3e" font-family="Arial" font-weight="bold">A</text><text x="13" y="13" font-size="12" stroke="none" fill="#222f3e" font-family="Arial" font-weight="bold">B</text><path d="M7 18.5h10"/><path class="f" d="M7.5 18.5 4.5 16.6v3.8z"/><path class="f" d="M16.5 18.5l3-1.9v3.8z"/>'),
799
+ 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"/>'),
800
+ inlinecodeic: S('<path d="M8.5 8 4.5 12l4 4M15.5 8l4 4-4 4"/><path d="M13 6l-2 12"/>'),
801
+ moverowup: S('<rect x="4" y="15.5" width="16" height="5" rx="1"/><path d="M12 12.5V4M8.5 7.5 12 4l3.5 3.5"/>'),
802
+ moverowdown: S('<rect x="4" y="3.5" width="16" height="5" rx="1"/><path d="M12 11.5V20M8.5 16.5 12 20l3.5-3.5"/>'),
803
+ movecolleft: S('<rect x="15.5" y="4" width="5" height="16" rx="1"/><path d="M12.5 12H4M7.5 8.5 4 12l3.5 3.5"/>'),
804
+ movecolright: S('<rect x="3.5" y="4" width="5" height="16" rx="1"/><path d="M11.5 12H20M16.5 8.5 20 12l-3.5 3.5"/>'),
805
+ codesampleic: S('<rect x="3.5" y="4" width="17" height="16" rx="2"/><path d="M3.5 8.5h17"/><path d="M9.5 12 7 14.25l2.5 2.25M14.5 12l2.5 2.25-2.5 2.25"/>')
507
806
  };
508
807
  const TXT = (html) => `<span class="txt">${html}</span>`;
509
808
 
@@ -1394,6 +1693,82 @@ const TPL_ICON = {
1394
1693
  'img-top': IC.tpltop,
1395
1694
  'img-center': IC.tplcenter
1396
1695
  };
1696
+ /* ---------------------------------------------------------- tooltips
1697
+ One shared bubble + one delegated document listener restyles every native
1698
+ `title` tooltip in the editor UI (toolbar, menus, popups, context toolbars,
1699
+ dialogs) without extra wrapper markup per control. Titles inside the
1700
+ editable area are user content and keep the browser default. */
1701
+ const TIP_SCOPE = '.tox,.pop,.tblctx,.imgctx,.selctx,.dlg';
1702
+ let tipEl = null;
1703
+ let tipTarget = null;
1704
+ let tipTimer = 0;
1705
+ let tipsInstalled = false;
1706
+ function hideTip() {
1707
+ clearTimeout(tipTimer);
1708
+ tipTarget = null;
1709
+ if (tipEl)
1710
+ tipEl.classList.remove('show');
1711
+ }
1712
+ function showTip(el) {
1713
+ const text = el.dataset.tip;
1714
+ if (!text)
1715
+ return;
1716
+ tipTarget = el;
1717
+ clearTimeout(tipTimer);
1718
+ // moving along a toolbar re-anchors an already-visible tip almost instantly
1719
+ const delay = tipEl && tipEl.classList.contains('show') ? 60 : 350;
1720
+ tipTimer = window.setTimeout(() => {
1721
+ if (tipTarget !== el || !document.contains(el))
1722
+ return;
1723
+ if (!tipEl) {
1724
+ tipEl = document.createElement('div');
1725
+ tipEl.className = 'etip';
1726
+ document.body.appendChild(tipEl);
1727
+ }
1728
+ tipEl.textContent = text;
1729
+ tipEl.classList.add('show');
1730
+ const r = el.getBoundingClientRect();
1731
+ const w = tipEl.offsetWidth;
1732
+ const h = tipEl.offsetHeight;
1733
+ let x = r.left + r.width / 2 - w / 2;
1734
+ x = Math.max(6, Math.min(x, innerWidth - w - 6));
1735
+ let y = r.bottom + 7;
1736
+ if (y + h > innerHeight - 6)
1737
+ y = r.top - h - 7;
1738
+ tipEl.style.left = x + 'px';
1739
+ tipEl.style.top = y + 'px';
1740
+ }, delay);
1741
+ }
1742
+ function installTooltips() {
1743
+ if (tipsInstalled)
1744
+ return;
1745
+ tipsInstalled = true;
1746
+ document.addEventListener('mouseover', (e) => {
1747
+ const raw = e.target;
1748
+ const el = raw.closest ? raw.closest('[title],[data-tip]') : null;
1749
+ if (!el || !el.closest(TIP_SCOPE) || el.closest('.earea')) {
1750
+ if (el !== tipTarget)
1751
+ hideTip();
1752
+ return;
1753
+ }
1754
+ const title = el.getAttribute('title');
1755
+ if (title) {
1756
+ // hand the text to the custom bubble and disarm the native one
1757
+ el.dataset.tip = title;
1758
+ el.removeAttribute('title');
1759
+ if (!el.getAttribute('aria-label'))
1760
+ el.setAttribute('aria-label', title);
1761
+ }
1762
+ if (el !== tipTarget)
1763
+ showTip(el);
1764
+ });
1765
+ document.addEventListener('mouseout', (e) => {
1766
+ if (tipTarget && !tipTarget.contains(e.relatedTarget))
1767
+ hideTip();
1768
+ });
1769
+ document.addEventListener('mousedown', hideTip, true);
1770
+ addEventListener('scroll', hideTip, true);
1771
+ }
1397
1772
  class FableEditor {
1398
1773
  static instanceCounter = 0;
1399
1774
  options;
@@ -1432,7 +1807,22 @@ class FableEditor {
1432
1807
  imageUploadHandler;
1433
1808
  onImageUploadError;
1434
1809
  contentStyle;
1810
+ vidInput;
1811
+ /* a <video> element or a .video-embed iframe wrapper — both share the
1812
+ same selection outline + align/delete context toolbar */
1813
+ vidActive = null;
1814
+ vidCtx = null;
1815
+ vidHideTimer = null;
1816
+ vphActive = null;
1817
+ vphCtx = null;
1818
+ vphUploadTarget = null;
1819
+ videoUploadHandler;
1820
+ onVideoUploadError;
1435
1821
  selCtx = null;
1822
+ tbrMore = null;
1823
+ tbrExpanded = false;
1824
+ codeActive = null;
1825
+ codeCtx = null;
1436
1826
  tplActive = null;
1437
1827
  tplCtx = null;
1438
1828
  docInput;
@@ -1456,11 +1846,15 @@ class FableEditor {
1456
1846
  readonly: options.readonly ?? false,
1457
1847
  draftKey: options.draftKey ?? (typeof location !== 'undefined' ? location.pathname : 'default'),
1458
1848
  fontFamilyFormats: options.fontFamilyFormats ?? FONTS,
1459
- imageFileTypes: options.imageFileTypes ?? ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/bmp', 'image/webp']
1849
+ imageFileTypes: options.imageFileTypes ?? ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/bmp', 'image/webp'],
1850
+ videoFileTypes: options.videoFileTypes ?? ['video/mp4', 'video/webm', 'video/ogg']
1460
1851
  };
1461
1852
  this.imageUploadHandler = options.imageUploadHandler;
1462
1853
  this.onImageUploadError = options.onImageUploadError;
1854
+ this.videoUploadHandler = options.videoUploadHandler;
1855
+ this.onVideoUploadError = options.onVideoUploadError;
1463
1856
  this.contentStyle = options.contentStyle;
1857
+ this.applyTheme(options);
1464
1858
  this.lang = this.options.language;
1465
1859
  this.draftStorageKey = 'tmclone-draft:' + this.options.draftKey;
1466
1860
  this.initDOM();
@@ -1519,6 +1913,7 @@ class FableEditor {
1519
1913
  this.shell.appendChild(this.contentStyleEl);
1520
1914
  }
1521
1915
  target.appendChild(this.shell);
1916
+ installTooltips();
1522
1917
  if (!this.options.statusbar)
1523
1918
  this.statusbar.style.display = 'none';
1524
1919
  this.imgInput = document.createElement('input');
@@ -1526,12 +1921,29 @@ class FableEditor {
1526
1921
  this.imgInput.accept = this.options.imageFileTypes.join(',');
1527
1922
  this.imgInput.style.display = 'none';
1528
1923
  target.appendChild(this.imgInput);
1924
+ this.vidInput = document.createElement('input');
1925
+ this.vidInput.type = 'file';
1926
+ this.vidInput.accept = this.options.videoFileTypes.join(',');
1927
+ this.vidInput.style.display = 'none';
1928
+ target.appendChild(this.vidInput);
1529
1929
  this.docInput = document.createElement('input');
1530
1930
  this.docInput.type = 'file';
1531
1931
  this.docInput.accept = '.docx,application/vnd.openxmlformats-officedocument.wordprocessingml.document';
1532
1932
  this.docInput.style.display = 'none';
1533
1933
  target.appendChild(this.docInput);
1534
1934
  }
1935
+ /** Theme options land as CSS variables on the document root rather than the editor
1936
+ * shell, because dialogs, popups and floating context toolbars are appended to
1937
+ * document.body and would not inherit variables scoped to the shell. */
1938
+ applyTheme(options) {
1939
+ const root = document.documentElement;
1940
+ if (options.primaryColor)
1941
+ root.style.setProperty('--fable-primary', options.primaryColor);
1942
+ if (options.toolbarGroupBackground)
1943
+ root.style.setProperty('--fable-tgrp-bg', options.toolbarGroupBackground);
1944
+ if (options.uiFontFamily)
1945
+ root.style.setProperty('--fable-ui-font', options.uiFontFamily);
1946
+ }
1535
1947
  /** Rewrites the literal word `body` in a user-supplied `contentStyle` string to a
1536
1948
  * selector scoped to this instance's content div. Everything else passes through
1537
1949
  * verbatim, since `.ed` is a plain div in the host page (not an iframe) and an
@@ -1594,6 +2006,9 @@ class FableEditor {
1594
2006
  this.positionImageHandles();
1595
2007
  this.positionImgPhCtx();
1596
2008
  this.positionTplCtx();
2009
+ this.positionVphCtx();
2010
+ this.positionVidCtx();
2011
+ this.positionCodeCtx();
1597
2012
  });
1598
2013
  this.on(this.ed, 'keydown', (e) => {
1599
2014
  if (e.altKey && e.key === '0') {
@@ -1604,18 +2019,32 @@ class FableEditor {
1604
2019
  e.preventDefault();
1605
2020
  this.removeImgPlaceholder();
1606
2021
  }
2022
+ if ((e.key === 'Delete' || e.key === 'Backspace') && this.vphActive) {
2023
+ e.preventDefault();
2024
+ this.removeVphPlaceholder();
2025
+ }
2026
+ if (e.key === '`' && !e.ctrlKey && !e.metaKey && !e.altKey)
2027
+ this.tryCodeTyping(e);
1607
2028
  });
1608
2029
  this.on(this.ed, 'paste', (e) => this.handlePaste(e));
1609
2030
  this.on(this.ed, 'drop', (e) => {
1610
2031
  /* content drops stay blocked (powerpaste_block_drop) — only an
1611
- image file dropped onto an upload placeholder is consumed */
2032
+ image/video file dropped onto its matching upload placeholder is consumed */
1612
2033
  e.preventDefault();
1613
- const ph = e.target.closest?.('.img-ph');
1614
- if (!ph || !this.ed.contains(ph) || this.options.readonly)
2034
+ const target = e.target;
2035
+ const ph = target.closest?.('.img-ph:not(.vid-ph)');
2036
+ if (ph && this.ed.contains(ph) && !this.options.readonly) {
2037
+ const file = Array.from(e.dataTransfer?.files || []).find((f) => f.type.startsWith('image/'));
2038
+ if (file)
2039
+ this.readImageFileInto(file, ph);
1615
2040
  return;
1616
- const file = Array.from(e.dataTransfer?.files || []).find((f) => f.type.startsWith('image/'));
1617
- if (file)
1618
- this.readImageFileInto(file, ph);
2041
+ }
2042
+ const vph = target.closest?.('.vid-ph');
2043
+ if (vph && this.ed.contains(vph) && !this.options.readonly) {
2044
+ const file = Array.from(e.dataTransfer?.files || []).find((f) => f.type.startsWith('video/'));
2045
+ if (file)
2046
+ this.readVideoFileInto(file, vph);
2047
+ }
1619
2048
  });
1620
2049
  this.on(this.ed, 'dragover', (e) => e.preventDefault());
1621
2050
  this.on(this.ed, 'mousedown', (e) => {
@@ -1625,7 +2054,7 @@ class FableEditor {
1625
2054
  this.selectTableForResize(table);
1626
2055
  else
1627
2056
  this.clearTableHandles();
1628
- const ph = target.closest?.('.img-ph');
2057
+ const ph = target.closest?.('.img-ph:not(.vid-ph)');
1629
2058
  if (ph && this.ed.contains(ph) && !this.options.readonly) {
1630
2059
  e.preventDefault();
1631
2060
  this.ed.focus();
@@ -1634,11 +2063,28 @@ class FableEditor {
1634
2063
  else {
1635
2064
  this.clearImgPlaceholderSel();
1636
2065
  }
2066
+ const vph = target.closest?.('.vid-ph');
2067
+ if (vph && this.ed.contains(vph) && !this.options.readonly) {
2068
+ e.preventDefault();
2069
+ this.ed.focus();
2070
+ this.selectVphPlaceholder(vph);
2071
+ }
2072
+ else {
2073
+ this.clearVphPlaceholderSel();
2074
+ }
1637
2075
  const tpl = target.closest?.('.tpl');
1638
2076
  if (tpl && this.ed.contains(tpl) && !this.options.readonly)
1639
2077
  this.selectTemplate(tpl);
1640
2078
  else
1641
2079
  this.clearTplSel();
2080
+ const codeBlk = target.closest?.('pre.code-block');
2081
+ if (codeBlk && this.ed.contains(codeBlk) && !this.options.readonly) {
2082
+ e.preventDefault();
2083
+ this.selectCodeEl(codeBlk);
2084
+ }
2085
+ else {
2086
+ this.clearCodeSel();
2087
+ }
1642
2088
  const img = target.closest?.('img');
1643
2089
  if (img && this.ed.contains(img) && !this.options.readonly) {
1644
2090
  e.preventDefault();
@@ -1647,6 +2093,14 @@ class FableEditor {
1647
2093
  else {
1648
2094
  this.clearImageHandles();
1649
2095
  }
2096
+ const vid = target.closest?.('video, .video-embed');
2097
+ if (vid && this.ed.contains(vid) && !this.options.readonly) {
2098
+ e.preventDefault();
2099
+ this.selectVideo(vid);
2100
+ }
2101
+ else {
2102
+ this.clearVidHandles();
2103
+ }
1650
2104
  });
1651
2105
  this.on(this.ed, 'mouseover', (e) => {
1652
2106
  if (this.options.readonly)
@@ -1674,12 +2128,18 @@ class FableEditor {
1674
2128
  this.positionImageHandles();
1675
2129
  this.positionImgPhCtx();
1676
2130
  this.positionTplCtx();
2131
+ this.positionVphCtx();
2132
+ this.positionVidCtx();
2133
+ this.positionCodeCtx();
1677
2134
  });
1678
2135
  this.onWin('scroll', () => {
1679
2136
  this.positionTableHandles();
1680
2137
  this.positionImageHandles();
1681
2138
  this.positionImgPhCtx();
1682
2139
  this.positionTplCtx();
2140
+ this.positionVphCtx();
2141
+ this.positionVidCtx();
2142
+ this.positionCodeCtx();
1683
2143
  this.repositionSelToolbar();
1684
2144
  }, true);
1685
2145
  this.onWin('resize', () => {
@@ -1687,8 +2147,19 @@ class FableEditor {
1687
2147
  this.positionImageHandles();
1688
2148
  this.positionImgPhCtx();
1689
2149
  this.positionTplCtx();
2150
+ this.positionVphCtx();
2151
+ this.positionVidCtx();
2152
+ this.positionCodeCtx();
1690
2153
  this.repositionSelToolbar();
2154
+ this.updateToolbarOverflow();
1691
2155
  });
2156
+ /* container-driven, not just viewport-driven: an editor inside a resizable
2157
+ panel collapses/expands its toolbar as its own width changes */
2158
+ if (typeof ResizeObserver !== 'undefined') {
2159
+ const ro = new ResizeObserver(() => this.updateToolbarOverflow());
2160
+ ro.observe(this.shell);
2161
+ this.listeners.push(() => ro.disconnect());
2162
+ }
1692
2163
  this.on(this.imgInput, 'change', () => {
1693
2164
  const file = this.imgInput.files?.[0];
1694
2165
  if (!file)
@@ -1707,6 +2178,24 @@ class FableEditor {
1707
2178
  };
1708
2179
  fr.readAsDataURL(file);
1709
2180
  });
2181
+ this.on(this.vidInput, 'change', () => {
2182
+ const file = this.vidInput.files?.[0];
2183
+ if (!file)
2184
+ return;
2185
+ const vph = this.vphUploadTarget;
2186
+ this.vphUploadTarget = null;
2187
+ if (vph && this.ed.contains(vph)) {
2188
+ this.readVideoFileInto(file, vph);
2189
+ return;
2190
+ }
2191
+ const fr = new FileReader();
2192
+ fr.onload = () => {
2193
+ this.restoreSel();
2194
+ document.execCommand('insertHTML', false, `<video controls src="${fr.result}" title="${file.name.replace(/"/g, '')}"></video>`);
2195
+ this.onChange();
2196
+ };
2197
+ fr.readAsDataURL(file);
2198
+ });
1710
2199
  this.on(this.docInput, 'change', () => {
1711
2200
  const file = this.docInput.files?.[0];
1712
2201
  if (!file)
@@ -1723,6 +2212,9 @@ class FableEditor {
1723
2212
  (this.phCtx && this.phCtx.contains(e.target)) ||
1724
2213
  (this.tplCtx && this.tplCtx.contains(e.target)) ||
1725
2214
  (this.selCtx && this.selCtx.contains(e.target)) ||
2215
+ (this.vphCtx && this.vphCtx.contains(e.target)) ||
2216
+ (this.vidCtx && this.vidCtx.contains(e.target)) ||
2217
+ (this.codeCtx && this.codeCtx.contains(e.target)) ||
1726
2218
  (this.openPop && this.openPop.contains(e.target)) ||
1727
2219
  (this.openSubEl && this.openSubEl.contains(e.target)))
1728
2220
  return;
@@ -1730,6 +2222,9 @@ class FableEditor {
1730
2222
  this.clearImageHandles();
1731
2223
  this.clearImgPlaceholderSel();
1732
2224
  this.clearTplSel();
2225
+ this.clearVphPlaceholderSel();
2226
+ this.clearVidHandles();
2227
+ this.clearCodeSel();
1733
2228
  this.clearSelToolbar();
1734
2229
  });
1735
2230
  }
@@ -1756,6 +2251,9 @@ class FableEditor {
1756
2251
  this.clearImageHandles();
1757
2252
  this.clearImgPlaceholderSel();
1758
2253
  this.clearTplSel();
2254
+ this.clearVphPlaceholderSel();
2255
+ this.clearVidHandles();
2256
+ this.clearCodeSel();
1759
2257
  this.clearSelToolbar();
1760
2258
  this.onChange();
1761
2259
  }
@@ -1818,6 +2316,9 @@ class FableEditor {
1818
2316
  this.clearImageHandles();
1819
2317
  this.clearImgPlaceholderSel();
1820
2318
  this.clearTplSel();
2319
+ this.clearVphPlaceholderSel();
2320
+ this.clearVidHandles();
2321
+ this.clearCodeSel();
1821
2322
  this.clearSelToolbar();
1822
2323
  this.listeners.forEach((fn) => fn());
1823
2324
  this.listeners = [];
@@ -1825,6 +2326,8 @@ class FableEditor {
1825
2326
  this.shell.parentNode.removeChild(this.shell);
1826
2327
  if (this.imgInput && this.imgInput.parentNode)
1827
2328
  this.imgInput.parentNode.removeChild(this.imgInput);
2329
+ if (this.vidInput && this.vidInput.parentNode)
2330
+ this.vidInput.parentNode.removeChild(this.vidInput);
1828
2331
  if (this.docInput && this.docInput.parentNode)
1829
2332
  this.docInput.parentNode.removeChild(this.docInput);
1830
2333
  this.contentStyleEl = null;
@@ -2015,14 +2518,14 @@ class FableEditor {
2015
2518
  sub.style.top = y + 'px';
2016
2519
  this.openSubEl = sub;
2017
2520
  }
2018
- popup(anchor, build) {
2521
+ popup(anchor, build, cls) {
2019
2522
  if (this.openPop && this.popAnchor === anchor) {
2020
2523
  this.closePop();
2021
2524
  return;
2022
2525
  }
2023
2526
  this.closePop();
2024
2527
  const el = document.createElement('div');
2025
- el.className = 'pop';
2528
+ el.className = 'pop' + (cls ? ' ' + cls : '');
2026
2529
  el.dir = this.dir();
2027
2530
  build(el);
2028
2531
  document.body.appendChild(el);
@@ -2204,53 +2707,61 @@ class FableEditor {
2204
2707
  el.appendChild(grid);
2205
2708
  });
2206
2709
  }
2207
- colorMenu(anchor, kind) {
2208
- this.popup(anchor, (el) => {
2209
- const grid = document.createElement('div');
2210
- grid.className = 'cpal';
2211
- COLORS.forEach((c) => {
2212
- const b = document.createElement('button');
2213
- b.style.background = c;
2214
- b.title = c;
2215
- b.addEventListener('click', () => {
2216
- this.closePop();
2217
- this.applyColor(kind, c);
2218
- });
2219
- grid.appendChild(b);
2220
- });
2221
- el.appendChild(grid);
2222
- el.insertAdjacentHTML('beforeend', '<div class="sep"></div>');
2223
- const foot = document.createElement('div');
2224
- foot.className = 'cfoot';
2225
- const cur = document.createElement('div');
2226
- cur.className = 'cswatch cur';
2227
- cur.title = this.t('currentcolor');
2228
- const curColor = kind === 'fore' ? this.foreColor : this.backColor;
2229
- cur.style.background = /^#[0-9a-f]{6}$/i.test(curColor) ? curColor : '#000000';
2230
- cur.innerHTML = IC.check;
2231
- const nocolor = document.createElement('button');
2232
- nocolor.type = 'button';
2233
- nocolor.className = 'cswatch nocolor';
2234
- nocolor.title = this.t('removecolor');
2235
- nocolor.addEventListener('click', () => {
2236
- this.closePop();
2237
- this.applyColor(kind, null);
2238
- });
2239
- const wheel = document.createElement('label');
2240
- wheel.className = 'cswatch cwheel';
2241
- wheel.title = this.t('customcolor');
2242
- wheel.innerHTML = IC.palette;
2243
- const picker = document.createElement('input');
2244
- picker.type = 'color';
2245
- picker.value = /^#[0-9a-f]{6}$/i.test(curColor) ? curColor : '#000000';
2246
- picker.addEventListener('change', () => {
2710
+ /** Shared color-picker popup body — swatch grid plus the current-color /
2711
+ * no-color / custom-picker footer — so the fore/back toolbar menus and the
2712
+ * table cell-background menu all show the identical popup. */
2713
+ buildPaletteInto(el, curColor, apply) {
2714
+ const grid = document.createElement('div');
2715
+ grid.className = 'cpal';
2716
+ COLORS.forEach((c) => {
2717
+ const b = document.createElement('button');
2718
+ b.type = 'button';
2719
+ b.style.background = c;
2720
+ b.title = c;
2721
+ b.addEventListener('click', () => {
2247
2722
  this.closePop();
2248
- this.applyColor(kind, picker.value);
2723
+ apply(c);
2249
2724
  });
2250
- wheel.appendChild(picker);
2251
- foot.append(cur, nocolor, wheel);
2252
- el.appendChild(foot);
2725
+ grid.appendChild(b);
2253
2726
  });
2727
+ el.appendChild(grid);
2728
+ el.insertAdjacentHTML('beforeend', '<div class="sep"></div>');
2729
+ const foot = document.createElement('div');
2730
+ foot.className = 'cfoot';
2731
+ const hex = /^#[0-9a-f]{6}$/i.test(curColor) ? curColor : '#000000';
2732
+ const cur = document.createElement('div');
2733
+ cur.className = 'cswatch cur';
2734
+ cur.title = this.t('currentcolor');
2735
+ cur.style.background = hex;
2736
+ cur.innerHTML = IC.check;
2737
+ const nocolor = document.createElement('button');
2738
+ nocolor.type = 'button';
2739
+ nocolor.className = 'cswatch nocolor';
2740
+ nocolor.title = this.t('removecolor');
2741
+ nocolor.addEventListener('click', () => {
2742
+ this.closePop();
2743
+ apply(null);
2744
+ });
2745
+ const wheel = document.createElement('label');
2746
+ wheel.className = 'cswatch cwheel';
2747
+ wheel.title = this.t('customcolor');
2748
+ wheel.innerHTML = IC.palette;
2749
+ const picker = document.createElement('input');
2750
+ picker.type = 'color';
2751
+ picker.value = hex;
2752
+ picker.addEventListener('change', () => {
2753
+ this.closePop();
2754
+ apply(picker.value);
2755
+ });
2756
+ wheel.appendChild(picker);
2757
+ foot.append(cur, nocolor, wheel);
2758
+ el.appendChild(foot);
2759
+ }
2760
+ colorMenu(anchor, kind) {
2761
+ this.popup(anchor, (el) => {
2762
+ const curColor = kind === 'fore' ? this.foreColor : this.backColor;
2763
+ this.buildPaletteInto(el, curColor, (c) => this.applyColor(kind, c));
2764
+ }, kind === 'fore' ? 'pop-forecolor' : 'pop-backcolor');
2254
2765
  }
2255
2766
  applyColor(kind, c) {
2256
2767
  if (kind === 'fore') {
@@ -2413,11 +2924,12 @@ class FableEditor {
2413
2924
  applyBlockStyle(prop, value) {
2414
2925
  this.restoreSel();
2415
2926
  const blocks = this.selectedBlocks();
2927
+ const cssProp = prop === 'lineHeight' ? 'line-height' : prop === 'wordSpacing' ? 'word-spacing' : 'letter-spacing';
2416
2928
  blocks.forEach((b) => {
2417
2929
  if (value)
2418
2930
  b.style[prop] = value;
2419
2931
  else
2420
- b.style.removeProperty(prop === 'lineHeight' ? 'line-height' : 'word-spacing');
2932
+ b.style.removeProperty(cssProp);
2421
2933
  });
2422
2934
  this.saveSel();
2423
2935
  this.onChange();
@@ -2453,6 +2965,19 @@ class FableEditor {
2453
2965
  ]);
2454
2966
  });
2455
2967
  }
2968
+ letterSpacingMenu(anchor) {
2969
+ const cur = this.currentBlockStyle('letterSpacing');
2970
+ this.popup(anchor, (el) => {
2971
+ this.menuItems(el, [
2972
+ { label: this.t('normal'), on: !cur, action: () => this.applyBlockStyle('letterSpacing', '') },
2973
+ ...LETTER_SPACINGS.map((v) => ({
2974
+ label: v,
2975
+ on: cur === v,
2976
+ action: () => this.applyBlockStyle('letterSpacing', v)
2977
+ }))
2978
+ ]);
2979
+ });
2980
+ }
2456
2981
  closestAnchor() {
2457
2982
  const s = window.getSelection();
2458
2983
  let node = s && this.ed.contains(s.anchorNode) ? s.anchorNode : null;
@@ -2626,7 +3151,7 @@ class FableEditor {
2626
3151
  * (the quick color chips); everything else yields one. */
2627
3152
  buildToolbarRegistry() {
2628
3153
  const tableBtn = this.tbtn(IC.tableic, this.t('quicktable'), () => this.tableGrid(tableBtn));
2629
- return {
3154
+ const registry = {
2630
3155
  undo: () => this.tbtn(IC.undo, this.t('undo'), () => this.exec('undo')),
2631
3156
  redo: () => this.tbtn(IC.redo, this.t('redo'), () => this.exec('redo')),
2632
3157
  preview: () => this.tbtn(IC.prevw, this.t('preview'), () => this.previewDlg()),
@@ -2653,20 +3178,32 @@ class FableEditor {
2653
3178
  indent: () => this.tbtn(IC.indent, this.t('indent'), () => this.exec('indent')),
2654
3179
  link: () => this.tbtn(IC.link, this.t('linkttl'), () => this.linkDlg()),
2655
3180
  blockquote: () => this.tbtn(IC.quote, this.t('quote'), () => this.toggleBlock('blockquote'), 'blockquote'),
3181
+ code: () => this.tbtn(IC.inlinecodeic, this.t('code'), () => this.toggleInlineCode(), 'code'),
3182
+ codesample: () => this.tbtn(IC.codesampleic, this.t('codesample'), () => this.codeDlg()),
2656
3183
  changecase: () => this.menuTbtn(IC.caseic, this.t('changecase'), (b) => this.caseMenu(b)),
2657
3184
  lineheight: () => this.menuTbtn(IC.lineheight, this.t('lineheight'), (b) => this.lineHeightMenu(b)),
2658
3185
  wordspacing: () => this.menuTbtn(IC.wordspacing, this.t('wordspacing'), (b) => this.wordSpacingMenu(b)),
3186
+ letterspacing: () => this.menuTbtn(IC.letterspacing, this.t('letterspacing'), (b) => this.letterSpacingMenu(b)),
2659
3187
  removeformat: () => this.tbtn(IC.clearformatic, this.t('removeformat'), () => this.exec('removeFormat')),
2660
3188
  blocks: () => this.tsel('w-block', 'blocksel', this.t('para'), (b) => this.blocksMenu(b)),
2661
3189
  ltr: () => this.tbtn(IC.ltr, this.t('ltr'), () => this.setDir('ltr'), 'ltr'),
2662
3190
  rtl: () => this.tbtn(IC.rtl, this.t('rtl'), () => this.setDir('rtl'), 'rtl'),
2663
3191
  quickimage: () => this.tbtn(IC.image, this.t('quickimage'), () => this.insertImagePlaceholder()),
3192
+ quickvideo: () => this.tbtn(IC.video, this.t('quickvideo'), () => this.videoDlg()),
2664
3193
  quicktable: () => tableBtn,
2665
3194
  template: () => this.menuTbtn(IC.templateic, this.t('template'), (b) => this.templateMenu(b)),
2666
3195
  charmap: () => this.tbtn(IC.charic, this.t('charmap'), () => this.charMap()),
3196
+ emoji: () => this.tbtn(IC.emojiic, this.t('emoji'), () => this.emojiMap()),
2667
3197
  fullscreen: () => this.tbtn(IC.fullscreen, this.t('fullscreen'), () => this.toggleFullscreen(), 'fullscreen'),
2668
3198
  sourcecode: () => this.tbtn(IC.srcic, this.t('sourcecode'), () => this.sourceDlg())
2669
3199
  };
3200
+ /* TinyMCE-compatible aliases so common TinyMCE toolbar strings
3201
+ (e.g. "undo redo | styles | ... | link image media") work as-is */
3202
+ registry.styles = registry.blocks;
3203
+ registry.image = registry.quickimage;
3204
+ registry.media = registry.quickvideo;
3205
+ registry.table = registry.quicktable;
3206
+ return registry;
2670
3207
  }
2671
3208
  buildToolbar() {
2672
3209
  this.toolbar.innerHTML = '';
@@ -2689,7 +3226,40 @@ class FableEditor {
2689
3226
  if (elements.length)
2690
3227
  this.toolbar.appendChild(this.group(...elements));
2691
3228
  });
3229
+ this.tbrMore = this.tbtn(IC.more, this.t('more'), () => {
3230
+ this.tbrExpanded = !this.tbrExpanded;
3231
+ this.updateToolbarOverflow();
3232
+ });
3233
+ this.tbrMore.classList.add('tbr-more');
3234
+ this.toolbar.appendChild(this.tbrMore);
2692
3235
  this.syncColorSwatches(this.toolbar);
3236
+ this.updateToolbarOverflow();
3237
+ }
3238
+ /** xs/sm/md editors (< 992px wide) collapse an overflowing toolbar to one row +
3239
+ * "…" expander; at lg widths and up the toolbar wraps to multiple rows as before. */
3240
+ static TBR_COLLAPSE_MAX = 992;
3241
+ /** Responsive one-line toolbar: on mobile/tablet widths (or a narrow host
3242
+ * container) a toolbar that can't fit on a single row collapses to one
3243
+ * clipped row plus a trailing "…" button that expands/collapses the full
3244
+ * set. Driven by the editor's own width, so it also applies to narrow
3245
+ * panels on desktop. */
3246
+ updateToolbarOverflow() {
3247
+ const tb = this.toolbar;
3248
+ if (!this.tbrMore)
3249
+ return;
3250
+ tb.classList.add('tbr-measure');
3251
+ const overflows = tb.scrollWidth > tb.clientWidth + 1;
3252
+ tb.classList.remove('tbr-measure');
3253
+ const narrow = this.shell.offsetWidth < FableEditor.TBR_COLLAPSE_MAX;
3254
+ if (!overflows || !narrow) {
3255
+ this.tbrExpanded = false;
3256
+ tb.classList.remove('collapsible', 'expanded');
3257
+ this.tbrMore.classList.remove('on');
3258
+ return;
3259
+ }
3260
+ tb.classList.add('collapsible');
3261
+ tb.classList.toggle('expanded', this.tbrExpanded);
3262
+ this.tbrMore.classList.toggle('on', this.tbrExpanded);
2693
3263
  }
2694
3264
  /* ---------------------------------------------------------- menubar */
2695
3265
  mItem(label, icon, action, shortcut) {
@@ -2733,11 +3303,14 @@ class FableEditor {
2733
3303
  insert: [
2734
3304
  this.mItem('link', IC.link, () => this.linkDlg()),
2735
3305
  this.mItem('image', IC.image, () => this.insertImagePlaceholder()),
3306
+ this.mItem('video', IC.video, () => this.videoDlg()),
2736
3307
  { label: this.t('inserttable'), icon: IC.tableic, action: () => this.tableGrid(this.menubar.querySelector('[data-menu-key="table"]') || this.menubar) },
3308
+ this.mItem('codesample', IC.codesampleic, () => this.codeDlg()),
2737
3309
  { label: this.t('template'), icon: IC.templateic, subBuild: (el) => this.buildTemplatePickInto(el) },
2738
3310
  this.mItem('hr', IC.hric, () => this.exec('insertHorizontalRule')),
2739
3311
  '|',
2740
3312
  this.mItem('charmap', IC.charic, () => this.charMap()),
3313
+ this.mItem('emoji', IC.emojiic, () => this.emojiMap()),
2741
3314
  this.mItem('datetime', IC.dateic, () => this.exec('insertText', new Date().toLocaleString(this.lang === 'ar' ? 'ar-AE' : 'en-GB'))),
2742
3315
  '|',
2743
3316
  {
@@ -2758,6 +3331,7 @@ class FableEditor {
2758
3331
  this.mItem('strikethrough', IC.strikethrough, () => this.exec('strikeThrough')),
2759
3332
  this.mItem('superscript', IC.superscriptic, () => this.exec('superscript')),
2760
3333
  this.mItem('subscript', IC.subscriptic, () => this.exec('subscript')),
3334
+ this.mItem('code', IC.inlinecodeic, () => this.toggleInlineCode()),
2761
3335
  '|',
2762
3336
  this.mItem('lowercase', IC.lowercaseic, () => this.transformCase('lower')),
2763
3337
  this.mItem('uppercase', IC.uppercaseic, () => this.transformCase('upper')),
@@ -2771,7 +3345,10 @@ class FableEditor {
2771
3345
  '|',
2772
3346
  {
2773
3347
  label: this.t('cell'),
2774
- sub: [{ label: this.t('cellprops'), icon: IC.tableic, action: () => this.cellPropsDlg() }]
3348
+ sub: [
3349
+ { label: this.t('cellprops'), icon: IC.tableic, action: () => this.cellPropsDlg() },
3350
+ { label: this.t('cellbg'), icon: IC.palette, subBuild: (el) => this.buildCellFillInto(el) }
3351
+ ]
2775
3352
  },
2776
3353
  {
2777
3354
  label: this.t('row'),
@@ -2780,6 +3357,9 @@ class FableEditor {
2780
3357
  { label: this.t('rowbelow'), icon: IC.rowafter, action: () => this.tableOp('rowbelow'), hover: (on) => this.setOpTarget(on ? 'row' : null) },
2781
3358
  { label: this.t('delrow'), icon: IC.rowdelete, action: () => this.tableOp('delrow'), hover: (on) => this.setOpTarget(on ? 'row' : null) },
2782
3359
  '|',
3360
+ { label: this.t('moverowup'), icon: IC.moverowup, action: () => this.tableMove('rowup'), hover: (on) => this.setOpTarget(on ? 'row' : null) },
3361
+ { label: this.t('moverowdown'), icon: IC.moverowdown, action: () => this.tableMove('rowdown'), hover: (on) => this.setOpTarget(on ? 'row' : null) },
3362
+ '|',
2783
3363
  { label: this.t('rowprops'), icon: IC.tableic, action: () => this.rowPropsDlg(), hover: (on) => this.setOpTarget(on ? 'row' : null) }
2784
3364
  ]
2785
3365
  },
@@ -2790,6 +3370,9 @@ class FableEditor {
2790
3370
  { label: this.t('colafter'), icon: IC.colafter, action: () => this.tableOp('colafter'), hover: (on) => this.setOpTarget(on ? 'col' : null) },
2791
3371
  { label: this.t('delcol'), icon: IC.coldelete, action: () => this.tableOp('delcol'), hover: (on) => this.setOpTarget(on ? 'col' : null) },
2792
3372
  '|',
3373
+ { label: this.t('movecolleft'), icon: IC.movecolleft, action: () => this.tableMove('colleft'), hover: (on) => this.setOpTarget(on ? 'col' : null) },
3374
+ { label: this.t('movecolright'), icon: IC.movecolright, action: () => this.tableMove('colright'), hover: (on) => this.setOpTarget(on ? 'col' : null) },
3375
+ '|',
2793
3376
  { label: this.t('colprops'), icon: IC.tableic, action: () => this.colPropsDlg(), hover: (on) => this.setOpTarget(on ? 'col' : null) }
2794
3377
  ]
2795
3378
  },
@@ -2900,6 +3483,93 @@ class FableEditor {
2900
3483
  this.onChange();
2901
3484
  this.positionTableHandles();
2902
3485
  }
3486
+ /** Reorders the current row/column: 'rowup'/'rowdown' swap the caret's row with
3487
+ * its neighbour, 'colleft'/'colright' move the caret's column one position in
3488
+ * DOM order across every row. The caret travels with the moved cell. */
3489
+ tableMove(op) {
3490
+ this.restoreSel();
3491
+ const cell = this.currentCell();
3492
+ if (!cell)
3493
+ return;
3494
+ const row = cell.parentElement;
3495
+ const table = cell.closest('table');
3496
+ const idx = Array.from(row.children).indexOf(cell);
3497
+ if (op === 'rowup') {
3498
+ const prev = row.previousElementSibling;
3499
+ if (prev)
3500
+ prev.before(row);
3501
+ }
3502
+ if (op === 'rowdown') {
3503
+ const next = row.nextElementSibling;
3504
+ if (next)
3505
+ next.after(row);
3506
+ }
3507
+ if (op === 'colleft' && idx > 0) {
3508
+ table.querySelectorAll('tr').forEach((tr) => {
3509
+ const c = tr.children[idx];
3510
+ const prev = tr.children[idx - 1];
3511
+ if (c && prev)
3512
+ prev.before(c);
3513
+ });
3514
+ }
3515
+ if (op === 'colright') {
3516
+ table.querySelectorAll('tr').forEach((tr) => {
3517
+ const c = tr.children[idx];
3518
+ const next = tr.children[idx + 1];
3519
+ if (c && next)
3520
+ next.after(c);
3521
+ });
3522
+ }
3523
+ /* moving a node resets live ranges inside it (they collapse to the old
3524
+ parent), so put the caret back into the moved cell explicitly — this is
3525
+ what lets the user click "move" repeatedly to walk a row/column along */
3526
+ const range = document.createRange();
3527
+ range.selectNodeContents(cell);
3528
+ range.collapse(true);
3529
+ const s = window.getSelection();
3530
+ if (s) {
3531
+ s.removeAllRanges();
3532
+ s.addRange(range);
3533
+ }
3534
+ this.saveSel();
3535
+ this.onChange();
3536
+ this.positionTableHandles();
3537
+ this.positionCellMarker();
3538
+ }
3539
+ /** Align the whole table via auto margins — same semantics as the align
3540
+ * field in the table-properties dialog. */
3541
+ alignTable(align) {
3542
+ const table = this.tblActive;
3543
+ if (!table)
3544
+ return;
3545
+ table.style.marginLeft = align === 'left' ? '0' : 'auto';
3546
+ table.style.marginRight = align === 'right' ? '0' : 'auto';
3547
+ this.onChange();
3548
+ this.positionTableHandles();
3549
+ }
3550
+ /** Color grid applying a background to the caret's cell — the direct way to
3551
+ * build header rows (the toolbar's backcolor highlights text, not cells). */
3552
+ buildCellFillInto(el) {
3553
+ const apply = (c) => {
3554
+ const cell = this.currentCell();
3555
+ if (!cell) {
3556
+ alert(this.t('nocell'));
3557
+ return;
3558
+ }
3559
+ this.setOrClear(cell, 'background-color', c || '');
3560
+ this.onChange();
3561
+ };
3562
+ const cell = this.currentCell();
3563
+ this.buildPaletteInto(el, this.cssToHex(cell ? cell.style.backgroundColor : ''), apply);
3564
+ }
3565
+ /** style.backgroundColor reads back as rgb(...) — normalise to #rrggbb for the picker. */
3566
+ cssToHex(c) {
3567
+ const m = c.match(/^rgb\((\d+),\s*(\d+),\s*(\d+)\)$/);
3568
+ return m ? '#' + m.slice(1, 4).map((n) => (+n).toString(16).padStart(2, '0')).join('') : c;
3569
+ }
3570
+ cellFillMenu(anchor) {
3571
+ this.popup(anchor, (el) => this.buildCellFillInto(el), 'pop-cellbg');
3572
+ }
2903
3573
  applyTableProps(table, vals) {
2904
3574
  if (vals.width)
2905
3575
  table.style.width = /^\d+$/.test(vals.width) ? vals.width + 'px' : vals.width;
@@ -3308,22 +3978,53 @@ class FableEditor {
3308
3978
  body.appendChild(box);
3309
3979
  });
3310
3980
  }
3311
- charMap() {
3312
- this.dialog(this.t('charmap').replace('…', ''), (body) => {
3313
- const g = document.createElement('div');
3314
- g.className = 'chgrid';
3315
- CHARS.forEach((ch) => {
3981
+ /** Two-pane picker shared by the special-character map and the emoji map:
3982
+ * fixed category tabs on the left, the active category's glyphs on the right. */
3983
+ glyphPickerDlg(title, cats, emoji) {
3984
+ this.saveSel();
3985
+ this.dialog(title, (body) => {
3986
+ const wrap = document.createElement('div');
3987
+ wrap.className = 'chmap';
3988
+ const tabs = document.createElement('div');
3989
+ tabs.className = 'chtabs';
3990
+ const panel = document.createElement('div');
3991
+ panel.className = 'chpanel';
3992
+ const grid = document.createElement('div');
3993
+ grid.className = 'chgrid' + (emoji ? ' emgrid' : '');
3994
+ panel.appendChild(grid);
3995
+ const show = (i) => {
3996
+ tabs.querySelectorAll('button').forEach((b, bi) => b.classList.toggle('on', bi === i));
3997
+ grid.innerHTML = '';
3998
+ cats[i].chars.forEach((ch) => {
3999
+ const b = document.createElement('button');
4000
+ b.type = 'button';
4001
+ b.textContent = ch;
4002
+ b.onclick = () => {
4003
+ this.closeDlg();
4004
+ this.exec('insertText', ch);
4005
+ };
4006
+ grid.appendChild(b);
4007
+ });
4008
+ panel.scrollTop = 0;
4009
+ };
4010
+ cats.forEach((c, i) => {
3316
4011
  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);
4012
+ b.type = 'button';
4013
+ b.textContent = this.t(c.key);
4014
+ b.addEventListener('click', () => show(i));
4015
+ tabs.appendChild(b);
3323
4016
  });
3324
- body.appendChild(g);
4017
+ wrap.append(tabs, panel);
4018
+ body.appendChild(wrap);
4019
+ show(0);
3325
4020
  });
3326
4021
  }
4022
+ charMap() {
4023
+ this.glyphPickerDlg(this.t('charmap').replace('…', ''), CHAR_CATEGORIES, false);
4024
+ }
4025
+ emojiMap() {
4026
+ this.glyphPickerDlg(this.t('emoji').replace('…', ''), EMOJI_CATEGORIES, true);
4027
+ }
3327
4028
  printDoc() {
3328
4029
  const w = window.open('', '_blank');
3329
4030
  if (!w)
@@ -3389,9 +4090,34 @@ class FableEditor {
3389
4090
  el.append(this.ctxBtn(IC.uploadic, this.t('uploadimg'), () => {
3390
4091
  this.phUploadTarget = this.phActive;
3391
4092
  this.pickImage();
3392
- }), this.ctxBtn(IC.link, this.t('imagelink'), () => this.renderPhCtxUrlInput()), this.ctxSep(), this.ctxBtn(IC.trash, this.t('deleteimg'), () => this.removeImgPlaceholder()));
4093
+ }), this.ctxBtn(IC.link, this.t('imagelink'), () => this.renderPhCtxUrlInput()));
4094
+ /* a template's media slot is image-first, but can host a video instead */
4095
+ if (this.phActive?.closest('.tpl-media')) {
4096
+ el.append(this.ctxBtn(IC.video, this.t('quickvideo'), () => this.swapPlaceholderKind('video')));
4097
+ }
4098
+ el.append(this.ctxSep(), this.ctxBtn(IC.trash, this.t('deleteimg'), () => this.removeImgPlaceholder()));
3393
4099
  this.positionImgPhCtx();
3394
4100
  }
4101
+ /** Swaps a template slot's placeholder between image and video kinds, so the
4102
+ * same media slot can be filled with either. */
4103
+ swapPlaceholderKind(to) {
4104
+ const ph = to === 'video' ? this.phActive : this.vphActive;
4105
+ if (!ph)
4106
+ return;
4107
+ const tmp = document.createElement('div');
4108
+ tmp.innerHTML = to === 'video' ? this.vidPhHTML() : this.imgPhHTML();
4109
+ const next = tmp.firstElementChild;
4110
+ if (to === 'video')
4111
+ this.clearImgPlaceholderSel();
4112
+ else
4113
+ this.clearVphPlaceholderSel();
4114
+ ph.replaceWith(next);
4115
+ if (to === 'video')
4116
+ this.selectVphPlaceholder(next);
4117
+ else
4118
+ this.selectImgPlaceholder(next);
4119
+ this.onChange();
4120
+ }
3395
4121
  renderPhCtxUrlInput() {
3396
4122
  const el = this.phCtx;
3397
4123
  if (!el)
@@ -3514,6 +4240,741 @@ class FableEditor {
3514
4240
  this.phCtx.style.left = cx + 'px';
3515
4241
  this.phCtx.style.top = cy + 'px';
3516
4242
  }
4243
+ /* ---------------------------------------------------------- video upload placeholder */
4244
+ pickVideo() {
4245
+ this.vidInput.value = '';
4246
+ this.vidInput.click();
4247
+ }
4248
+ vidPhHTML(id) {
4249
+ return `<div class="img-ph vid-ph"${id ? ` id="${id}"` : ''} contenteditable="false">${IC.video}<span>${this.t('dropvideo')}</span></div>`;
4250
+ }
4251
+ insertVideoPlaceholder() {
4252
+ this.restoreSel();
4253
+ const id = 'vid-ph-' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
4254
+ document.execCommand('insertHTML', false, this.vidPhHTML(id));
4255
+ const ph = this.ed.querySelector('#' + id);
4256
+ if (ph) {
4257
+ ph.removeAttribute('id');
4258
+ this.selectVphPlaceholder(ph);
4259
+ }
4260
+ this.onChange();
4261
+ }
4262
+ clearVphPlaceholderSel() {
4263
+ this.vphCtx?.remove();
4264
+ this.vphCtx = null;
4265
+ this.vphActive?.classList.remove('active');
4266
+ this.vphActive = null;
4267
+ }
4268
+ removeVphPlaceholder() {
4269
+ const ph = this.vphActive;
4270
+ if (!ph)
4271
+ return;
4272
+ this.clearVphPlaceholderSel();
4273
+ /* like the image variant: a placeholder inside a template block IS the
4274
+ media slot — deleting it drops the slot, leaving a text-only block */
4275
+ (ph.closest('.tpl-media') || ph).remove();
4276
+ this.refreshState();
4277
+ this.onChange();
4278
+ }
4279
+ selectVphPlaceholder(ph) {
4280
+ if (this.vphActive === ph)
4281
+ return;
4282
+ this.clearVphPlaceholderSel();
4283
+ this.vphActive = ph;
4284
+ ph.classList.add('active');
4285
+ this.vphCtx = document.createElement('div');
4286
+ this.vphCtx.className = 'imgctx img-ph-ctx';
4287
+ this.vphCtx.dir = this.dir();
4288
+ document.body.appendChild(this.vphCtx);
4289
+ this.renderVphCtxButtons();
4290
+ }
4291
+ renderVphCtxButtons() {
4292
+ const el = this.vphCtx;
4293
+ if (!el)
4294
+ return;
4295
+ el.innerHTML = '';
4296
+ el.append(this.ctxBtn(IC.uploadic, this.t('uploadvideo'), () => {
4297
+ this.vphUploadTarget = this.vphActive;
4298
+ this.pickVideo();
4299
+ }), this.ctxBtn(IC.link, this.t('videolink'), () => this.renderVphCtxUrlInput()));
4300
+ /* inside a template slot, offer switching the slot back to an image */
4301
+ if (this.vphActive?.closest('.tpl-media')) {
4302
+ el.append(this.ctxBtn(IC.image, this.t('quickimage'), () => this.swapPlaceholderKind('image')));
4303
+ }
4304
+ el.append(this.ctxSep(), this.ctxBtn(IC.trash, this.t('deletevideo'), () => this.removeVphPlaceholder()));
4305
+ this.positionVphCtx();
4306
+ }
4307
+ renderVphCtxUrlInput() {
4308
+ const el = this.vphCtx;
4309
+ if (!el)
4310
+ return;
4311
+ el.innerHTML = '';
4312
+ const inp = document.createElement('input');
4313
+ inp.type = 'url';
4314
+ inp.className = 'urlinp';
4315
+ inp.placeholder = this.t('videourlph');
4316
+ const ok = document.createElement('button');
4317
+ ok.type = 'button';
4318
+ ok.className = 'txtbtn';
4319
+ ok.textContent = this.t('insertvideo');
4320
+ const submit = () => {
4321
+ const url = inp.value.trim();
4322
+ const ph = this.vphActive;
4323
+ if (!ph)
4324
+ return;
4325
+ if (!/^(https?:|data:video\/|blob:)/i.test(url)) {
4326
+ inp.classList.add('err');
4327
+ inp.focus();
4328
+ return;
4329
+ }
4330
+ /* a page URL from a known video host can't play in a <video> tag —
4331
+ swap in the host's iframe player instead of probing it */
4332
+ const embed = this.videoEmbedUrl(url);
4333
+ if (embed) {
4334
+ this.replacePlaceholderWithEmbed(ph, embed);
4335
+ return;
4336
+ }
4337
+ ok.disabled = true;
4338
+ inp.disabled = true;
4339
+ /* only place the video once its metadata actually loads, so the user
4340
+ gets a working preview instead of a broken player */
4341
+ const probe = document.createElement('video');
4342
+ probe.preload = 'metadata';
4343
+ probe.onloadedmetadata = () => this.replacePlaceholderWithVideo(ph, url, '');
4344
+ probe.onerror = () => {
4345
+ ok.disabled = false;
4346
+ inp.disabled = false;
4347
+ inp.classList.add('err');
4348
+ inp.focus();
4349
+ };
4350
+ probe.src = url;
4351
+ };
4352
+ ok.addEventListener('click', submit);
4353
+ inp.addEventListener('input', () => inp.classList.remove('err'));
4354
+ inp.addEventListener('keydown', (e) => {
4355
+ if (e.key === 'Enter') {
4356
+ e.preventDefault();
4357
+ submit();
4358
+ }
4359
+ else if (e.key === 'Escape') {
4360
+ e.stopPropagation();
4361
+ this.renderVphCtxButtons();
4362
+ }
4363
+ });
4364
+ el.append(inp, ok);
4365
+ this.positionVphCtx();
4366
+ inp.focus();
4367
+ }
4368
+ readVideoFileInto(file, ph) {
4369
+ if (ph.classList.contains('uploading'))
4370
+ return;
4371
+ if (this.videoUploadHandler) {
4372
+ this.uploadVideoFile(file, ph);
4373
+ return;
4374
+ }
4375
+ const fr = new FileReader();
4376
+ fr.onload = () => {
4377
+ if (this.ed.contains(ph))
4378
+ this.replacePlaceholderWithVideo(ph, fr.result, file.name.replace(/"/g, ''));
4379
+ };
4380
+ fr.readAsDataURL(file);
4381
+ }
4382
+ /** Runs the host-supplied videoUploadHandler instead of inlining base64,
4383
+ * so apps can route the file to S3 / Azure Blob / their own server. */
4384
+ uploadVideoFile(file, ph) {
4385
+ const title = file.name.replace(/"/g, '');
4386
+ ph.classList.remove('upload-error');
4387
+ ph.classList.add('uploading');
4388
+ const span = ph.querySelector('span');
4389
+ if (span)
4390
+ span.textContent = this.t('uploading');
4391
+ this.videoUploadHandler(file)
4392
+ .then((url) => {
4393
+ if (!this.ed.contains(ph))
4394
+ return;
4395
+ this.replacePlaceholderWithVideo(ph, url, title);
4396
+ })
4397
+ .catch((err) => {
4398
+ if (!this.ed.contains(ph))
4399
+ return;
4400
+ ph.classList.remove('uploading');
4401
+ ph.classList.add('upload-error');
4402
+ if (span)
4403
+ span.textContent = this.t('uploadfailed');
4404
+ this.onVideoUploadError?.(err, file);
4405
+ });
4406
+ }
4407
+ replacePlaceholderWithVideo(ph, src, title) {
4408
+ const vid = document.createElement('video');
4409
+ vid.controls = true;
4410
+ vid.src = src;
4411
+ if (title)
4412
+ vid.title = title;
4413
+ ph.replaceWith(vid);
4414
+ if (this.vphActive === ph)
4415
+ this.clearVphPlaceholderSel();
4416
+ this.refreshState();
4417
+ this.onChange();
4418
+ }
4419
+ /** Swaps a video placeholder for a hosted player iframe (YouTube & co.). */
4420
+ replacePlaceholderWithEmbed(ph, embedUrl) {
4421
+ const tmp = document.createElement('div');
4422
+ tmp.innerHTML = this.videoEmbedHTML(embedUrl);
4423
+ ph.replaceWith(tmp.firstElementChild);
4424
+ if (this.vphActive === ph)
4425
+ this.clearVphPlaceholderSel();
4426
+ this.refreshState();
4427
+ this.onChange();
4428
+ }
4429
+ positionVphCtx() {
4430
+ if (this.vphActive && !document.body.contains(this.vphActive)) {
4431
+ this.clearVphPlaceholderSel();
4432
+ return;
4433
+ }
4434
+ if (!this.vphActive || !this.vphCtx)
4435
+ return;
4436
+ const r = this.vphActive.getBoundingClientRect();
4437
+ const cw = this.vphCtx.offsetWidth;
4438
+ const ch = this.vphCtx.offsetHeight;
4439
+ let cx = r.left + scrollX + (r.width - cw) / 2;
4440
+ cx = Math.max(8 + scrollX, Math.min(cx, scrollX + innerWidth - cw - 8));
4441
+ let cy = r.bottom + scrollY + 8;
4442
+ if (r.bottom + 8 + ch > innerHeight - 4)
4443
+ cy = Math.max(this.ctxMinTop(), r.top + scrollY - ch - 8);
4444
+ this.vphCtx.style.left = cx + 'px';
4445
+ this.vphCtx.style.top = cy + 'px';
4446
+ }
4447
+ /* ---------------------------------------------------------- video toolbar (post-insert) */
4448
+ cancelVidHide() {
4449
+ if (this.vidHideTimer != null) {
4450
+ window.clearTimeout(this.vidHideTimer);
4451
+ this.vidHideTimer = null;
4452
+ }
4453
+ }
4454
+ clearVidHandles() {
4455
+ this.cancelVidHide();
4456
+ this.vidCtx?.remove();
4457
+ this.vidCtx = null;
4458
+ this.vidActive?.classList.remove('vid-selected');
4459
+ this.vidActive = null;
4460
+ }
4461
+ selectVideo(vid) {
4462
+ if (this.vidActive === vid)
4463
+ return;
4464
+ this.clearVidHandles();
4465
+ this.vidActive = vid;
4466
+ vid.classList.add('vid-selected');
4467
+ this.vidCtx = this.buildVideoCtxToolbar(vid);
4468
+ this.positionVidCtx();
4469
+ }
4470
+ buildVideoCtxToolbar(vid) {
4471
+ const el = document.createElement('div');
4472
+ el.className = 'imgctx';
4473
+ el.dir = this.dir();
4474
+ const rebuild = () => {
4475
+ const rebuilt = this.buildVideoCtxToolbar(vid);
4476
+ this.vidCtx?.replaceWith(rebuilt);
4477
+ this.vidCtx = rebuilt;
4478
+ this.positionVidCtx();
4479
+ };
4480
+ const align = (a) => {
4481
+ this.setVideoAlign(vid, a);
4482
+ rebuild();
4483
+ };
4484
+ const isLeft = vid.style.float === 'left';
4485
+ const isRight = vid.style.float === 'right';
4486
+ const isCenter = !isLeft && !isRight && vid.style.marginLeft === 'auto' && vid.style.marginRight === 'auto';
4487
+ const mkBtn = (icon, tip, on, fn) => {
4488
+ const b = this.ctxBtn(icon, tip, fn);
4489
+ b.classList.toggle('on', on);
4490
+ return b;
4491
+ };
4492
+ 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, () => {
4493
+ vid.remove();
4494
+ this.clearVidHandles();
4495
+ this.onChange();
4496
+ }));
4497
+ el.addEventListener('mousedown', (e) => e.preventDefault());
4498
+ el.addEventListener('mouseenter', () => this.cancelVidHide());
4499
+ el.addEventListener('mouseleave', () => {
4500
+ this.cancelVidHide();
4501
+ this.vidHideTimer = window.setTimeout(() => this.clearVidHandles(), 250);
4502
+ });
4503
+ document.body.appendChild(el);
4504
+ return el;
4505
+ }
4506
+ setVideoAlign(vid, align) {
4507
+ vid.style.float = '';
4508
+ vid.style.display = '';
4509
+ vid.style.marginLeft = '';
4510
+ vid.style.marginRight = '';
4511
+ vid.style.marginBottom = '';
4512
+ if (align === 'left') {
4513
+ vid.style.float = 'left';
4514
+ vid.style.marginInlineEnd = '1em';
4515
+ vid.style.marginBottom = '0.5em';
4516
+ }
4517
+ else if (align === 'right') {
4518
+ vid.style.float = 'right';
4519
+ vid.style.marginInlineStart = '1em';
4520
+ vid.style.marginBottom = '0.5em';
4521
+ }
4522
+ else {
4523
+ vid.style.display = 'block';
4524
+ vid.style.marginLeft = 'auto';
4525
+ vid.style.marginRight = 'auto';
4526
+ }
4527
+ this.onChange();
4528
+ }
4529
+ positionVidCtx() {
4530
+ if (!this.vidActive || !document.body.contains(this.vidActive)) {
4531
+ this.clearVidHandles();
4532
+ return;
4533
+ }
4534
+ const r = this.vidActive.getBoundingClientRect();
4535
+ if (!this.vidCtx)
4536
+ return;
4537
+ const cw = this.vidCtx.offsetWidth;
4538
+ const ch = this.vidCtx.offsetHeight;
4539
+ let cx = r.left + scrollX + (r.width - cw) / 2;
4540
+ cx = Math.max(8 + scrollX, Math.min(cx, scrollX + innerWidth - cw - 8));
4541
+ let cy = r.top + scrollY - ch - 8;
4542
+ if (cy < this.ctxMinTop())
4543
+ cy = r.bottom + scrollY + 8;
4544
+ this.vidCtx.style.left = cx + 'px';
4545
+ this.vidCtx.style.top = cy + 'px';
4546
+ }
4547
+ /* ---------------------------------------------------------- video dialog / embeds */
4548
+ /** Maps a page URL from a known video host to its iframe player URL. Returns
4549
+ * null for anything else (e.g. direct .mp4 file URLs). */
4550
+ videoEmbedUrl(url) {
4551
+ let m = url.match(/^https?:\/\/(?:www\.|m\.)?youtube\.com\/watch\?(?:[^\s#]*[?&])?v=([\w-]{5,})/i);
4552
+ if (!m)
4553
+ m = url.match(/^https?:\/\/(?:www\.)?(?:youtu\.be|youtube\.com\/(?:embed|shorts|live))\/([\w-]{5,})/i);
4554
+ if (m)
4555
+ return 'https://www.youtube.com/embed/' + m[1];
4556
+ m = url.match(/^https?:\/\/(?:www\.)?vimeo\.com\/(\d+)/i);
4557
+ if (m)
4558
+ return 'https://player.vimeo.com/video/' + m[1];
4559
+ m = url.match(/^https?:\/\/(?:www\.)?dailymotion\.com\/video\/(\w+)/i);
4560
+ if (m)
4561
+ return 'https://www.dailymotion.com/embed/video/' + m[1];
4562
+ return null;
4563
+ }
4564
+ videoEmbedHTML(src, w = 560, h = 314) {
4565
+ 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>`;
4566
+ }
4567
+ /** Insert/edit-video dialog with General (source/width/height), Embed (embed
4568
+ * code) and Advanced (alternative source, poster image) tabs. Hosted page
4569
+ * URLs (YouTube & co.) become iframe players; anything else becomes a
4570
+ * <video> tag. The General tab also offers the same upload path as before
4571
+ * (videoUploadHandler when configured, else inline base64). */
4572
+ videoDlg() {
4573
+ this.saveSel();
4574
+ let srcInp;
4575
+ let wInp;
4576
+ let hInp;
4577
+ let embedTa;
4578
+ let altInp;
4579
+ let posterInp;
4580
+ const field = (labelKey, input) => {
4581
+ const f = document.createElement('div');
4582
+ f.className = 'dfield';
4583
+ const l = document.createElement('label');
4584
+ l.textContent = this.t(labelKey);
4585
+ f.append(l, input);
4586
+ return f;
4587
+ };
4588
+ const urlInput = () => {
4589
+ const i = document.createElement('input');
4590
+ i.type = 'url';
4591
+ return i;
4592
+ };
4593
+ this.dialog(this.t('videodlgttl'), (body) => {
4594
+ const tabs = document.createElement('div');
4595
+ tabs.className = 'dtabs';
4596
+ const panels = [];
4597
+ const mkTab = (labelKey, build) => {
4598
+ const b = document.createElement('button');
4599
+ b.type = 'button';
4600
+ b.textContent = this.t(labelKey);
4601
+ const p = document.createElement('div');
4602
+ p.className = 'dtabpanel';
4603
+ build(p);
4604
+ const i = panels.length;
4605
+ panels.push(p);
4606
+ b.addEventListener('click', () => show(i));
4607
+ tabs.appendChild(b);
4608
+ };
4609
+ const show = (i) => {
4610
+ tabs.querySelectorAll('button').forEach((x, xi) => x.classList.toggle('on', xi === i));
4611
+ panels.forEach((x, xi) => (x.style.display = xi === i ? '' : 'none'));
4612
+ };
4613
+ mkTab('vidgeneral', (p) => {
4614
+ srcInp = urlInput();
4615
+ srcInp.placeholder = this.t('videourlph');
4616
+ const srcRow = document.createElement('div');
4617
+ srcRow.className = 'drow';
4618
+ const upBtn = document.createElement('button');
4619
+ upBtn.type = 'button';
4620
+ upBtn.className = 'txtbtn';
4621
+ upBtn.textContent = this.t('uploadvideo');
4622
+ const file = document.createElement('input');
4623
+ file.type = 'file';
4624
+ file.accept = this.options.videoFileTypes.join(',');
4625
+ file.style.display = 'none';
4626
+ upBtn.addEventListener('click', () => {
4627
+ file.value = '';
4628
+ file.click();
4629
+ });
4630
+ file.addEventListener('change', () => {
4631
+ const f = file.files?.[0];
4632
+ if (!f)
4633
+ return;
4634
+ upBtn.disabled = true;
4635
+ upBtn.textContent = this.t('uploading');
4636
+ const done = (url) => {
4637
+ srcInp.value = url;
4638
+ upBtn.disabled = false;
4639
+ upBtn.textContent = this.t('uploadvideo');
4640
+ };
4641
+ if (this.videoUploadHandler) {
4642
+ this.videoUploadHandler(f)
4643
+ .then(done)
4644
+ .catch((err) => {
4645
+ upBtn.disabled = false;
4646
+ upBtn.textContent = this.t('uploadfailed');
4647
+ this.onVideoUploadError?.(err, f);
4648
+ });
4649
+ }
4650
+ else {
4651
+ const fr = new FileReader();
4652
+ fr.onload = () => done(fr.result);
4653
+ fr.readAsDataURL(f);
4654
+ }
4655
+ });
4656
+ srcRow.append(srcInp, upBtn, file);
4657
+ srcInp.style.flex = '1';
4658
+ p.appendChild(field('vidsource', srcRow));
4659
+ const dims = document.createElement('div');
4660
+ dims.className = 'drow';
4661
+ wInp = document.createElement('input');
4662
+ wInp.type = 'number';
4663
+ wInp.value = '560';
4664
+ hInp = document.createElement('input');
4665
+ hInp.type = 'number';
4666
+ hInp.value = '314';
4667
+ dims.append(field('tblwidth', wInp), field('tblheight', hInp));
4668
+ p.appendChild(dims);
4669
+ });
4670
+ mkTab('vidembed', (p) => {
4671
+ const hint = document.createElement('p');
4672
+ hint.textContent = this.t('vidembedhint');
4673
+ hint.style.cssText = 'margin:0 0 8px;color:#556;font-size:13.5px';
4674
+ embedTa = document.createElement('textarea');
4675
+ embedTa.style.cssText = 'width:100%;box-sizing:border-box;height:160px';
4676
+ p.append(hint, embedTa);
4677
+ });
4678
+ mkTab('vidadvanced', (p) => {
4679
+ altInp = urlInput();
4680
+ posterInp = urlInput();
4681
+ p.append(field('vidaltsource', altInp), field('vidposter', posterInp));
4682
+ });
4683
+ const wrap = document.createElement('div');
4684
+ wrap.className = 'dtabbody';
4685
+ panels.forEach((p) => wrap.appendChild(p));
4686
+ body.append(tabs, wrap);
4687
+ show(0);
4688
+ setTimeout(() => srcInp.focus(), 0);
4689
+ }, [
4690
+ { label: this.t('cancel'), action: () => this.closeDlg() },
4691
+ {
4692
+ label: this.t('save'),
4693
+ primary: true,
4694
+ action: () => {
4695
+ const embedCode = embedTa.value.trim();
4696
+ const src = srcInp.value.trim();
4697
+ const w = parseInt(wInp.value, 10) || 560;
4698
+ const h = parseInt(hInp.value, 10) || 314;
4699
+ this.closeDlg();
4700
+ if (!embedCode && !src)
4701
+ return;
4702
+ this.restoreSel();
4703
+ if (embedCode) {
4704
+ document.execCommand('insertHTML', false, `<span class="video-embed" contenteditable="false">${embedCode}</span>`);
4705
+ this.onChange();
4706
+ return;
4707
+ }
4708
+ const embed = this.videoEmbedUrl(src);
4709
+ if (embed) {
4710
+ document.execCommand('insertHTML', false, this.videoEmbedHTML(embed, w, h));
4711
+ }
4712
+ else {
4713
+ const alt = altInp.value.trim();
4714
+ const poster = posterInp.value.trim();
4715
+ document.execCommand('insertHTML', false, `<video controls width="${w}" height="${h}"${poster ? ` poster="${this.escapeAttr(poster)}"` : ''}>` +
4716
+ `<source src="${this.escapeAttr(src)}">${alt ? `<source src="${this.escapeAttr(alt)}">` : ''}</video>`);
4717
+ }
4718
+ this.onChange();
4719
+ }
4720
+ }
4721
+ ]);
4722
+ }
4723
+ escapeAttr(s) {
4724
+ return s.replace(/&/g, '&amp;').replace(/"/g, '&quot;');
4725
+ }
4726
+ escHtml(s) {
4727
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
4728
+ }
4729
+ /* ---------------------------------------------------------- inline code & code samples */
4730
+ /** The inline <code> run the caret sits in, if any (code blocks don't count). */
4731
+ closestInlineCode() {
4732
+ const s = window.getSelection();
4733
+ const node = s && this.ed.contains(s.anchorNode) ? s.anchorNode : null;
4734
+ if (!node)
4735
+ return null;
4736
+ const el = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement;
4737
+ const code = el?.closest('code');
4738
+ return code && this.ed.contains(code) && !code.closest('pre') ? code : null;
4739
+ }
4740
+ /** Wraps the selection in an inline <code> run, or unwraps the run the caret is
4741
+ * in. Selections spanning multiple blocks are left alone (a <code> can't wrap
4742
+ * across paragraphs without producing invalid markup). */
4743
+ toggleInlineCode() {
4744
+ this.restoreSel();
4745
+ const sel = window.getSelection();
4746
+ if (!sel || !sel.rangeCount)
4747
+ return;
4748
+ const existing = this.closestInlineCode();
4749
+ if (existing) {
4750
+ const first = existing.firstChild;
4751
+ const last = existing.lastChild;
4752
+ while (existing.firstChild)
4753
+ existing.parentNode.insertBefore(existing.firstChild, existing);
4754
+ existing.remove();
4755
+ if (first && last) {
4756
+ const r = document.createRange();
4757
+ r.setStartBefore(first);
4758
+ r.setEndAfter(last);
4759
+ sel.removeAllRanges();
4760
+ sel.addRange(r);
4761
+ }
4762
+ }
4763
+ else {
4764
+ const range = sel.getRangeAt(0);
4765
+ if (range.collapsed)
4766
+ return;
4767
+ if (this.closestBlock(range.startContainer) !== this.closestBlock(range.endContainer))
4768
+ return;
4769
+ const code = document.createElement('code');
4770
+ code.appendChild(range.extractContents());
4771
+ range.insertNode(code);
4772
+ const r = document.createRange();
4773
+ r.selectNodeContents(code);
4774
+ sel.removeAllRanges();
4775
+ sel.addRange(r);
4776
+ }
4777
+ this.saveSel();
4778
+ this.refreshState();
4779
+ this.onChange();
4780
+ }
4781
+ /** Typing `snippet` converts to an inline <code> run the moment the closing
4782
+ * backtick is typed, so code can be marked up without reaching for the toolbar. */
4783
+ tryCodeTyping(e) {
4784
+ const sel = window.getSelection();
4785
+ if (!sel || !sel.isCollapsed || !sel.anchorNode)
4786
+ return;
4787
+ const node = sel.anchorNode;
4788
+ if (node.nodeType !== Node.TEXT_NODE || !this.ed.contains(node))
4789
+ return;
4790
+ if (node.parentElement?.closest('code, pre'))
4791
+ return;
4792
+ const before = (node.textContent || '').slice(0, sel.anchorOffset);
4793
+ const m = before.match(/(?:^|[^`])`([^`]+)$/);
4794
+ if (!m)
4795
+ return;
4796
+ const src = m[1];
4797
+ if (/^\s|\s$/.test(src))
4798
+ return;
4799
+ e.preventDefault();
4800
+ const range = document.createRange();
4801
+ range.setStart(node, sel.anchorOffset - src.length - 1);
4802
+ range.setEnd(node, sel.anchorOffset);
4803
+ /* direct DOM instead of execCommand('insertHTML') — with styleWithCSS
4804
+ active the browser rewrites a pasted <code> into a styled <span>,
4805
+ losing the semantic tag */
4806
+ range.deleteContents();
4807
+ const code = document.createElement('code');
4808
+ code.textContent = src;
4809
+ range.insertNode(code);
4810
+ const after = document.createRange();
4811
+ after.setStartAfter(code);
4812
+ after.collapse(true);
4813
+ sel.removeAllRanges();
4814
+ sel.addRange(after);
4815
+ this.saveSel();
4816
+ this.onChange();
4817
+ }
4818
+ /** Custom dropdown for dialog fields (a native <select> popup can't be styled):
4819
+ * a field-styled button showing the current choice that opens a floating,
4820
+ * scrollable option list. Read the current choice via `.value()`. */
4821
+ dlgSelect(options, selected) {
4822
+ let value = selected;
4823
+ const el = document.createElement('div');
4824
+ el.className = 'dsel';
4825
+ const btn = document.createElement('button');
4826
+ btn.type = 'button';
4827
+ btn.className = 'dsel-btn';
4828
+ const lbl = document.createElement('span');
4829
+ lbl.className = 'lbl';
4830
+ lbl.textContent = options.find(([v]) => v === selected)?.[1] || '';
4831
+ btn.append(lbl);
4832
+ btn.insertAdjacentHTML('beforeend', IC.chev);
4833
+ const list = document.createElement('div');
4834
+ list.className = 'dsel-list';
4835
+ options.forEach(([v, label]) => {
4836
+ const o = document.createElement('button');
4837
+ o.type = 'button';
4838
+ o.textContent = label;
4839
+ o.classList.toggle('on', v === value);
4840
+ o.addEventListener('click', () => {
4841
+ value = v;
4842
+ lbl.textContent = label;
4843
+ list.querySelectorAll('button').forEach((b) => b.classList.toggle('on', b === o));
4844
+ el.classList.remove('open');
4845
+ btn.focus();
4846
+ });
4847
+ list.appendChild(o);
4848
+ });
4849
+ btn.addEventListener('click', () => {
4850
+ el.classList.toggle('open');
4851
+ if (el.classList.contains('open'))
4852
+ list.querySelector('.on')?.scrollIntoView({ block: 'nearest' });
4853
+ });
4854
+ el.addEventListener('focusout', (e) => {
4855
+ if (!el.contains(e.relatedTarget))
4856
+ el.classList.remove('open');
4857
+ });
4858
+ el.addEventListener('keydown', (e) => {
4859
+ /* Escape closes the open list without closing the whole dialog */
4860
+ if (e.key === 'Escape' && el.classList.contains('open')) {
4861
+ e.stopPropagation();
4862
+ el.classList.remove('open');
4863
+ btn.focus();
4864
+ }
4865
+ });
4866
+ el.append(btn, list);
4867
+ return { el, value: () => value };
4868
+ }
4869
+ /** Insert/edit-code-sample dialog: language dropdown + monospace textarea to
4870
+ * paste or type code into. Inserts a non-editable <pre class="code-block">
4871
+ * whose header shows the language; click the block to edit / copy / delete. */
4872
+ codeDlg(existing) {
4873
+ this.saveSel();
4874
+ let langDd;
4875
+ let ta;
4876
+ this.dialog(this.t('codedlgttl'), (body) => {
4877
+ const f = document.createElement('div');
4878
+ f.className = 'dfield';
4879
+ const l = document.createElement('label');
4880
+ l.textContent = this.t('codelang');
4881
+ langDd = this.dlgSelect(CODE_LANGS, existing?.dataset.lang || 'plain');
4882
+ f.append(l, langDd.el);
4883
+ ta = document.createElement('textarea');
4884
+ ta.className = 'code-input';
4885
+ ta.spellcheck = false;
4886
+ ta.value = existing?.querySelector('code')?.textContent || '';
4887
+ ta.placeholder = 'const answer = 42;';
4888
+ body.append(f, ta);
4889
+ setTimeout(() => ta.focus(), 0);
4890
+ }, [
4891
+ { label: this.t('cancel'), action: () => this.closeDlg() },
4892
+ {
4893
+ label: this.t('save'),
4894
+ primary: true,
4895
+ action: () => {
4896
+ const src = ta.value.replace(/\s+$/, '');
4897
+ const lang = langDd.value();
4898
+ const label = CODE_LANGS.find(([v]) => v === lang)?.[1] || lang;
4899
+ this.closeDlg();
4900
+ if (!src.trim())
4901
+ return;
4902
+ if (existing) {
4903
+ existing.dataset.lang = lang;
4904
+ existing.setAttribute('data-lang-label', label);
4905
+ const codeEl = existing.querySelector('code') || existing;
4906
+ codeEl.textContent = src;
4907
+ this.clearCodeSel();
4908
+ this.onChange();
4909
+ return;
4910
+ }
4911
+ this.restoreSel();
4912
+ document.execCommand('insertHTML', false, `<pre class="code-block" contenteditable="false" data-lang="${lang}" data-lang-label="${this.escapeAttr(label)}">` +
4913
+ `<code>${this.escHtml(src)}</code></pre><p><br></p>`);
4914
+ this.onChange();
4915
+ }
4916
+ }
4917
+ ]);
4918
+ }
4919
+ clearCodeSel() {
4920
+ this.codeCtx?.remove();
4921
+ this.codeCtx = null;
4922
+ this.codeActive?.classList.remove('code-selected');
4923
+ this.codeActive = null;
4924
+ }
4925
+ selectCodeEl(el) {
4926
+ if (this.codeActive === el)
4927
+ return;
4928
+ this.clearCodeSel();
4929
+ this.codeActive = el;
4930
+ el.classList.add('code-selected');
4931
+ this.codeCtx = this.buildCodeCtxToolbar(el);
4932
+ this.positionCodeCtx();
4933
+ }
4934
+ buildCodeCtxToolbar(el) {
4935
+ const ctx = document.createElement('div');
4936
+ ctx.className = 'imgctx';
4937
+ ctx.dir = this.dir();
4938
+ ctx.append(this.ctxBtn(IC.editic, this.t('editcode'), () => this.codeDlg(el)), this.ctxBtn(IC.copyic, this.t('copycode'), () => this.copyCodeBlock(el)), this.ctxSep(), this.ctxBtn(IC.trash, this.t('deletecode'), () => {
4939
+ el.remove();
4940
+ this.clearCodeSel();
4941
+ this.onChange();
4942
+ }));
4943
+ ctx.addEventListener('mousedown', (e) => e.preventDefault());
4944
+ document.body.appendChild(ctx);
4945
+ return ctx;
4946
+ }
4947
+ copyCodeBlock(el) {
4948
+ const text = el.querySelector('code')?.textContent ?? el.textContent ?? '';
4949
+ if (navigator.clipboard?.writeText) {
4950
+ navigator.clipboard.writeText(text).catch(() => { });
4951
+ return;
4952
+ }
4953
+ const ta = document.createElement('textarea');
4954
+ ta.value = text;
4955
+ document.body.appendChild(ta);
4956
+ ta.select();
4957
+ document.execCommand('copy');
4958
+ ta.remove();
4959
+ }
4960
+ positionCodeCtx() {
4961
+ if (!this.codeActive || !document.body.contains(this.codeActive)) {
4962
+ this.clearCodeSel();
4963
+ return;
4964
+ }
4965
+ const r = this.codeActive.getBoundingClientRect();
4966
+ if (!this.codeCtx)
4967
+ return;
4968
+ const cw = this.codeCtx.offsetWidth;
4969
+ const ch = this.codeCtx.offsetHeight;
4970
+ let cx = r.left + scrollX + (r.width - cw) / 2;
4971
+ cx = Math.max(8 + scrollX, Math.min(cx, scrollX + innerWidth - cw - 8));
4972
+ let cy = r.top + scrollY - ch - 8;
4973
+ if (cy < this.ctxMinTop())
4974
+ cy = r.bottom + scrollY + 8;
4975
+ this.codeCtx.style.left = cx + 'px';
4976
+ this.codeCtx.style.top = cy + 'px';
4977
+ }
3517
4978
  /* ---------------------------------------------------------- templates */
3518
4979
  templateMenu(anchor) {
3519
4980
  this.popup(anchor, (el) => this.buildTemplatePickInto(el));
@@ -3753,7 +5214,8 @@ class FableEditor {
3753
5214
  b.addEventListener('mouseleave', () => this.setOpTarget(null));
3754
5215
  return b;
3755
5216
  };
3756
- 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()));
5217
+ const fillBtn = this.ctxBtn(IC.palette, this.t('cellbg'), () => this.cellFillMenu(fillBtn));
5218
+ 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.moverowup, this.t('moverowup'), () => this.tableMove('rowup')), 'row'), hl(this.ctxBtn(IC.moverowdown, this.t('moverowdown'), () => this.tableMove('rowdown')), '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.movecolleft, this.t('movecolleft'), () => this.tableMove('colleft')), 'col'), hl(this.ctxBtn(IC.movecolright, this.t('movecolright'), () => this.tableMove('colright')), 'col'), hl(this.ctxBtn(IC.coldelete, this.t('delcol'), () => this.tableOp('delcol')), 'col'), this.ctxSep(), this.ctxBtn(IC.alignleft, this.t('tblalignleft'), () => this.alignTable('left')), this.ctxBtn(IC.aligncenter, this.t('tblaligncenter'), () => this.alignTable('center')), this.ctxBtn(IC.alignright, this.t('tblalignright'), () => this.alignTable('right')), this.ctxSep(), fillBtn, this.ctxBtn(IC.trash, this.t('deltable'), () => this.tableOp('deltable')), this.ctxBtn(IC.tableic, this.t('tablepropsttl'), () => this.tablePropsDlg()));
3757
5219
  document.body.appendChild(el);
3758
5220
  return el;
3759
5221
  }
@@ -4110,7 +5572,7 @@ class FableEditor {
4110
5572
  const caseBtn = this.ctxBtn(IC.caseic, this.t('changecase'), () => this.caseMenu(caseBtn));
4111
5573
  const lhBtn = this.ctxBtn(IC.lineheight, this.t('lineheight'), () => this.lineHeightMenu(lhBtn));
4112
5574
  const wsBtn = this.ctxBtn(IC.wordspacing, this.t('wordspacing'), () => this.wordSpacingMenu(wsBtn));
4113
- el.append(this.ctxBtn(IC.bold, this.t('bold'), () => this.exec('bold'), 'bold'), this.ctxBtn(IC.italic, this.t('italic'), () => this.exec('italic'), 'italic'), foreBtn, backBtn, this.ctxSep(), this.ctxBtn(IC.quote, this.t('quote'), () => this.toggleBlock('blockquote'), 'blockquote'), this.ctxBtn('<b style="font:700 12px/1 Arial,sans-serif">H2</b>', this.blockLabel('h2'), () => this.toggleBlock('h2'), 'h2'), this.ctxBtn('<b style="font:700 12px/1 Arial,sans-serif">H3</b>', this.blockLabel('h3'), () => this.toggleBlock('h3'), 'h3'), this.ctxSep(), this.ctxBtn(IC.link, this.t('linkttl'), () => this.linkDlg()), caseBtn, lhBtn, wsBtn);
5575
+ el.append(this.ctxBtn(IC.bold, this.t('bold'), () => this.exec('bold'), 'bold'), this.ctxBtn(IC.italic, this.t('italic'), () => this.exec('italic'), 'italic'), foreBtn, backBtn, this.ctxSep(), this.ctxBtn(IC.quote, this.t('quote'), () => this.toggleBlock('blockquote'), 'blockquote'), this.ctxBtn(IC.inlinecodeic, this.t('code'), () => this.toggleInlineCode(), 'code'), this.ctxBtn('<b style="font:700 12px/1 Arial,sans-serif">H2</b>', this.blockLabel('h2'), () => this.toggleBlock('h2'), 'h2'), this.ctxBtn('<b style="font:700 12px/1 Arial,sans-serif">H3</b>', this.blockLabel('h3'), () => this.toggleBlock('h3'), 'h3'), this.ctxSep(), this.ctxBtn(IC.link, this.t('linkttl'), () => this.linkDlg()), caseBtn, lhBtn, wsBtn);
4114
5576
  el.addEventListener('mousedown', (e) => e.preventDefault());
4115
5577
  document.body.appendChild(el);
4116
5578
  this.syncColorSwatches(el);
@@ -4193,6 +5655,9 @@ class FableEditor {
4193
5655
  if (b)
4194
5656
  b.classList.toggle('on', inEd && blkTag === tag);
4195
5657
  });
5658
+ const codeB = container.querySelector('[data-id=code]');
5659
+ if (codeB)
5660
+ codeB.classList.toggle('on', inEd && !!this.closestInlineCode());
4196
5661
  });
4197
5662
  const blk = s ? this.closestBlock(s.anchorNode) : null;
4198
5663
  const dir = blk?.closest('[dir]')?.getAttribute('dir') || this.ed.getAttribute('dir') || this.dir();
@@ -4233,6 +5698,18 @@ class FableEditor {
4233
5698
  fr.readAsDataURL(imgItem.getAsFile());
4234
5699
  return;
4235
5700
  }
5701
+ if (!html) {
5702
+ /* additive pre-check before the normal text pipeline runs (the paste
5703
+ engine itself is untouched): a bare video page URL (YouTube & co.)
5704
+ becomes an embedded player. Anything else falls through unchanged. */
5705
+ const text = cd.getData('text/plain');
5706
+ const embed = /^\S+$/.test(text.trim()) ? this.videoEmbedUrl(text.trim()) : null;
5707
+ if (embed) {
5708
+ document.execCommand('insertHTML', false, this.videoEmbedHTML(embed));
5709
+ this.onChange();
5710
+ return;
5711
+ }
5712
+ }
4236
5713
  if (html) {
4237
5714
  document.execCommand('insertHTML', false, cleanPastedHTML(html, this.dir()));
4238
5715
  }
@@ -4247,6 +5724,9 @@ class FableEditor {
4247
5724
  this.closeDlg();
4248
5725
  this.clearTableHandles();
4249
5726
  this.clearImgPlaceholderSel();
5727
+ this.clearVphPlaceholderSel();
5728
+ this.clearVidHandles();
5729
+ this.clearCodeSel();
4250
5730
  this.shell.dir = this.dir();
4251
5731
  this.ed.dir = this.dir();
4252
5732
  if (this.options.menubar)
@@ -4270,6 +5750,9 @@ class FableEditorComponent {
4270
5750
  toolbar = true;
4271
5751
  statusbar = true;
4272
5752
  readonly = false;
5753
+ primaryColor;
5754
+ toolbarGroupBackground;
5755
+ uiFontFamily;
4273
5756
  init = {};
4274
5757
  value = '';
4275
5758
  editorChange = new EventEmitter();
@@ -4286,6 +5769,9 @@ class FableEditorComponent {
4286
5769
  toolbar: this.toolbar,
4287
5770
  statusbar: this.statusbar,
4288
5771
  readonly: this.readonly,
5772
+ primaryColor: this.primaryColor,
5773
+ toolbarGroupBackground: this.toolbarGroupBackground,
5774
+ uiFontFamily: this.uiFontFamily,
4289
5775
  initialContent: this.value,
4290
5776
  ...this.init,
4291
5777
  onChange: (content) => {
@@ -4330,7 +5816,7 @@ class FableEditorComponent {
4330
5816
  this.editor?.setContent(value ?? '');
4331
5817
  }
4332
5818
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: FableEditorComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
4333
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "18.2.14", type: FableEditorComponent, isStandalone: true, selector: "fable-editor", inputs: { language: "language", height: "height", menubar: "menubar", toolbar: "toolbar", statusbar: "statusbar", readonly: "readonly", init: "init", value: "value" }, outputs: { editorChange: "editorChange", editorReady: "editorReady" }, viewQueries: [{ propertyName: "container", first: true, predicate: ["container"], descendants: true, static: true }], usesOnChanges: true, ngImport: i0, template: `<div #container></div>`, isInline: true, styles: [":host{display:block}\n"] });
5819
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "18.2.14", type: FableEditorComponent, isStandalone: true, selector: "fable-editor", inputs: { language: "language", height: "height", menubar: "menubar", toolbar: "toolbar", statusbar: "statusbar", readonly: "readonly", primaryColor: "primaryColor", toolbarGroupBackground: "toolbarGroupBackground", uiFontFamily: "uiFontFamily", init: "init", value: "value" }, outputs: { editorChange: "editorChange", editorReady: "editorReady" }, viewQueries: [{ propertyName: "container", first: true, predicate: ["container"], descendants: true, static: true }], usesOnChanges: true, ngImport: i0, template: `<div #container></div>`, isInline: true, styles: [":host{display:block}\n"] });
4334
5820
  }
4335
5821
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: FableEditorComponent, decorators: [{
4336
5822
  type: Component,
@@ -4350,6 +5836,12 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
4350
5836
  type: Input
4351
5837
  }], readonly: [{
4352
5838
  type: Input
5839
+ }], primaryColor: [{
5840
+ type: Input
5841
+ }], toolbarGroupBackground: [{
5842
+ type: Input
5843
+ }], uiFontFamily: [{
5844
+ type: Input
4353
5845
  }], init: [{
4354
5846
  type: Input
4355
5847
  }], value: [{