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