fable-editor 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +175 -0
  3. package/dist/angular/LICENSE +21 -0
  4. package/dist/angular/README.md +175 -0
  5. package/dist/angular/angular/fable-editor.component.d.ts +29 -0
  6. package/dist/angular/angular/fable-editor.module.d.ts +7 -0
  7. package/dist/angular/angular/index.d.ts +2 -0
  8. package/dist/angular/core/config.d.ts +17 -0
  9. package/dist/angular/core/docx-import.d.ts +1 -0
  10. package/dist/angular/core/editor.d.ts +219 -0
  11. package/dist/angular/core/i18n.d.ts +139 -0
  12. package/dist/angular/core/icons.d.ts +2 -0
  13. package/dist/angular/core/index.d.ts +7 -0
  14. package/dist/angular/core/paste-engine.d.ts +6 -0
  15. package/dist/angular/core/types.d.ts +87 -0
  16. package/dist/angular/esm2022/angular/fable-editor.component.mjs +101 -0
  17. package/dist/angular/esm2022/angular/fable-editor.module.mjs +16 -0
  18. package/dist/angular/esm2022/angular/index.mjs +3 -0
  19. package/dist/angular/esm2022/core/config.mjs +122 -0
  20. package/dist/angular/esm2022/core/docx-import.mjs +391 -0
  21. package/dist/angular/esm2022/core/editor.mjs +2700 -0
  22. package/dist/angular/esm2022/core/i18n.mjs +278 -0
  23. package/dist/angular/esm2022/core/icons.mjs +89 -0
  24. package/dist/angular/esm2022/core/index.mjs +6 -0
  25. package/dist/angular/esm2022/core/paste-engine.mjs +482 -0
  26. package/dist/angular/esm2022/core/types.mjs +2 -0
  27. package/dist/angular/esm2022/fable-editor.mjs +5 -0
  28. package/dist/angular/esm2022/index.mjs +2 -0
  29. package/dist/angular/fesm2022/fable-editor.mjs +4176 -0
  30. package/dist/angular/fesm2022/fable-editor.mjs.map +1 -0
  31. package/dist/angular/index.d.ts +1 -0
  32. package/dist/core/config.d.ts +18 -0
  33. package/dist/core/config.d.ts.map +1 -0
  34. package/dist/core/docx-import.d.ts +2 -0
  35. package/dist/core/docx-import.d.ts.map +1 -0
  36. package/dist/core/editor.css +1175 -0
  37. package/dist/core/editor.d.ts +221 -0
  38. package/dist/core/editor.d.ts.map +1 -0
  39. package/dist/core/i18n.d.ts +141 -0
  40. package/dist/core/i18n.d.ts.map +1 -0
  41. package/dist/core/icons.d.ts +3 -0
  42. package/dist/core/icons.d.ts.map +1 -0
  43. package/dist/core/index.cjs +12 -0
  44. package/dist/core/index.d.ts +8 -0
  45. package/dist/core/index.d.ts.map +1 -0
  46. package/dist/core/index.mjs +2789 -0
  47. package/dist/core/paste-engine.d.ts +7 -0
  48. package/dist/core/paste-engine.d.ts.map +1 -0
  49. package/dist/core/types.d.ts +88 -0
  50. package/dist/core/types.d.ts.map +1 -0
  51. package/dist/react/FableEditor.d.ts +13 -0
  52. package/dist/react/FableEditor.d.ts.map +1 -0
  53. package/dist/react/index.cjs +41 -0
  54. package/dist/react/index.d.ts +3 -0
  55. package/dist/react/index.d.ts.map +1 -0
  56. package/dist/react/index.mjs +3471 -0
  57. package/package.json +105 -0
  58. package/style.css +1175 -0
@@ -0,0 +1,4176 @@
1
+ import * as i0 from '@angular/core';
2
+ import { EventEmitter, Output, Input, ViewChild, Component, NgModule } from '@angular/core';
3
+
4
+ const I18N = {
5
+ en: {
6
+ dir: 'ltr',
7
+ file: 'File',
8
+ edit: 'Edit',
9
+ view: 'View',
10
+ insert: 'Insert',
11
+ format: 'Format',
12
+ tools: 'Tools',
13
+ table: 'Table',
14
+ help: 'Help',
15
+ newdoc: 'New document',
16
+ preview: 'Preview',
17
+ print: 'Print…',
18
+ undo: 'Undo',
19
+ redo: 'Redo',
20
+ cut: 'Cut',
21
+ copy: 'Copy',
22
+ paste: 'Paste',
23
+ selectall: 'Select all',
24
+ fullscreen: 'Fullscreen',
25
+ sourcecode: 'Source code',
26
+ wordcount: 'Word count',
27
+ image: 'Image…',
28
+ inserttable: 'Table',
29
+ hr: 'Horizontal line',
30
+ charmap: 'Special character…',
31
+ datetime: 'Date/time',
32
+ pagebreak: 'Page break',
33
+ nbsp: 'Nonbreaking space',
34
+ bold: 'Bold',
35
+ italic: 'Italic',
36
+ underline: 'Underline',
37
+ strikethrough: 'Strikethrough',
38
+ superscript: 'Superscript',
39
+ subscript: 'Subscript',
40
+ clearformat: 'Clear formatting',
41
+ rowabove: 'Insert row before',
42
+ rowbelow: 'Insert row after',
43
+ delrow: 'Delete row',
44
+ colbefore: 'Insert column before',
45
+ colafter: 'Insert column after',
46
+ delcol: 'Delete column',
47
+ deltable: 'Delete table',
48
+ ltr: 'Left to right',
49
+ rtl: 'Right to left',
50
+ alignleft: 'Align left',
51
+ aligncenter: 'Align center',
52
+ alignright: 'Align right',
53
+ alignjustify: 'Justify',
54
+ numlist: 'Numbered list',
55
+ bullist: 'Bullet list',
56
+ forecolor: 'Text color',
57
+ backcolor: 'Background color',
58
+ removeformat: 'Clear formatting',
59
+ outdent: 'Decrease indent',
60
+ indent: 'Increase indent',
61
+ fontfamily: 'Font family',
62
+ fontsize: 'Font size',
63
+ blocks: 'Blocks',
64
+ quickimage: 'Insert image',
65
+ quicktable: 'Insert table',
66
+ more: 'More…',
67
+ para: 'Paragraph',
68
+ heading: 'Heading',
69
+ pre: 'Preformatted',
70
+ removecolor: 'Remove color',
71
+ currentcolor: 'Current color',
72
+ statushelp: 'Press Alt+0 for help',
73
+ words: 'words',
74
+ helpttl: 'Help',
75
+ shortcuts: 'Handy shortcuts',
76
+ close: 'Close',
77
+ save: 'Save',
78
+ cancel: 'Cancel',
79
+ srcttl: 'Source code',
80
+ wcttl: 'Word count',
81
+ wcwords: 'Words',
82
+ wcchars: 'Characters',
83
+ pastehint: 'Use Ctrl+V / ⌘V to paste',
84
+ liststyles_ol: ['Default', 'Lower alpha', 'Lower roman', 'Upper alpha', 'Upper roman'],
85
+ liststyles_ul: ['Default', 'Circle', 'Square'],
86
+ previewttl: 'Preview',
87
+ tablepropsttl: 'Table properties',
88
+ tblwidth: 'Width',
89
+ tblcellspacing: 'Cell spacing',
90
+ tblcellpadding: 'Cell padding',
91
+ tblborder: 'Border width',
92
+ tblalign: 'Alignment',
93
+ alignnone: 'None',
94
+ quote: 'Blockquote',
95
+ link: 'Link…',
96
+ linkttl: 'Insert/edit link',
97
+ linkurl: 'URL',
98
+ unlink: 'Remove link',
99
+ lineheight: 'Line height',
100
+ wordspacing: 'Word spacing',
101
+ changecase: 'Change case',
102
+ lowercase: 'Lowercase',
103
+ uppercase: 'Uppercase',
104
+ capitalize: 'Capitalize Each Word',
105
+ customcolor: 'Custom color…',
106
+ normal: 'Normal',
107
+ revhistory: 'Revision history',
108
+ revempty: 'No revisions yet',
109
+ restore: 'Restore',
110
+ currentver: 'Current version',
111
+ importword: 'Import from Word…',
112
+ importfail: 'Could not import this file. Please choose a valid .docx document.',
113
+ restoredraft: 'Restore last draft',
114
+ draftempty: 'No saved draft found',
115
+ deleteimg: 'Delete image',
116
+ rotateleft: 'Rotate left',
117
+ rotateright: 'Rotate right',
118
+ fliphorizontal: 'Flip horizontal',
119
+ flipvertical: 'Flip vertical',
120
+ cell: 'Cell',
121
+ row: 'Row',
122
+ column: 'Column',
123
+ cellprops: 'Cell properties…',
124
+ rowprops: 'Row properties…',
125
+ colprops: 'Column properties…',
126
+ tblheight: 'Height',
127
+ valign: 'Vertical align',
128
+ valigntop: 'Top',
129
+ valignmiddle: 'Middle',
130
+ valignbottom: 'Bottom',
131
+ fill: 'Fill',
132
+ nocell: 'Click inside a table cell first',
133
+ uploadimg: 'Upload from computer',
134
+ imagelink: 'Insert from URL',
135
+ imageurlph: 'Paste image URL…',
136
+ insertimg: 'Insert',
137
+ dropimage: 'Drop image here',
138
+ uploading: 'Uploading…',
139
+ uploadfailed: 'Upload failed — click to retry'
140
+ },
141
+ ar: {
142
+ dir: 'rtl',
143
+ file: 'ملف',
144
+ edit: 'تحرير',
145
+ view: 'عرض',
146
+ insert: 'إدراج',
147
+ format: 'التنسيق',
148
+ tools: 'الأدوات',
149
+ table: 'جدول',
150
+ help: 'تعليمات',
151
+ newdoc: 'مستند جديد',
152
+ preview: 'معاينة',
153
+ print: 'طباعة…',
154
+ undo: 'تراجع',
155
+ redo: 'إعادة',
156
+ cut: 'قص',
157
+ copy: 'نسخ',
158
+ paste: 'لصق',
159
+ selectall: 'تحديد الكل',
160
+ fullscreen: 'ملء الشاشة',
161
+ sourcecode: 'الكود المصدري',
162
+ wordcount: 'عدد الكلمات',
163
+ image: 'صورة…',
164
+ inserttable: 'جدول',
165
+ hr: 'خط أفقي',
166
+ charmap: 'رمز خاص…',
167
+ datetime: 'التاريخ/الوقت',
168
+ pagebreak: 'فاصل الصفحة',
169
+ nbsp: 'مسافة غير فاصلة',
170
+ bold: 'غامق',
171
+ italic: 'مائل',
172
+ underline: 'تسطير',
173
+ strikethrough: 'يتوسطه خط',
174
+ superscript: 'نص مرتفع',
175
+ subscript: 'نص منخفض',
176
+ clearformat: 'مسح التنسيق',
177
+ rowabove: 'إدراج صف قبل',
178
+ rowbelow: 'إدراج صف بعد',
179
+ delrow: 'حذف الصف',
180
+ colbefore: 'إدراج عمود قبل',
181
+ colafter: 'إدراج عمود بعد',
182
+ delcol: 'حذف العمود',
183
+ deltable: 'حذف الجدول',
184
+ ltr: 'من اليسار إلى اليمين',
185
+ rtl: 'من اليمين إلى اليسار',
186
+ alignleft: 'محاذاة لليسار',
187
+ aligncenter: 'توسيط',
188
+ alignright: 'محاذاة لليمين',
189
+ alignjustify: 'ضبط',
190
+ numlist: 'قائمة رقمية',
191
+ bullist: 'قائمة نقطية',
192
+ forecolor: 'لون النص',
193
+ backcolor: 'لون الخلفية',
194
+ removeformat: 'مسح التنسيق',
195
+ outdent: 'إنقاص المسافة البادئة',
196
+ indent: 'زيادة المسافة البادئة',
197
+ fontfamily: 'عائلة الخط',
198
+ fontsize: 'حجم الخط',
199
+ blocks: 'الفقرات',
200
+ quickimage: 'إدراج صورة',
201
+ quicktable: 'إدراج جدول',
202
+ more: 'المزيد…',
203
+ para: 'الفقرة',
204
+ heading: 'العنوان',
205
+ pre: 'منسق مسبقاً',
206
+ removecolor: 'إزالة اللون',
207
+ currentcolor: 'اللون الحالي',
208
+ statushelp: 'اضغط على Alt+0 للحصول على مساعدة',
209
+ words: 'الكلمات',
210
+ helpttl: 'تعليمات',
211
+ shortcuts: 'اختصارات مفيدة',
212
+ close: 'إغلاق',
213
+ save: 'حفظ',
214
+ cancel: 'إلغاء',
215
+ srcttl: 'الكود المصدري',
216
+ wcttl: 'عدد الكلمات',
217
+ wcwords: 'الكلمات',
218
+ wcchars: 'الأحرف',
219
+ pastehint: 'استخدم Ctrl+V / ⌘V للصق',
220
+ liststyles_ol: ['افتراضي', 'أبجدي صغير', 'روماني صغير', 'أبجدي كبير', 'روماني كبير'],
221
+ liststyles_ul: ['افتراضي', 'دائرة', 'مربع'],
222
+ previewttl: 'معاينة',
223
+ tablepropsttl: 'خصائص الجدول',
224
+ tblwidth: 'العرض',
225
+ tblcellspacing: 'تباعد الخلايا',
226
+ tblcellpadding: 'حشو الخلايا',
227
+ tblborder: 'عرض الحدود',
228
+ tblalign: 'المحاذاة',
229
+ alignnone: 'بدون',
230
+ quote: 'اقتباس',
231
+ link: 'رابط…',
232
+ linkttl: 'إدراج/تعديل رابط',
233
+ linkurl: 'الرابط',
234
+ unlink: 'إزالة الرابط',
235
+ lineheight: 'تباعد الأسطر',
236
+ wordspacing: 'تباعد الكلمات',
237
+ changecase: 'تغيير حالة الأحرف',
238
+ lowercase: 'أحرف صغيرة',
239
+ uppercase: 'أحرف كبيرة',
240
+ capitalize: 'تكبير أول حرف',
241
+ customcolor: 'لون مخصص…',
242
+ normal: 'عادي',
243
+ revhistory: 'سجل المراجعات',
244
+ revempty: 'لا توجد مراجعات بعد',
245
+ restore: 'استعادة',
246
+ currentver: 'النسخة الحالية',
247
+ importword: 'استيراد من Word…',
248
+ importfail: 'تعذر استيراد هذا الملف. الرجاء اختيار مستند ‎.docx صالح.',
249
+ restoredraft: 'استعادة آخر مسودة',
250
+ draftempty: 'لا توجد مسودة محفوظة',
251
+ deleteimg: 'حذف الصورة',
252
+ rotateleft: 'تدوير لليسار',
253
+ rotateright: 'تدوير لليمين',
254
+ fliphorizontal: 'قلب أفقي',
255
+ flipvertical: 'قلب رأسي',
256
+ cell: 'خلية',
257
+ row: 'صف',
258
+ column: 'عمود',
259
+ cellprops: 'خصائص الخلية…',
260
+ rowprops: 'خصائص الصف…',
261
+ colprops: 'خصائص العمود…',
262
+ tblheight: 'الارتفاع',
263
+ valign: 'محاذاة عمودية',
264
+ valigntop: 'أعلى',
265
+ valignmiddle: 'وسط',
266
+ valignbottom: 'أسفل',
267
+ fill: 'تعبئة',
268
+ nocell: 'انقر داخل خلية جدول أولاً',
269
+ uploadimg: 'رفع من الجهاز',
270
+ imagelink: 'إدراج من رابط',
271
+ imageurlph: 'الصق رابط الصورة…',
272
+ insertimg: 'إدراج',
273
+ dropimage: 'أسقط الصورة هنا',
274
+ uploading: 'جارٍ الرفع…',
275
+ uploadfailed: 'فشل الرفع — انقر لإعادة المحاولة'
276
+ }
277
+ };
278
+ function getStrings(lang) {
279
+ return I18N[lang];
280
+ }
281
+
282
+ /** Transcribed group-by-group from the editor's built-in toolbar layout so that
283
+ * `toolbar: true` (the default) renders identically to the hard-coded original. */
284
+ const DEFAULT_TOOLBAR = 'undo redo | preview print | importword revhistory | fontfamily fontsize | fontsizeincrease fontsizedecrease | ' +
285
+ 'bold italic underline strikethrough | forecolor backcolor | alignleft aligncenter alignright alignjustify | ' +
286
+ 'bullist numlist | outdent indent | link blockquote changecase lineheight wordspacing | removeformat | blocks | ' +
287
+ 'ltr rtl | quickimage quicktable charmap | fullscreen sourcecode';
288
+ /** Transcribed from the editor's built-in menu keys so that `menubar: true` (the
289
+ * default) renders identically to the hard-coded original. */
290
+ const DEFAULT_MENUBAR = 'file edit view insert format tools table help';
291
+ const FONTS = [
292
+ ['Andale Mono', 'andale mono,times'],
293
+ ['Arial', 'arial,helvetica,sans-serif'],
294
+ ['Arial Black', 'arial black,avant garde'],
295
+ ['Book Antiqua', 'book antiqua,palatino'],
296
+ ['Comic Sans MS', 'comic sans ms,sans-serif'],
297
+ ['Courier New', 'courier new,courier'],
298
+ ['Georgia', 'georgia,palatino'],
299
+ ['Helvetica', 'helvetica'],
300
+ ['Impact', 'impact,chicago'],
301
+ ['Oswald', 'oswald'],
302
+ ['Sakkal Majalla', 'sakkal majalla'],
303
+ ['Symbol', 'symbol'],
304
+ ['Tahoma', 'tahoma,arial,helvetica,sans-serif'],
305
+ ['Terminal', 'terminal,monaco'],
306
+ ['Times New Roman', 'times new roman,times'],
307
+ ['Trebuchet MS', 'trebuchet ms,geneva'],
308
+ ['Verdana', 'verdana,geneva'],
309
+ ['Webdings', 'webdings'],
310
+ ['Wingdings', 'wingdings,zapf dingbats']
311
+ ];
312
+ const SIZES = ['8px', '9px', '10px', '11px', '12px', '14px', '16px', '18px', '20px', '24px', '28px', '32px', '36px', '48px', '72px', '96px'];
313
+ const MIN_FONT_PX = 1;
314
+ const MAX_FONT_PX = 100;
315
+ const LINE_HEIGHTS = ['1', '1.15', '1.4', '1.5', '1.75', '2', '2.5', '3'];
316
+ const WORD_SPACINGS = ['1px', '2px', '4px', '6px', '8px', '12px'];
317
+ const BLOCKS = [
318
+ ['p', 'para'],
319
+ ['h1', 'heading1'],
320
+ ['h2', 'heading2'],
321
+ ['h3', 'heading3'],
322
+ ['h4', 'heading4'],
323
+ ['h5', 'heading5'],
324
+ ['h6', 'heading6'],
325
+ ['pre', 'pre']
326
+ ];
327
+ /** Quick-access text color chips shown directly in the toolbar. */
328
+ const QUICK_COLORS = ['#F76707', '#4263EB', '#CC5DE8'];
329
+ const COLORS = [
330
+ '#BFEDD2',
331
+ '#FBEEB8',
332
+ '#F8CAC6',
333
+ '#ECCAFA',
334
+ '#C2E0F4',
335
+ '#2DC26B',
336
+ '#F1C40F',
337
+ '#E03E2D',
338
+ '#B96AD9',
339
+ '#3598DB',
340
+ '#169179',
341
+ '#E67E23',
342
+ '#BA372A',
343
+ '#843FA1',
344
+ '#236FA1',
345
+ '#ECF0F1',
346
+ '#CED4D9',
347
+ '#95A5A6',
348
+ '#7E8C8D',
349
+ '#34495E',
350
+ '#000000',
351
+ '#FFFFFF'
352
+ ];
353
+ const CHARS = [
354
+ '©',
355
+ '®',
356
+ '™',
357
+ '§',
358
+ '¶',
359
+ '†',
360
+ '‡',
361
+ '•',
362
+ '…',
363
+ '′',
364
+ '″',
365
+ '‰',
366
+ '°',
367
+ '±',
368
+ '×',
369
+ '÷',
370
+ '≈',
371
+ '≠',
372
+ '≤',
373
+ '≥',
374
+ '∞',
375
+ 'µ',
376
+ '√',
377
+ '∑',
378
+ '€',
379
+ '£',
380
+ '¥',
381
+ '¢',
382
+ '←',
383
+ '→',
384
+ '↑',
385
+ '↓',
386
+ '«',
387
+ '»',
388
+ '“',
389
+ '”',
390
+ '‘',
391
+ '’',
392
+ '–',
393
+ '—',
394
+ '؟',
395
+ '،',
396
+ '؛',
397
+ '٪',
398
+ '٭',
399
+ 'ـ',
400
+ '٠',
401
+ '١'
402
+ ];
403
+
404
+ const S = (inner, vb) => `<svg viewBox="${vb || '0 0 24 24'}">${inner}</svg>`;
405
+ /** Google Material Symbols icon: solid-fill path on the -960..0/0..960 grid those exports use. */
406
+ const G = (path) => S(`<path class="f" d="${path}"/>`, '0 -960 960 960');
407
+ /** Three-line list icon with custom markers (text glyphs) per line. */
408
+ const LIST = (marks, fs = 7.5) => S('<path d="M10 6h10M10 12h10M10 18h10"/>' +
409
+ marks
410
+ .map((m, i) => `<text x="2.5" y="${8.4 + i * 6}" font-size="${fs}" stroke="none" fill="#222f3e" font-family="Arial">${m}</text>`)
411
+ .join(''));
412
+ const IC = {
413
+ bold: G('M272-200v-560h221q65 0 120 40t55 111q0 51-23 78.5T602-491q25 11 55.5 41t30.5 90q0 89-65 124.5T501-200H272Zm121-112h104q48 0 58.5-24.5T566-372q0-11-10.5-35.5T494-432H393v120Zm0-228h93q33 0 48-17t15-38q0-24-17-39t-44-15h-95v109Z'),
414
+ italic: G('M200-200v-100h160l120-360H320v-100h400v100H580L460-300h140v100H200Z'),
415
+ underline: G('M200-120v-80h560v80H200Zm123-223q-56-63-56-167v-330h103v336q0 56 28 91t82 35q54 0 82-35t28-91v-336h103v330q0 104-56 167t-157 63q-101 0-157-63Z'),
416
+ strikethrough: G('M486-160q-76 0-135-45t-85-123l88-38q14 48 48.5 79t85.5 31q42 0 76-20t34-64q0-18-7-33t-19-27h112q5 14 7.5 28.5T694-340q0 86-61.5 133T486-160ZM80-480v-80h800v80H80Zm402-326q66 0 115.5 32.5T674-674l-88 39q-9-29-33.5-52T484-710q-41 0-68 18.5T386-640h-96q2-69 54.5-117.5T482-806Z'),
417
+ undo: G('M280-200v-80h284q63 0 109.5-40T720-420q0-60-46.5-100T564-560H312l104 104-56 56-200-200 200-200 56 56-104 104h252q97 0 166.5 63T800-420q0 94-69.5 157T564-200H280Z'),
418
+ redo: G('M396-200q-97 0-166.5-63T160-420q0-94 69.5-157T396-640h252L544-744l56-56 200 200-200 200-56-56 104-104H396q-63 0-109.5 40T240-420q0 60 46.5 100T396-280h284v80H396Z'),
419
+ ltr: G('M360-360v-200q-66 0-113-47t-47-113q0-66 47-113t113-47h320v80h-80v440h-80v-440h-80v440h-80Zm0-280v-160q-33 0-56.5 23.5T280-720q0 33 23.5 56.5T360-640Zm0-80ZM680-80l-56-56 64-64H120v-80h568l-64-64 56-56 160 160L680-80Z'),
420
+ rtl: G('M360-360v-200q-66 0-113-47t-47-113q0-66 47-113t113-47h320v80h-80v440h-80v-440h-80v440h-80Zm-88 160 64 64-56 56-160-160 160-160 56 56-64 64h568v80H272Zm88-440v-160q-33 0-56.5 23.5T280-720q0 33 23.5 56.5T360-640Zm0-80Z'),
421
+ alignleft: G('M120-120v-80h720v80H120Zm0-160v-80h480v80H120Zm0-160v-80h720v80H120Zm0-160v-80h480v80H120Zm0-160v-80h720v80H120Z'),
422
+ aligncenter: G('M120-120v-80h720v80H120Zm160-160v-80h400v80H280ZM120-440v-80h720v80H120Zm160-160v-80h400v80H280ZM120-760v-80h720v80H120Z'),
423
+ alignright: G('M120-760v-80h720v80H120Zm240 160v-80h480v80H360ZM120-440v-80h720v80H120Zm240 160v-80h480v80H360ZM120-120v-80h720v80H120Z'),
424
+ alignjustify: G('M120-120v-80h720v80H120Zm0-160v-80h720v80H120Zm0-160v-80h720v80H120Zm0-160v-80h720v80H120Zm0-160v-80h720v80H120Z'),
425
+ fullscreen: G('M120-120v-200h80v120h120v80H120Zm520 0v-80h120v-120h80v200H640ZM120-640v-200h200v80H200v120h-80Zm640 0v-120H640v-80h200v200h-80Z'),
426
+ clearformatic: G('m528-546-93-93-121-121h486v120H568l-40 94ZM792-56 460-388l-80 188H249l119-280L56-792l56-56 736 736-56 56Z'),
427
+ numlist: G('M120-80v-60h100v-30h-60v-60h60v-30H120v-60h120q17 0 28.5 11.5T280-280v40q0 17-11.5 28.5T240-200q17 0 28.5 11.5T280-160v40q0 17-11.5 28.5T240-80H120Zm0-280v-110q0-17 11.5-28.5T160-510h60v-30H120v-60h120q17 0 28.5 11.5T280-560v70q0 17-11.5 28.5T240-450h-60v30h100v60H120Zm60-280v-180h-60v-60h120v240h-60Zm180 440v-80h480v80H360Zm0-240v-80h480v80H360Zm0-240v-80h480v80H360Z'),
428
+ bullist: G('M360-200v-80h480v80H360Zm0-240v-80h480v80H360Zm0-240v-80h480v80H360ZM200-160q-33 0-56.5-23.5T120-240q0-33 23.5-56.5T200-320q33 0 56.5 23.5T280-240q0 33-23.5 56.5T200-160Zm0-240q-33 0-56.5-23.5T120-480q0-33 23.5-56.5T200-560q33 0 56.5 23.5T280-480q0 33-23.5 56.5T200-400Zm-56.5-263.5Q120-687 120-720t23.5-56.5Q167-800 200-800t56.5 23.5Q280-753 280-720t-23.5 56.5Q233-640 200-640t-56.5-23.5Z'),
429
+ chev: S('<path d="M2 3.5 6 7.5 10 3.5"/>', '0 0 12 11'),
430
+ backcolor: G('M440-80q-33 0-56.5-23.5T360-160v-160H240q-33 0-56.5-23.5T160-400v-280q0-66 47-113t113-47h480v440q0 33-23.5 56.5T720-320H600v160q0 33-23.5 56.5T520-80h-80ZM240-560h480v-200h-40v160h-80v-160h-40v80h-80v-80H320q-33 0-56.5 23.5T240-680v120Zm0 160h480v-80H240v80Zm0 0v-80 80Z'),
431
+ outdent: G('M120-120v-80h720v80H120Zm320-160v-80h400v80H440Zm0-160v-80h400v80H440Zm0-160v-80h400v80H440ZM120-760v-80h720v80H120Zm160 440L120-480l160-160v320Z'),
432
+ indent: G('M120-120v-80h720v80H120Zm320-160v-80h400v80H440Zm0-160v-80h400v80H440Zm0-160v-80h400v80H440ZM120-760v-80h720v80H120Zm0 440v-320l160 160-160 160Z'),
433
+ more: S('<circle class="f" cx="5" cy="12" r="1.7"/><circle class="f" cx="12" cy="12" r="1.7"/><circle class="f" cx="19" cy="12" r="1.7"/>'),
434
+ image: G('M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h560q33 0 56.5 23.5T840-760v560q0 33-23.5 56.5T760-120H200Zm0-80h560v-560H200v560Zm40-80h480L570-480 450-320l-90-120-120 160Zm-40 80v-560 560Z'),
435
+ uploadic: G('M440-200v-326L336-422l-56-58 200-200 200 200-56 58-104-104v326h-80ZM240-80q-33 0-56.5-23.5T160-160v-120h80v120h480v-120h80v120q0 33-23.5 56.5T720-80H240Z'),
436
+ tableic: G('M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h560q33 0 56.5 23.5T840-760v560q0 33-23.5 56.5T760-120H200Zm240-240H200v160h240v-160Zm80 0v160h240v-160H520Zm-80-80v-160H200v160h240Zm80 0h240v-160H520v160ZM200-680h560v-80H200v80Z'),
437
+ hric: S('<path d="M4 12h16"/>'),
438
+ charic: G('M120-120v-120h200q-84-45-132-125t-48-175q0-142 99-241t241-99q142 0 241 99t99 241q0 95-48 175T640-240h200v120H520v-204q78-14 129-75t51-141q0-92-64-156t-156-64q-92 0-156 64t-64 156q0 80 51 141t129 75v204H120Z'),
439
+ dateic: S('<rect x="4" y="6" width="16" height="14" rx="1.5"/><path d="M4 10h16M8 4v4M16 4v4"/>'),
440
+ pbic: G('M240-80q-33 0-56.5-23.5T160-160v-120h80v120h480v-120h80v120q0 33-23.5 56.5T720-80H240Zm-80-440v-280q0-33 23.5-56.5T240-880h320l240 240v120h-80v-80H520v-200H240v280h-80Zm200 160v-80h240v80H360Zm320 0v-80h240v80H680Zm-640 0v-80h240v80H40Zm440-160Zm0 240Z'),
441
+ nbspic: S('<path d="M6 12v4h12v-4"/>'),
442
+ srcic: G('M240-280 40-480l200-200 56 56-143 144 143 144-56 56Zm178 132-76-24 200-640 76 24-200 640Zm302-132-56-56 143-144-143-144 56-56 200 200-200 200Z'),
443
+ prevw: G('M607.5-372.5Q660-425 660-500t-52.5-127.5Q555-680 480-680t-127.5 52.5Q300-575 300-500t52.5 127.5Q405-320 480-320t127.5-52.5Zm-204-51Q372-455 372-500t31.5-76.5Q435-608 480-608t76.5 31.5Q588-545 588-500t-31.5 76.5Q525-392 480-392t-76.5-31.5ZM214-281.5Q94-363 40-500q54-137 174-218.5T480-800q146 0 266 81.5T920-500q-54 137-174 218.5T480-200q-146 0-266-81.5ZM480-500Zm207.5 160.5Q782-399 832-500q-50-101-144.5-160.5T480-720q-113 0-207.5 59.5T128-500q50 101 144.5 160.5T480-280q113 0 207.5-59.5Z'),
444
+ printic: G('M640-640v-120H320v120h-80v-200h480v200h-80Zm-480 80h640-640Zm560 100q17 0 28.5-11.5T760-500q0-17-11.5-28.5T720-540q-17 0-28.5 11.5T680-500q0 17 11.5 28.5T720-460Zm-80 260v-160H320v160h320Zm80 80H240v-160H80v-240q0-51 35-85.5t85-34.5h560q51 0 85.5 34.5T880-520v240H720v160Zm80-240v-160q0-17-11.5-28.5T760-560H200q-17 0-28.5 11.5T160-520v160h80v-80h480v80h80Z'),
445
+ cutic: G('M760-120 480-400l-94 94q8 15 11 32t3 34q0 66-47 113T240-80q-66 0-113-47T80-240q0-66 47-113t113-47q17 0 34 3t32 11l94-94-94-94q-15 8-32 11t-34 3q-66 0-113-47T80-720q0-66 47-113t113-47q66 0 113 47t47 113q0 17-3 34t-11 32l494 494v40H760ZM600-520l-80-80 240-240h120v40L600-520ZM296.5-663.5Q320-687 320-720t-23.5-56.5Q273-800 240-800t-56.5 23.5Q160-753 160-720t23.5 56.5Q207-640 240-640t56.5-23.5ZM494-466q6-6 6-14t-6-14q-6-6-14-6t-14 6q-6 6-6 14t6 14q6 6 14 6t14-6ZM296.5-183.5Q320-207 320-240t-23.5-56.5Q273-320 240-320t-56.5 23.5Q160-273 160-240t23.5 56.5Q207-160 240-160t56.5-23.5Z'),
446
+ copyic: G('M360-240q-33 0-56.5-23.5T280-320v-480q0-33 23.5-56.5T360-880h360q33 0 56.5 23.5T800-800v480q0 33-23.5 56.5T720-240H360Zm0-80h360v-480H360v480ZM200-80q-33 0-56.5-23.5T120-160v-560h80v560h440v80H200Zm160-240v-480 480Z'),
447
+ pasteic: G('M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h167q11-35 43-57.5t70-22.5q40 0 71.5 22.5T594-840h166q33 0 56.5 23.5T840-760v560q0 33-23.5 56.5T760-120H200Zm0-80h560v-560h-80v120H280v-120h-80v560Zm308.5-571.5Q520-783 520-800t-11.5-28.5Q497-840 480-840t-28.5 11.5Q440-817 440-800t11.5 28.5Q463-760 480-760t28.5-11.5Z'),
448
+ selall: G('M280-280v-400h400v400H280Zm80-80h240v-240H360v240ZM200-200v80q-33 0-56.5-23.5T120-200h80Zm-80-80v-80h80v80h-80Zm0-160v-80h80v80h-80Zm0-160v-80h80v80h-80Zm80-160h-80q0-33 23.5-56.5T200-840v80Zm80 640v-80h80v80h-80Zm0-640v-80h80v80h-80Zm160 640v-80h80v80h-80Zm0-640v-80h80v80h-80Zm160 640v-80h80v80h-80Zm0-640v-80h80v80h-80Zm160 640v-80h80q0 33-23.5 56.5T760-120Zm0-160v-80h80v80h-80Zm0-160v-80h80v80h-80Zm0-160v-80h80v80h-80Zm0-160v-80q33 0 56.5 23.5T840-760h-80Z'),
449
+ superscriptic: G('M760-600v-80q0-17 11.5-28.5T800-720h80v-40H760v-40h120q17 0 28.5 11.5T920-760v40q0 17-11.5 28.5T880-680h-80v40h120v40H760ZM235-160l185-291-172-269h106l124 200h4l123-200h107L539-451l186 291H618L482-377h-4L342-160H235Z'),
450
+ subscriptic: G('M760-160v-80q0-17 11.5-28.5T800-280h80v-40H760v-40h120q17 0 28.5 11.5T920-320v40q0 17-11.5 28.5T880-240h-80v40h120v40H760Zm-525-80 185-291-172-269h106l124 200h4l123-200h107L539-531l186 291H618L482-457h-4L342-240H235Z'),
451
+ newic: S('<path d="M6 3h8l5 5v13H6z"/><path d="M14 3v5h5"/>'),
452
+ helpic: S('<circle cx="12" cy="12" r="8.5"/><path d="M9.6 9.6a2.5 2.5 0 1 1 3.4 2.6c-.8.3-1 .9-1 1.8"/><circle class="f" cx="12" cy="16.6" r="1.1"/>'),
453
+ wcic: S('<path d="M4 6h16M4 10h16M4 14h9"/><path d="M15 17.5l2 2 3.6-4"/>'),
454
+ rowbefore: G('M200-160h560v-240H200v240Zm640 80H120v-720h160v80h-80v240h560v-240h-80v-80h160v720ZM480-480Zm0 80v-80 80Zm0 0Zm-40-240v-80h-80v-80h80v-80h80v80h80v80h-80v80h-80Z'),
455
+ rowafter: G('M200-560h560v-240H200v240Zm-80 400v-720h720v720H680v-80h80v-240H200v240h80v80H120Zm360-320Zm0-80v80-80Zm0 0ZM440-80v-80h-80v-80h80v-80h80v80h80v80h-80v80h-80Z'),
456
+ rowdelete: G('M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h560q33 0 56.5 23.5T840-760v560q0 33-23.5 56.5T760-120H200ZM200-200h560v-560H200ZM200-400v-160h560v160H200ZM330-580l250 250 50-50-250-250ZM330-380l250-250 50 50-250 250Z'),
457
+ colbefore: G('M800-200v-560H560v560h240Zm-640 80v-160h80v80h240v-560H240v80h-80v-160h720v720H160Zm320-360Zm80 0h-80 80Zm0 0ZM160-360v-80H80v-80h80v-80h80v80h80v80h-80v80h-80Z'),
458
+ colafter: G('M160-760v560h240v-560H160ZM80-120v-720h720v160h-80v-80H480v560h240v-80h80v160H80Zm400-360Zm-80 0h80-80Zm0 0Zm320 120v-80h-80v-80h80v-80h80v80h80v80h-80v80h-80Z'),
459
+ coldelete: G('M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h560q33 0 56.5 23.5T840-760v560q0 33-23.5 56.5T760-120H200ZM200-200h560v-560H200ZM400-200v-560h160v560H400ZM330-580l250 250 50-50-250-250ZM330-380l250-250 50 50-250 250Z'),
460
+ tabledelete: S('<rect x="4" y="5" width="16" height="14" rx="1.5"/><path d="M4 10h16M4 14.5h16M9.5 5v14M14.5 5v14"/><path d="M6.5 6.5l11 11M17.5 6.5l-11 11" stroke-width="2.1"/>'),
461
+ quote: G('m228-240 92-160q-66 0-113-47t-47-113q0-66 47-113t113-47q66 0 113 47t47 113q0 23-5.5 42.5T458-480L320-240h-92Zm360 0 92-160q-66 0-113-47t-47-113q0-66 47-113t113-47q66 0 113 47t47 113q0 23-5.5 42.5T818-480L680-240h-92ZM362.5-517.5Q380-535 380-560t-17.5-42.5Q345-620 320-620t-42.5 17.5Q260-585 260-560t17.5 42.5Q295-500 320-500t42.5-17.5Zm360 0Q740-535 740-560t-17.5-42.5Q705-620 680-620t-42.5 17.5Q620-585 620-560t17.5 42.5Q655-500 680-500t42.5-17.5ZM680-560Zm-360 0Z'),
462
+ link: G('M440-280H280q-83 0-141.5-58.5T80-480q0-83 58.5-141.5T280-680h160v80H280q-50 0-85 35t-35 85q0 50 35 85t85 35h160v80ZM320-440v-80h320v80H320Zm200 160v-80h160q50 0 85-35t35-85q0-50-35-85t-85-35H520v-80h160q83 0 141.5 58.5T880-480q0 83-58.5 141.5T680-280H520Z'),
463
+ unlink: S('<path d="M13.5 6.5l1.7-1.7a4 4 0 0 1 5.6 5.6L19 12.2"/><path d="M10.5 17.5l-1.7 1.7a4 4 0 0 1-5.6-5.6L5 11.8"/><path d="M4 4l16 16"/>'),
464
+ lineheight: G('M240-160 80-320l56-56 64 62v-332l-64 62-56-56 160-160 160 160-56 56-64-62v332l64-62 56 56-160 160Zm240-40v-80h400v80H480Zm0-240v-80h400v80H480Zm0-240v-80h400v80H480Z'),
465
+ wordspacing: G('M320-80 160-240l160-160 57 56-64 64h334l-63-64 56-56 160 160L640-80l-57-56 64-64H313l63 64-56 56ZM200-480v-400h80v400h-80Zm240 0v-400h80v400h-80Zm240 0v-400h80v400h-80Z'),
466
+ caseic: G('m131-252 165-440h79l165 440h-76l-39-112H247l-40 112h-76Zm139-176h131l-64-182h-4l-63 182Zm395 186q-51 0-81-27.5T554-342q0-44 34.5-72.5T677-443q23 0 45 4t38 11v-12q0-29-20.5-47T685-505q-23 0-42 9.5T610-468l-47-35q24-29 54.5-43t68.5-14q69 0 103 32.5t34 97.5v178h-63v-37h-4q-14 23-38 35t-53 12Zm12-54q35 0 59.5-24t24.5-56q-14-8-33.5-12.5T689-393q-32 0-50 14t-18 37q0 20 16 33t40 13Z'),
467
+ uppercaseic: G('M660-240v-248l-64 64-56-56 160-160 160 160-56 56-64-64v248h-80Zm-540 0 165-440h79l165 440h-76l-39-113H236l-40 113h-76Zm139-177h131l-65-182h-4l-62 182Z'),
468
+ lowercaseic: G('M300-240q-51 0-81-27.5T189-340q0-44 34.5-72.5T312-441q23 0 45 4t38 11v-12q0-29-20.5-47T320-503q-23 0-42 9.5T245-466l-47-35q24-29 54.5-43t68.5-14q69 0 103 32.5t34 97.5v178h-63v-37h-4q-14 23-38 35t-53 12Zm12-54q35 0 59.5-24t24.5-56q-14-8-33.5-12.5T324-391q-32 0-50 14t-18 37q0 20 16 33t40 13Zm388 54L540-400l56-56 64 64v-248h80v248l64-64 56 56-160 160Z'),
469
+ capitalizeic: G('M344-250v-366H224v-64h308v64H412v366h-68Zm344 10q-44 0-69-25.5T594-336v-162h-54v-58h54v-87h66v87h74v58h-74v148q0 23 10.5 36t28.5 13q9 0 18.5-3.5T736-314v65q-10 5-22 7t-26 2Z'),
470
+ plus: S('<path d="M12 5.5v13M5.5 12h13"/>'),
471
+ minus: S('<path d="M5.5 12h13"/>'),
472
+ fontsizeup: G('m40-200 210-560h100l210 560h-96l-51-143H187l-51 143H40Zm176-224h168l-82-232h-4l-82 232Zm504 104v-120H600v-80h120v-120h80v120h120v80H800v120h-80Z'),
473
+ fontsizedown: G('m40-200 210-560h100l210 560h-96l-51-143H187l-51 143H40Zm176-224h168l-82-232h-4l-82 232Zm384-16v-80h320v80H600Z'),
474
+ historyic: G('M480-120q-138 0-240.5-91.5T122-440h82q14 104 92.5 172T480-200q117 0 198.5-81.5T760-480q0-117-81.5-198.5T480-760q-69 0-129 32t-101 88h110v80H120v-240h80v94q51-64 124.5-99T480-840q75 0 140.5 28.5t114 77q48.5 48.5 77 114T840-480q0 75-28.5 140.5t-77 114q-48.5 48.5-114 77T480-120Zm112-192L440-464v-216h80v184l128 128-56 56Z'),
475
+ wordic: S('<path d="M6 3h8l5 5v13H6z"/><path d="M14 3v5h5"/><text x="8" y="17" font-size="8.5" stroke="none" fill="#222f3e" font-family="Arial" font-weight="bold">W</text>'),
476
+ draftic: G('M480-160q-134 0-227-93t-93-227q0-134 93-227t227-93q69 0 132 28.5T720-690v-110h80v280H520v-80h168q-32-56-87.5-88T480-720q-100 0-170 70t-70 170q0 100 70 170t170 70q77 0 139-44t87-116h84q-28 106-114 173t-196 67Z'),
477
+ trash: S('<path d="M5 7h14M10 7V5h4v2M7.5 7l1 13h7l1-13"/>'),
478
+ rotateleft: S('<polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/>'),
479
+ rotateright: S('<polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>'),
480
+ fliphorizontal: S('<path d="M12 3v18"/><path d="M7 8l-4 4 4 4"/><path d="M17 8l4 4-4 4"/>'),
481
+ flipvertical: S('<path d="M3 12h18"/><path d="M8 7l4-4 4 4"/><path d="M8 17l4 4 4-4"/>'),
482
+ check: S('<path d="M5 12.5 9.5 17 19 7"/>'),
483
+ palette: S('<path d="M12 3a9 9 0 1 0 0 18h1.2a2 2 0 0 0 2-2 2 2 0 0 1 2-2H18a3 3 0 0 0 3-3c0-6.1-4.5-11-9-11z"/><circle class="f" cx="7.8" cy="10.2" r="1.3"/><circle class="f" cx="11.6" cy="7.3" r="1.3"/><circle class="f" cx="15.8" cy="9.6" r="1.3"/><circle class="f" cx="9" cy="14.8" r="1.3"/>'),
484
+ ul_circle: G('M280-600v-80h560v80H280Zm0 160v-80h560v80H280Zm0 160v-80h560v80H280ZM160-600q-17 0-28.5-11.5T120-640q0-17 11.5-28.5T160-680q17 0 28.5 11.5T200-640q0 17-11.5 28.5T160-600Zm0 160q-17 0-28.5-11.5T120-480q0-17 11.5-28.5T160-520q17 0 28.5 11.5T200-480q0 17-11.5 28.5T160-440Zm0 160q-17 0-28.5-11.5T120-320q0-17 11.5-28.5T160-360q17 0 28.5 11.5T200-320q0 17-11.5 28.5T160-280Z'),
485
+ ul_square: G('M80-160v-160h160v160H80Zm240 0v-160h560v160H320ZM80-400v-160h160v160H80Zm240 0v-160h560v160H320ZM80-640v-160h160v160H80Zm240 0v-160h560v160H320Z'),
486
+ ol_alpha: LIST(['a', 'b', 'c']),
487
+ ol_roman: LIST(['i', 'ii', 'iii'], 6.5),
488
+ ol_alphau: LIST(['A', 'B', 'C']),
489
+ ol_romanu: LIST(['I', 'II', 'III'], 6.5)
490
+ };
491
+ const TXT = (html) => `<span class="txt">${html}</span>`;
492
+
493
+ const ALLOWED_TAGS = new Set([
494
+ 'p',
495
+ 'div',
496
+ 'span',
497
+ 'br',
498
+ 'hr',
499
+ 'a',
500
+ 'b',
501
+ 'strong',
502
+ 'i',
503
+ 'em',
504
+ 'u',
505
+ 's',
506
+ 'strike',
507
+ 'sub',
508
+ 'sup',
509
+ 'h1',
510
+ 'h2',
511
+ 'h3',
512
+ 'h4',
513
+ 'h5',
514
+ 'h6',
515
+ 'blockquote',
516
+ 'pre',
517
+ 'code',
518
+ 'ul',
519
+ 'ol',
520
+ 'li',
521
+ 'table',
522
+ 'thead',
523
+ 'tbody',
524
+ 'tfoot',
525
+ 'tr',
526
+ 'td',
527
+ 'th',
528
+ 'caption',
529
+ 'colgroup',
530
+ 'col',
531
+ 'img',
532
+ 'figure',
533
+ 'figcaption',
534
+ 'mark'
535
+ ]);
536
+ const ALLOWED_STYLES = new Set([
537
+ 'text-align',
538
+ 'direction',
539
+ 'unicode-bidi',
540
+ 'font-weight',
541
+ 'font-style',
542
+ 'text-decoration',
543
+ 'text-decoration-line',
544
+ 'color',
545
+ 'background-color',
546
+ 'background',
547
+ 'background-image',
548
+ 'background-position',
549
+ 'background-repeat',
550
+ 'background-size',
551
+ 'font-size',
552
+ 'font-family',
553
+ 'line-height',
554
+ 'letter-spacing',
555
+ 'vertical-align',
556
+ 'text-indent',
557
+ 'margin',
558
+ 'margin-top',
559
+ 'margin-bottom',
560
+ 'margin-left',
561
+ 'margin-right',
562
+ 'margin-inline-start',
563
+ 'margin-inline-end',
564
+ 'padding',
565
+ 'padding-top',
566
+ 'padding-bottom',
567
+ 'padding-left',
568
+ 'padding-right',
569
+ 'width',
570
+ 'height',
571
+ 'min-width',
572
+ 'max-width',
573
+ 'min-height',
574
+ 'max-height',
575
+ 'border',
576
+ 'border-top',
577
+ 'border-right',
578
+ 'border-bottom',
579
+ 'border-left',
580
+ 'border-color',
581
+ 'border-style',
582
+ 'border-width',
583
+ 'border-radius',
584
+ 'border-collapse',
585
+ 'border-spacing',
586
+ 'table-layout',
587
+ 'float',
588
+ 'display',
589
+ 'box-sizing',
590
+ 'list-style-type',
591
+ 'white-space'
592
+ ]);
593
+ const ALLOWED_ATTRS = {
594
+ '*': ['dir', 'lang', 'colspan', 'rowspan'],
595
+ a: ['href', 'target', 'rel', 'title'],
596
+ img: ['src', 'alt', 'width', 'height', 'title'],
597
+ col: ['span', 'width'],
598
+ td: ['width', 'valign'],
599
+ th: ['width', 'valign'],
600
+ table: ['width', 'cellpadding', 'cellspacing']
601
+ };
602
+ function styleMap(el) {
603
+ const map = {};
604
+ const raw = el.getAttribute && el.getAttribute('style');
605
+ if (!raw)
606
+ return map;
607
+ raw.split(';').forEach((p) => {
608
+ const i = p.indexOf(':');
609
+ if (i < 1)
610
+ return;
611
+ map[p.slice(0, i).trim().toLowerCase()] = p.slice(i + 1).trim();
612
+ });
613
+ return map;
614
+ }
615
+ function preClean(html) {
616
+ return html
617
+ .replace(/<!--\[if !supportLists\]-->([\s\S]*?)<!--\[endif\]-->/gi, '$1')
618
+ .replace(/<!--\[if !supportLists\]>([\s\S]*?)<!\[endif\]-->/gi, '$1')
619
+ .replace(/<!\[if !supportLists\]>([\s\S]*?)<!\[endif\]>/gi, '$1')
620
+ .replace(/<!--\[if [\s\S]*?<!\[endif\]-->/gi, '')
621
+ .replace(/<!--[\s\S]*?-->/g, '')
622
+ .replace(/<(script|style|xml|title)[\s\S]*?<\/\1>/gi, '')
623
+ .replace(/<\/?(meta|link)[^>]*>/gi, '')
624
+ .replace(/<\?xml[\s\S]*?\?>/gi, '')
625
+ .replace(/<\/?(o|v|w|m|st1):[^>]*>/gi, '');
626
+ }
627
+ function markerType(mt) {
628
+ if (/^[0-9]+[\.\)،]/.test(mt))
629
+ return { ordered: true };
630
+ if (/^[٠-٩۰-۹]+/.test(mt))
631
+ return { ordered: true, style: 'arabic-indic' };
632
+ if (/^[ivxlc]{2,}[\.\)]/.test(mt))
633
+ return { ordered: true, style: 'lower-roman' };
634
+ if (/^[IVXLC]{2,}[\.\)]/.test(mt))
635
+ return { ordered: true, style: 'upper-roman' };
636
+ if (/^[a-z][\.\)]/.test(mt))
637
+ return { ordered: true, style: 'lower-alpha' };
638
+ if (/^[A-Z][\.\)]/.test(mt))
639
+ return { ordered: true, style: 'upper-alpha' };
640
+ if (/^[ء-ي][\.\)،-]/.test(mt))
641
+ return { ordered: true, style: 'arabic-indic' };
642
+ return { ordered: false };
643
+ }
644
+ function rebuildWordLists(doc) {
645
+ const isListP = (p) => {
646
+ const s = (p.getAttribute('style') || '') + ' ' + (p.className || '');
647
+ return /mso-list\s*:(?!\s*ignore)/i.test(s) || /MsoListParagraph/i.test(s);
648
+ };
649
+ const groups = [];
650
+ let cur = null;
651
+ Array.from(doc.body.querySelectorAll('p')).forEach((p) => {
652
+ if (!isListP(p)) {
653
+ cur = null;
654
+ return;
655
+ }
656
+ const prev = p.previousElementSibling;
657
+ if (cur && prev && cur[cur.length - 1] === prev)
658
+ cur.push(p);
659
+ else {
660
+ cur = [p];
661
+ groups.push(cur);
662
+ }
663
+ });
664
+ groups.forEach((group) => {
665
+ const stack = [];
666
+ const rootAnchor = group[0];
667
+ group.forEach((p) => {
668
+ const style = p.getAttribute('style') || '';
669
+ const level = +(style.match(/level(\d+)/i)?.[1] || 1);
670
+ const marker = Array.from(p.querySelectorAll('span')).find((sp) => /mso-list\s*:\s*ignore/i.test(sp.getAttribute('style') || ''));
671
+ const mt = marker
672
+ ? marker.textContent?.trim() || ''
673
+ : (p.textContent?.trim().match(/^\S{1,4}/) || [''])[0];
674
+ const { ordered, style: listStyle } = markerType(mt);
675
+ if (marker)
676
+ marker.remove();
677
+ while (stack.length && stack[stack.length - 1].level > level)
678
+ stack.pop();
679
+ if (!stack.length || stack[stack.length - 1].level < level) {
680
+ const list = doc.createElement(ordered ? 'ol' : 'ul');
681
+ if (ordered && listStyle)
682
+ list.style.listStyleType = listStyle;
683
+ if (stack.length) {
684
+ const par = stack[stack.length - 1].list;
685
+ (par.lastElementChild || par).appendChild(list);
686
+ }
687
+ else {
688
+ rootAnchor.parentNode.insertBefore(list, rootAnchor);
689
+ }
690
+ stack.push({ list, level });
691
+ }
692
+ const li = doc.createElement('li');
693
+ const keep = styleMap(p);
694
+ const kept = ['text-align', 'direction', 'unicode-bidi', 'color', 'font-size', 'font-family', 'background-color']
695
+ .filter((k) => keep[k])
696
+ .map((k) => k + ':' + keep[k])
697
+ .join(';');
698
+ if (kept)
699
+ li.setAttribute('style', kept);
700
+ if (p.getAttribute('dir'))
701
+ li.setAttribute('dir', p.getAttribute('dir'));
702
+ while (p.firstChild)
703
+ li.appendChild(p.firstChild);
704
+ stack[stack.length - 1].list.appendChild(li);
705
+ p.remove();
706
+ });
707
+ });
708
+ }
709
+ function fixGoogleDocs(doc) {
710
+ doc.querySelectorAll('b[id^="docs-internal-guid"]').forEach((b) => {
711
+ while (b.firstChild)
712
+ b.parentNode.insertBefore(b.firstChild, b);
713
+ b.remove();
714
+ });
715
+ }
716
+ function fixLegacyAttrs(doc) {
717
+ doc.querySelectorAll('table').forEach((tb) => {
718
+ const htmlTb = tb;
719
+ if (!styleMap(htmlTb)['border-collapse'])
720
+ htmlTb.style.borderCollapse = 'collapse';
721
+ });
722
+ doc.querySelectorAll('[align]').forEach((el) => {
723
+ const htmlEl = el;
724
+ const v = htmlEl.getAttribute('align').toLowerCase();
725
+ if (htmlEl.tagName.toLowerCase() === 'table') {
726
+ if (v === 'center') {
727
+ htmlEl.style.marginLeft = htmlEl.style.marginLeft || 'auto';
728
+ htmlEl.style.marginRight = htmlEl.style.marginRight || 'auto';
729
+ }
730
+ else if (v === 'right')
731
+ htmlEl.style.marginLeft = htmlEl.style.marginLeft || 'auto';
732
+ else if (v === 'left')
733
+ htmlEl.style.marginRight = htmlEl.style.marginRight || 'auto';
734
+ }
735
+ else if (['left', 'right', 'center', 'justify'].includes(v)) {
736
+ htmlEl.style.textAlign = htmlEl.style.textAlign || v;
737
+ // Word aligns tables by wrapping them in <div align=...>; text-align
738
+ // does not move a table box, so mirror the alignment onto the table.
739
+ Array.from(htmlEl.children).forEach((ch) => {
740
+ if (ch.tagName.toLowerCase() !== 'table')
741
+ return;
742
+ const tb = ch;
743
+ if (v === 'center') {
744
+ tb.style.marginLeft = tb.style.marginLeft || 'auto';
745
+ tb.style.marginRight = tb.style.marginRight || 'auto';
746
+ }
747
+ else if (v === 'right')
748
+ tb.style.marginLeft = tb.style.marginLeft || 'auto';
749
+ else if (v === 'left')
750
+ tb.style.marginRight = tb.style.marginRight || 'auto';
751
+ });
752
+ }
753
+ htmlEl.removeAttribute('align');
754
+ });
755
+ doc.querySelectorAll('center table').forEach((tb) => {
756
+ const htmlTb = tb;
757
+ htmlTb.style.marginLeft = htmlTb.style.marginLeft || 'auto';
758
+ htmlTb.style.marginRight = htmlTb.style.marginRight || 'auto';
759
+ });
760
+ doc.querySelectorAll('[bgcolor]').forEach((el) => {
761
+ const htmlEl = el;
762
+ htmlEl.style.backgroundColor = htmlEl.style.backgroundColor || htmlEl.getAttribute('bgcolor');
763
+ htmlEl.removeAttribute('bgcolor');
764
+ });
765
+ }
766
+ function sanitize(node, doc) {
767
+ Array.from(node.childNodes).forEach((child) => {
768
+ if (child.nodeType === Node.COMMENT_NODE) {
769
+ child.remove();
770
+ return;
771
+ }
772
+ if (child.nodeType !== Node.ELEMENT_NODE)
773
+ return;
774
+ const el = child;
775
+ const tag = el.tagName.toLowerCase();
776
+ if (!ALLOWED_TAGS.has(tag)) {
777
+ if (tag === 'font') {
778
+ const span = doc.createElement('span');
779
+ if (el.getAttribute('style'))
780
+ span.setAttribute('style', el.getAttribute('style'));
781
+ if (el.getAttribute('color'))
782
+ span.style.color = el.getAttribute('color');
783
+ if (el.getAttribute('face'))
784
+ span.style.fontFamily = el.getAttribute('face');
785
+ const sz = el.getAttribute('size');
786
+ if (sz && !span.style.fontSize) {
787
+ const szMap = {
788
+ '1': '8pt',
789
+ '2': '10pt',
790
+ '3': '12pt',
791
+ '4': '14pt',
792
+ '5': '18pt',
793
+ '6': '24pt',
794
+ '7': '36pt'
795
+ };
796
+ if (szMap[sz])
797
+ span.style.fontSize = szMap[sz];
798
+ }
799
+ while (el.firstChild)
800
+ span.appendChild(el.firstChild);
801
+ el.replaceWith(span);
802
+ sanitize(span, doc);
803
+ return;
804
+ }
805
+ sanitize(el, doc);
806
+ while (el.firstChild)
807
+ el.parentNode.insertBefore(el.firstChild, el);
808
+ el.remove();
809
+ return;
810
+ }
811
+ const allowed = new Set([...(ALLOWED_ATTRS['*'] || []), ...(ALLOWED_ATTRS[tag] || [])]);
812
+ Array.from(el.attributes).forEach((a) => {
813
+ const n = a.name.toLowerCase();
814
+ if (n !== 'style' && !allowed.has(n))
815
+ el.removeAttribute(a.name);
816
+ });
817
+ if (tag === 'a') {
818
+ const href = el.getAttribute('href') || '';
819
+ if (!/^(https?:|mailto:|tel:|#)/i.test(href))
820
+ el.removeAttribute('href');
821
+ }
822
+ if (tag === 'img') {
823
+ const src = el.getAttribute('src') || '';
824
+ if (!/^(https?:|data:image\/|blob:)/i.test(src)) {
825
+ const ph = doc.createElement('span');
826
+ ph.textContent = '[local image — paste it separately]';
827
+ ph.setAttribute('style', 'color:#a33;font-style:italic;font-size:12px');
828
+ el.replaceWith(ph);
829
+ return;
830
+ }
831
+ }
832
+ const styles = styleMap(el);
833
+ const kept = [];
834
+ for (const [k, v] of Object.entries(styles)) {
835
+ if (!ALLOWED_STYLES.has(k))
836
+ continue;
837
+ if (/expression|javascript|url\s*\(\s*['"]?\s*file:/i.test(v))
838
+ continue;
839
+ if (k === 'font-weight' && /^(400|normal)$/i.test(v))
840
+ continue;
841
+ if (k === 'font-style' && /^normal$/i.test(v))
842
+ continue;
843
+ if (k === 'text-decoration' && /^none$/i.test(v))
844
+ continue;
845
+ kept.push(k + ':' + v);
846
+ }
847
+ if (kept.length)
848
+ el.setAttribute('style', kept.join(';'));
849
+ else
850
+ el.removeAttribute('style');
851
+ el.removeAttribute('class');
852
+ sanitize(el, doc);
853
+ if (tag === 'span' && !el.attributes.length) {
854
+ while (el.firstChild)
855
+ el.parentNode.insertBefore(el.firstChild, el);
856
+ el.remove();
857
+ }
858
+ });
859
+ }
860
+ function extractStyleRules(raw) {
861
+ const rules = [];
862
+ const re = /<style[^>]*>([\s\S]*?)<\/style>/gi;
863
+ let m;
864
+ while ((m = re.exec(raw))) {
865
+ let css = m[1].replace(/<!--|-->/g, '').replace(/\/\*[\s\S]*?\*\//g, '');
866
+ css = css.replace(/@media[^{]*\{(?:[^{}]*\{[^{}]*\})*[^{}]*\}/gi, '');
867
+ css = css.replace(/@[\w-]+[^{]*\{[^{}]*\}/g, '');
868
+ let rm;
869
+ const rre = /([^{}]+)\{([^}]*)\}/g;
870
+ while ((rm = rre.exec(css))) {
871
+ const decls = rm[2]
872
+ .split(';')
873
+ .map((d) => {
874
+ const i = d.indexOf(':');
875
+ if (i < 1)
876
+ return null;
877
+ return [d.slice(0, i).trim().toLowerCase(), d.slice(i + 1).trim()];
878
+ })
879
+ .filter((d) => !!d && !!d[1]);
880
+ if (!decls.length)
881
+ continue;
882
+ rm[1]
883
+ .split(',')
884
+ .map((s) => s.trim())
885
+ .filter((s) => s && !s.startsWith('@'))
886
+ .forEach((sel) => rules.push({ sel, decls }));
887
+ }
888
+ }
889
+ return rules;
890
+ }
891
+ function applyStyleRules(doc, rules) {
892
+ if (!rules.length)
893
+ return;
894
+ const addMap = new Map();
895
+ rules.forEach(({ sel, decls }) => {
896
+ let els;
897
+ try {
898
+ els = doc.querySelectorAll(sel);
899
+ }
900
+ catch (e) {
901
+ return;
902
+ }
903
+ els.forEach((el) => {
904
+ let m = addMap.get(el);
905
+ if (!m) {
906
+ m = {};
907
+ addMap.set(el, m);
908
+ }
909
+ decls.forEach(([k, v]) => {
910
+ m[k] = v;
911
+ });
912
+ });
913
+ });
914
+ addMap.forEach((m, el) => {
915
+ const have = styleMap(el);
916
+ const hasDir = el.hasAttribute('dir');
917
+ const hasAlign = el.hasAttribute('align');
918
+ const add = Object.entries(m).filter(([k]) => {
919
+ if (k in have)
920
+ return false;
921
+ if ((k === 'direction' || k === 'unicode-bidi') && hasDir)
922
+ return false;
923
+ if (k === 'text-align' && (hasAlign || hasDir))
924
+ return false;
925
+ if ((k === 'background' || k === 'background-color') && el.hasAttribute('bgcolor'))
926
+ return false;
927
+ return true;
928
+ });
929
+ if (!add.length)
930
+ return;
931
+ const prefix = add.map(([k, v]) => k + ':' + v).join(';');
932
+ const cur = el.getAttribute('style');
933
+ el.setAttribute('style', cur ? prefix + ';' + cur : prefix);
934
+ });
935
+ }
936
+ function normalizeArabicPresentation(s) {
937
+ return s.replace(/[ﭐ-﷿ﹰ-ﻼ]+/g, (m) => m.normalize('NFKC'));
938
+ }
939
+ function normalizeArabicText(doc) {
940
+ const w = doc.createTreeWalker(doc.body, NodeFilter.SHOW_TEXT, null);
941
+ let n;
942
+ while ((n = w.nextNode()))
943
+ if (/[ﭐ-﷿ﹰ-ﻼ]/.test(n.nodeValue || ''))
944
+ n.nodeValue = normalizeArabicPresentation(n.nodeValue || '');
945
+ }
946
+ function cleanPastedHTML(raw, editorDir = 'ltr') {
947
+ const cssRules = extractStyleRules(raw);
948
+ const doc = new DOMParser().parseFromString(preClean(raw), 'text/html');
949
+ applyStyleRules(doc, cssRules);
950
+ if (/class="?Mso|mso-|urn:schemas-microsoft-com/i.test(doc.body.innerHTML))
951
+ rebuildWordLists(doc);
952
+ fixGoogleDocs(doc);
953
+ fixLegacyAttrs(doc);
954
+ sanitize(doc.body, doc);
955
+ normalizeArabicText(doc);
956
+ const emptyP = (el) => el && el.tagName === 'P' && !el.textContent?.trim() && !el.querySelector('img,table');
957
+ while (emptyP(doc.body.firstElementChild))
958
+ doc.body.firstElementChild.remove();
959
+ while (emptyP(doc.body.lastElementChild))
960
+ doc.body.lastElementChild.remove();
961
+ return doc.body.innerHTML.trim();
962
+ }
963
+ function normalizeTextPaste(raw) {
964
+ const txt = normalizeArabicPresentation(raw).replace(/\r/g, '');
965
+ const esc = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
966
+ if (/\n/.test(txt)) {
967
+ return txt
968
+ .split(/\n{2,}/)
969
+ .map((par) => `<p dir="auto">${esc(par).replace(/\n/g, '<br>') || '<br>'}</p>`)
970
+ .join('');
971
+ }
972
+ return esc(txt);
973
+ }
974
+
975
+ /* Minimal .docx → HTML converter with no dependencies.
976
+ Parses the ZIP container manually and inflates entries with the browser's
977
+ DecompressionStream, then maps WordprocessingML to editor-friendly HTML:
978
+ headings, run formatting, colors, hyperlinks, lists, tables and images. */
979
+ function parseZip(buf) {
980
+ const d = new DataView(buf);
981
+ const entries = new Map();
982
+ // find End Of Central Directory record (sig 0x06054b50), scanning back over the comment
983
+ let eocd = -1;
984
+ for (let i = buf.byteLength - 22; i >= Math.max(0, buf.byteLength - 22 - 0xffff); i--) {
985
+ if (d.getUint32(i, true) === 0x06054b50) {
986
+ eocd = i;
987
+ break;
988
+ }
989
+ }
990
+ if (eocd < 0)
991
+ throw new Error('EOCD not found');
992
+ const count = d.getUint16(eocd + 10, true);
993
+ let p = d.getUint32(eocd + 16, true);
994
+ const utf8 = new TextDecoder();
995
+ for (let i = 0; i < count; i++) {
996
+ if (d.getUint32(p, true) !== 0x02014b50)
997
+ break;
998
+ const method = d.getUint16(p + 10, true);
999
+ const compSize = d.getUint32(p + 20, true);
1000
+ const nameLen = d.getUint16(p + 28, true);
1001
+ const extraLen = d.getUint16(p + 30, true);
1002
+ const commentLen = d.getUint16(p + 32, true);
1003
+ const localOffset = d.getUint32(p + 42, true);
1004
+ const name = utf8.decode(new Uint8Array(buf, p + 46, nameLen));
1005
+ entries.set(name, { name, method, compSize, localOffset });
1006
+ p += 46 + nameLen + extraLen + commentLen;
1007
+ }
1008
+ return entries;
1009
+ }
1010
+ async function readEntry(buf, e) {
1011
+ const d = new DataView(buf, e.localOffset);
1012
+ if (d.getUint32(0, true) !== 0x04034b50)
1013
+ throw new Error('Bad local header');
1014
+ const nameLen = d.getUint16(26, true);
1015
+ const extraLen = d.getUint16(28, true);
1016
+ const start = e.localOffset + 30 + nameLen + extraLen;
1017
+ const comp = new Uint8Array(buf.slice(start, start + e.compSize));
1018
+ if (e.method === 0)
1019
+ return comp;
1020
+ if (e.method !== 8)
1021
+ throw new Error('Unsupported compression method ' + e.method);
1022
+ const stream = new Blob([comp]).stream().pipeThrough(new DecompressionStream('deflate-raw'));
1023
+ return new Uint8Array(await new Response(stream).arrayBuffer());
1024
+ }
1025
+ /* ------------------------------------------------------------- helpers */
1026
+ const esc = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
1027
+ const kids = (el, name) => Array.from(el.children).filter((c) => c.localName === name);
1028
+ const kid = (el, ...path) => {
1029
+ let cur = el;
1030
+ for (const name of path) {
1031
+ if (!cur)
1032
+ return null;
1033
+ cur = Array.from(cur.children).find((c) => c.localName === name) || null;
1034
+ }
1035
+ return cur;
1036
+ };
1037
+ const val = (el, attrName = 'val') => {
1038
+ if (!el)
1039
+ return '';
1040
+ for (const a of Array.from(el.attributes)) {
1041
+ if (a.localName === attrName)
1042
+ return a.value;
1043
+ }
1044
+ return '';
1045
+ };
1046
+ /** A toggle property like <w:b/> is on unless val is explicitly false/0. */
1047
+ const flagOn = (rPr, name) => {
1048
+ const el = rPr ? kid(rPr, name) : null;
1049
+ if (!el)
1050
+ return false;
1051
+ const v = val(el);
1052
+ return v !== 'false' && v !== '0';
1053
+ };
1054
+ const HIGHLIGHTS = {
1055
+ yellow: '#FFFF00',
1056
+ green: '#00FF00',
1057
+ cyan: '#00FFFF',
1058
+ magenta: '#FF00FF',
1059
+ blue: '#0000FF',
1060
+ red: '#FF0000',
1061
+ darkBlue: '#00008B',
1062
+ darkCyan: '#008B8B',
1063
+ darkGreen: '#006400',
1064
+ darkMagenta: '#8B008B',
1065
+ darkRed: '#8B0000',
1066
+ darkYellow: '#808000',
1067
+ darkGray: '#A9A9A9',
1068
+ lightGray: '#D3D3D3',
1069
+ black: '#000000',
1070
+ white: '#FFFFFF'
1071
+ };
1072
+ const IMG_MIME = {
1073
+ png: 'image/png',
1074
+ jpg: 'image/jpeg',
1075
+ jpeg: 'image/jpeg',
1076
+ gif: 'image/gif',
1077
+ bmp: 'image/bmp',
1078
+ webp: 'image/webp'
1079
+ };
1080
+ function toBase64(bytes) {
1081
+ let bin = '';
1082
+ for (let i = 0; i < bytes.length; i += 0x8000) {
1083
+ bin += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
1084
+ }
1085
+ return btoa(bin);
1086
+ }
1087
+ /* ------------------------------------------------------------- converter */
1088
+ class DocxConverter {
1089
+ rels;
1090
+ images;
1091
+ numFmt;
1092
+ constructor(rels, images, numFmt) {
1093
+ this.rels = rels;
1094
+ this.images = images;
1095
+ this.numFmt = numFmt;
1096
+ }
1097
+ convertBody(body) {
1098
+ let html = '';
1099
+ // stack of open lists for nested numbering
1100
+ let listStack = [];
1101
+ const closeLists = (depth) => {
1102
+ while (listStack.length > depth)
1103
+ html += `</${listStack.pop()}>`;
1104
+ };
1105
+ for (const child of Array.from(body.children)) {
1106
+ if (child.localName === 'p') {
1107
+ const list = this.paraList(child);
1108
+ if (list) {
1109
+ const depth = list.ilvl + 1;
1110
+ while (listStack.length > depth)
1111
+ html += `</${listStack.pop()}>`;
1112
+ if (listStack.length === depth && listStack[depth - 1] !== list.type) {
1113
+ html += `</${listStack.pop()}>`;
1114
+ }
1115
+ while (listStack.length < depth) {
1116
+ html += `<${list.type}>`;
1117
+ listStack.push(list.type);
1118
+ }
1119
+ html += `<li>${this.runsOf(child) || '<br>'}</li>`;
1120
+ continue;
1121
+ }
1122
+ closeLists(0);
1123
+ html += this.convertPara(child);
1124
+ }
1125
+ else if (child.localName === 'tbl') {
1126
+ closeLists(0);
1127
+ html += this.convertTable(child);
1128
+ }
1129
+ else if (child.localName === 'sectPr') {
1130
+ // section properties — no visual content
1131
+ }
1132
+ }
1133
+ closeLists(0);
1134
+ return html || '<p><br></p>';
1135
+ }
1136
+ paraList(p) {
1137
+ const numPr = kid(p, 'pPr', 'numPr');
1138
+ if (!numPr)
1139
+ return null;
1140
+ const numId = val(kid(numPr, 'numId'));
1141
+ if (!numId || numId === '0')
1142
+ return null;
1143
+ const ilvl = parseInt(val(kid(numPr, 'ilvl')) || '0', 10) || 0;
1144
+ return { type: this.numFmt.get(numId) || 'ul', ilvl: Math.min(ilvl, 8) };
1145
+ }
1146
+ convertPara(p) {
1147
+ const pPr = kid(p, 'pPr');
1148
+ const style = val(kid(p, 'pPr', 'pStyle'));
1149
+ let tag = 'p';
1150
+ const m = /^Heading([1-6])$/i.exec(style);
1151
+ if (m)
1152
+ tag = 'h' + m[1];
1153
+ else if (/^Title$/i.test(style))
1154
+ tag = 'h1';
1155
+ const css = [];
1156
+ const jc = val(kid(p, 'pPr', 'jc'));
1157
+ if (jc === 'center')
1158
+ css.push('text-align:center');
1159
+ else if (jc === 'right' || jc === 'end')
1160
+ css.push('text-align:right');
1161
+ else if (jc === 'both' || jc === 'distribute')
1162
+ css.push('text-align:justify');
1163
+ if (pPr && kid(pPr, 'bidi'))
1164
+ css.push('direction:rtl');
1165
+ const inner = this.runsOf(p);
1166
+ const attrs = css.length ? ` style="${css.join(';')}"` : '';
1167
+ return `<${tag}${attrs}>${inner || '<br>'}</${tag}>`;
1168
+ }
1169
+ runsOf(el) {
1170
+ let html = '';
1171
+ for (const child of Array.from(el.children)) {
1172
+ if (child.localName === 'r')
1173
+ html += this.convertRun(child);
1174
+ else if (child.localName === 'hyperlink') {
1175
+ let href = this.rels.get(val(child, 'id')) || '';
1176
+ const anchor = val(child, 'anchor');
1177
+ if (!href && anchor)
1178
+ href = '#' + anchor;
1179
+ const inner = this.runsOf(child);
1180
+ html += href ? `<a href="${esc(href)}">${inner}</a>` : inner;
1181
+ }
1182
+ else if (child.localName === 'smartTag' || child.localName === 'ins') {
1183
+ html += this.runsOf(child);
1184
+ }
1185
+ }
1186
+ return html;
1187
+ }
1188
+ convertRun(r) {
1189
+ const rPr = kid(r, 'rPr');
1190
+ let text = '';
1191
+ for (const child of Array.from(r.children)) {
1192
+ if (child.localName === 't')
1193
+ text += esc(child.textContent || '');
1194
+ else if (child.localName === 'br' || child.localName === 'cr')
1195
+ text += '<br>';
1196
+ else if (child.localName === 'tab')
1197
+ text += '&emsp;';
1198
+ else if (child.localName === 'drawing' || child.localName === 'pict' || child.localName === 'object') {
1199
+ text += this.convertImage(child);
1200
+ }
1201
+ }
1202
+ if (!text)
1203
+ return '';
1204
+ const css = [];
1205
+ const color = val(rPr ? kid(rPr, 'color') : null);
1206
+ if (color && color !== 'auto')
1207
+ css.push('color:#' + color);
1208
+ const highlight = val(rPr ? kid(rPr, 'highlight') : null);
1209
+ if (highlight && HIGHLIGHTS[highlight])
1210
+ css.push('background-color:' + HIGHLIGHTS[highlight]);
1211
+ const sz = parseFloat(val(rPr ? kid(rPr, 'sz') : null));
1212
+ if (sz)
1213
+ css.push('font-size:' + Math.round((sz / 2) * (4 / 3)) + 'px');
1214
+ const font = rPr ? kid(rPr, 'rFonts') : null;
1215
+ const fontName = val(font, 'ascii') || val(font, 'cs');
1216
+ if (fontName)
1217
+ css.push(`font-family:'${fontName}'`);
1218
+ if (css.length)
1219
+ text = `<span style="${css.join(';')}">${text}</span>`;
1220
+ if (flagOn(rPr, 'b'))
1221
+ text = `<strong>${text}</strong>`;
1222
+ if (flagOn(rPr, 'i'))
1223
+ text = `<em>${text}</em>`;
1224
+ if (flagOn(rPr, 'u') && val(kid(rPr, 'u')) !== 'none')
1225
+ text = `<u>${text}</u>`;
1226
+ if (flagOn(rPr, 'strike'))
1227
+ text = `<s>${text}</s>`;
1228
+ const vert = val(rPr ? kid(rPr, 'vertAlign') : null);
1229
+ if (vert === 'superscript')
1230
+ text = `<sup>${text}</sup>`;
1231
+ else if (vert === 'subscript')
1232
+ text = `<sub>${text}</sub>`;
1233
+ return text;
1234
+ }
1235
+ convertImage(holder) {
1236
+ // find any blip (DrawingML) or imagedata (VML) descendant regardless of depth
1237
+ let relId = '';
1238
+ let widthPx = 0;
1239
+ const walk = (el) => {
1240
+ if (el.localName === 'blip')
1241
+ relId = relId || val(el, 'embed') || val(el, 'link');
1242
+ if (el.localName === 'imagedata')
1243
+ relId = relId || val(el, 'id');
1244
+ if (el.localName === 'extent') {
1245
+ const cx = parseFloat(el.getAttribute('cx') || '0');
1246
+ if (cx)
1247
+ widthPx = Math.round(cx / 9525); // EMU → px
1248
+ }
1249
+ for (const c of Array.from(el.children))
1250
+ walk(c);
1251
+ };
1252
+ walk(holder);
1253
+ if (!relId)
1254
+ return '';
1255
+ const src = this.images.get(relId);
1256
+ if (!src)
1257
+ return '';
1258
+ const w = widthPx ? ` width="${widthPx}"` : '';
1259
+ return `<img src="${src}" alt=""${w}>`;
1260
+ }
1261
+ convertTable(tbl) {
1262
+ let html = '<table style="border-collapse:collapse;width:100%"><tbody>';
1263
+ for (const tr of kids(tbl, 'tr')) {
1264
+ html += '<tr>';
1265
+ for (const tc of kids(tr, 'tc')) {
1266
+ const span = parseInt(val(kid(tc, 'tcPr', 'gridSpan')) || '1', 10) || 1;
1267
+ const spanAttr = span > 1 ? ` colspan="${span}"` : '';
1268
+ let inner = '';
1269
+ for (const child of Array.from(tc.children)) {
1270
+ if (child.localName === 'p')
1271
+ inner += this.convertPara(child);
1272
+ else if (child.localName === 'tbl')
1273
+ inner += this.convertTable(child);
1274
+ }
1275
+ html += `<td style="border:1px solid #b9c2cc"${spanAttr}>${inner || '<br>'}</td>`;
1276
+ }
1277
+ html += '</tr>';
1278
+ }
1279
+ return html + '</tbody></table>';
1280
+ }
1281
+ }
1282
+ /* ------------------------------------------------------------- entry point */
1283
+ async function importDocxToHtml(input) {
1284
+ const buf = input instanceof ArrayBuffer ? input : await input.arrayBuffer();
1285
+ const entries = parseZip(buf);
1286
+ const decoder = new TextDecoder();
1287
+ const readXml = async (name) => {
1288
+ const e = entries.get(name);
1289
+ if (!e)
1290
+ return null;
1291
+ return new DOMParser().parseFromString(decoder.decode(await readEntry(buf, e)), 'application/xml');
1292
+ };
1293
+ const doc = await readXml('word/document.xml');
1294
+ const body = doc?.getElementsByTagName('*');
1295
+ let bodyEl = null;
1296
+ if (body) {
1297
+ for (const el of Array.from(body)) {
1298
+ if (el.localName === 'body') {
1299
+ bodyEl = el;
1300
+ break;
1301
+ }
1302
+ }
1303
+ }
1304
+ if (!bodyEl)
1305
+ throw new Error('word/document.xml missing or invalid');
1306
+ // relationships: id → target (hyperlinks are absolute, images relative to word/)
1307
+ const rels = new Map();
1308
+ const imageTargets = new Map();
1309
+ const relsDoc = await readXml('word/_rels/document.xml.rels');
1310
+ if (relsDoc) {
1311
+ for (const rel of Array.from(relsDoc.getElementsByTagName('*'))) {
1312
+ if (rel.localName !== 'Relationship')
1313
+ continue;
1314
+ const id = rel.getAttribute('Id') || '';
1315
+ const target = rel.getAttribute('Target') || '';
1316
+ const type = rel.getAttribute('Type') || '';
1317
+ if (/hyperlink/i.test(type))
1318
+ rels.set(id, target);
1319
+ else if (/image/i.test(type))
1320
+ imageTargets.set(id, target);
1321
+ }
1322
+ }
1323
+ // load referenced images as data URLs
1324
+ const images = new Map();
1325
+ for (const [id, target] of imageTargets) {
1326
+ const path = target.startsWith('/') ? target.slice(1) : 'word/' + target.replace(/^\.\//, '');
1327
+ const entry = entries.get(path);
1328
+ if (!entry)
1329
+ continue;
1330
+ const ext = (path.split('.').pop() || '').toLowerCase();
1331
+ const mime = IMG_MIME[ext];
1332
+ if (!mime)
1333
+ continue; // skip unsupported formats like emf/wmf
1334
+ try {
1335
+ images.set(id, `data:${mime};base64,${toBase64(await readEntry(buf, entry))}`);
1336
+ }
1337
+ catch {
1338
+ /* skip broken image */
1339
+ }
1340
+ }
1341
+ // numbering: numId → ul/ol (from level-0 numFmt of the abstract definition)
1342
+ const numFmt = new Map();
1343
+ const numDoc = await readXml('word/numbering.xml');
1344
+ if (numDoc) {
1345
+ const abstractFmt = new Map();
1346
+ const all = Array.from(numDoc.getElementsByTagName('*'));
1347
+ for (const el of all) {
1348
+ if (el.localName !== 'abstractNum')
1349
+ continue;
1350
+ const id = val(el, 'abstractNumId');
1351
+ const lvl0 = kids(el, 'lvl').find((l) => val(l, 'ilvl') === '0') || kids(el, 'lvl')[0];
1352
+ const fmt = lvl0 ? val(kid(lvl0, 'numFmt')) : '';
1353
+ abstractFmt.set(id, fmt === 'bullet' ? 'ul' : 'ol');
1354
+ }
1355
+ for (const el of all) {
1356
+ if (el.localName !== 'num')
1357
+ continue;
1358
+ const numId = val(el, 'numId');
1359
+ const absId = val(kid(el, 'abstractNumId'));
1360
+ numFmt.set(numId, abstractFmt.get(absId) || 'ul');
1361
+ }
1362
+ }
1363
+ return new DocxConverter(rels, images, numFmt).convertBody(bodyEl);
1364
+ }
1365
+
1366
+ const IMG_CORNERS = ['nw', 'ne', 'sw', 'se'];
1367
+ class FableEditor {
1368
+ static instanceCounter = 0;
1369
+ options;
1370
+ lang = 'en';
1371
+ contentClass = 'fable-content-' + FableEditor.instanceCounter++;
1372
+ contentStyleEl = null;
1373
+ shell;
1374
+ menubar;
1375
+ toolbar;
1376
+ ed;
1377
+ statusbar;
1378
+ sbHelp;
1379
+ sbWords;
1380
+ imgInput;
1381
+ savedRange = null;
1382
+ foreColor = '#000000';
1383
+ backColor = '#FACC15';
1384
+ openPop = null;
1385
+ openSubEl = null;
1386
+ popAnchor = null;
1387
+ dlgOvl = null;
1388
+ tblActive = null;
1389
+ tblCorner = null;
1390
+ tblColBars = [];
1391
+ tblCtx = null;
1392
+ tblCellMark = null;
1393
+ tblOpMark = null;
1394
+ tblOpMarkKind = null;
1395
+ imgActive = null;
1396
+ imgHandles = [];
1397
+ imgCtx = null;
1398
+ imgHideTimer = null;
1399
+ phActive = null;
1400
+ phCtx = null;
1401
+ phUploadTarget = null;
1402
+ imageUploadHandler;
1403
+ onImageUploadError;
1404
+ contentStyle;
1405
+ selCtx = null;
1406
+ docInput;
1407
+ revisions = [];
1408
+ revTimer = null;
1409
+ draftTimer = null;
1410
+ draftStorageKey = '';
1411
+ listeners = [];
1412
+ isDestroyed = false;
1413
+ constructor(options) {
1414
+ this.options = {
1415
+ target: options.target,
1416
+ language: options.language || 'en',
1417
+ height: options.height ?? 302,
1418
+ initialContent: options.initialContent ?? '<p><br></p>',
1419
+ onChange: options.onChange || (() => { }),
1420
+ onReady: options.onReady || (() => { }),
1421
+ menubar: options.menubar ?? true,
1422
+ toolbar: options.toolbar ?? true,
1423
+ statusbar: options.statusbar ?? true,
1424
+ readonly: options.readonly ?? false,
1425
+ draftKey: options.draftKey ?? (typeof location !== 'undefined' ? location.pathname : 'default'),
1426
+ fontFamilyFormats: options.fontFamilyFormats ?? FONTS,
1427
+ imageFileTypes: options.imageFileTypes ?? ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/bmp', 'image/webp']
1428
+ };
1429
+ this.imageUploadHandler = options.imageUploadHandler;
1430
+ this.onImageUploadError = options.onImageUploadError;
1431
+ this.contentStyle = options.contentStyle;
1432
+ this.lang = this.options.language;
1433
+ this.draftStorageKey = 'tmclone-draft:' + this.options.draftKey;
1434
+ this.initDOM();
1435
+ this.bindEvents();
1436
+ this.initUI();
1437
+ this.recordRevision();
1438
+ this.options.onReady(this);
1439
+ }
1440
+ t(key) {
1441
+ const s = getStrings(this.lang)[key];
1442
+ return typeof s === 'string' ? s : s.join(',');
1443
+ }
1444
+ tArr(key) {
1445
+ return getStrings(this.lang)[key];
1446
+ }
1447
+ dir() {
1448
+ return getStrings(this.lang).dir;
1449
+ }
1450
+ /* ---------------------------------------------------------- DOM setup */
1451
+ initDOM() {
1452
+ const target = this.options.target;
1453
+ target.innerHTML = '';
1454
+ target.classList.add('tox-root');
1455
+ this.shell = document.createElement('div');
1456
+ this.shell.className = 'tox';
1457
+ this.menubar = document.createElement('div');
1458
+ this.menubar.className = 'mnb';
1459
+ this.toolbar = document.createElement('div');
1460
+ this.toolbar.className = 'tbr';
1461
+ this.ed = document.createElement('div');
1462
+ this.ed.className = 'earea ' + this.contentClass;
1463
+ this.ed.setAttribute('contenteditable', (!this.options.readonly).toString());
1464
+ this.ed.spellcheck = true;
1465
+ this.ed.innerHTML = this.options.initialContent;
1466
+ this.ed.style.height = this.options.height + 'px';
1467
+ this.statusbar = document.createElement('div');
1468
+ this.statusbar.className = 'sbar';
1469
+ this.statusbar.innerHTML = `
1470
+ <span class="mid"></span>
1471
+ <span class="words"></span>
1472
+ <span class="grip">
1473
+ <svg viewBox="0 0 12 12"><path d="M11 1 1 11M11 6 6 11M11 11h0"/></svg>
1474
+ </span>
1475
+ `;
1476
+ this.sbHelp = this.statusbar.querySelector('.mid');
1477
+ this.sbWords = this.statusbar.querySelector('.words');
1478
+ const grip = this.statusbar.querySelector('.grip');
1479
+ this.bindGrip(grip);
1480
+ this.shell.appendChild(this.menubar);
1481
+ this.shell.appendChild(this.toolbar);
1482
+ this.shell.appendChild(this.ed);
1483
+ this.shell.appendChild(this.statusbar);
1484
+ if (this.contentStyle) {
1485
+ this.contentStyleEl = document.createElement('style');
1486
+ this.contentStyleEl.textContent = this.scopeContentStyle(this.contentStyle);
1487
+ this.shell.appendChild(this.contentStyleEl);
1488
+ }
1489
+ target.appendChild(this.shell);
1490
+ if (!this.options.statusbar)
1491
+ this.statusbar.style.display = 'none';
1492
+ this.imgInput = document.createElement('input');
1493
+ this.imgInput.type = 'file';
1494
+ this.imgInput.accept = this.options.imageFileTypes.join(',');
1495
+ this.imgInput.style.display = 'none';
1496
+ target.appendChild(this.imgInput);
1497
+ this.docInput = document.createElement('input');
1498
+ this.docInput.type = 'file';
1499
+ this.docInput.accept = '.docx,application/vnd.openxmlformats-officedocument.wordprocessingml.document';
1500
+ this.docInput.style.display = 'none';
1501
+ target.appendChild(this.docInput);
1502
+ }
1503
+ /** Rewrites the literal word `body` in a user-supplied `contentStyle` string to a
1504
+ * selector scoped to this instance's content div. Everything else passes through
1505
+ * verbatim, since `.ed` is a plain div in the host page (not an iframe) and an
1506
+ * unscoped `body { ... }` rule would otherwise leak onto the host page's body. */
1507
+ scopeContentStyle(css) {
1508
+ return css.replace(/\bbody\b/g, '.' + this.contentClass);
1509
+ }
1510
+ bindGrip(grip) {
1511
+ let startY = 0;
1512
+ let startH = 0;
1513
+ const md = (e) => {
1514
+ startY = e.clientY;
1515
+ startH = this.ed.offsetHeight;
1516
+ e.preventDefault();
1517
+ const mv = (ev) => {
1518
+ this.ed.style.height = Math.max(120, startH + ev.clientY - startY) + 'px';
1519
+ };
1520
+ const up = () => {
1521
+ window.removeEventListener('mousemove', mv);
1522
+ window.removeEventListener('mouseup', up);
1523
+ };
1524
+ window.addEventListener('mousemove', mv);
1525
+ window.addEventListener('mouseup', up);
1526
+ };
1527
+ grip.addEventListener('mousedown', md);
1528
+ this.listeners.push(() => grip.removeEventListener('mousedown', md));
1529
+ }
1530
+ /* ---------------------------------------------------------- event binding */
1531
+ bindEvents() {
1532
+ this.onDoc('mousedown', (e) => {
1533
+ if (this.openPop &&
1534
+ !this.openPop.contains(e.target) &&
1535
+ !this.popAnchor.contains(e.target) &&
1536
+ !(this.openSubEl && this.openSubEl.contains(e.target))) {
1537
+ this.closePop();
1538
+ }
1539
+ });
1540
+ this.onDoc('keydown', (e) => {
1541
+ if (e.key === 'Escape') {
1542
+ this.closePop();
1543
+ this.closeDlg();
1544
+ }
1545
+ });
1546
+ this.onDoc('selectionchange', () => {
1547
+ const sel = window.getSelection();
1548
+ if (sel && this.ed.contains(sel.anchorNode)) {
1549
+ this.saveSel();
1550
+ this.refreshState();
1551
+ this.updateSelToolbar(sel);
1552
+ this.positionCellMarker();
1553
+ }
1554
+ else {
1555
+ this.clearSelToolbar();
1556
+ }
1557
+ });
1558
+ this.on(this.ed, 'input', () => {
1559
+ this.refreshState();
1560
+ this.onChange();
1561
+ this.positionTableHandles();
1562
+ this.positionImageHandles();
1563
+ this.positionImgPhCtx();
1564
+ });
1565
+ this.on(this.ed, 'keydown', (e) => {
1566
+ if (e.altKey && e.key === '0') {
1567
+ e.preventDefault();
1568
+ this.helpDlg();
1569
+ }
1570
+ });
1571
+ this.on(this.ed, 'paste', (e) => this.handlePaste(e));
1572
+ this.on(this.ed, 'drop', (e) => {
1573
+ /* content drops stay blocked (powerpaste_block_drop) — only an
1574
+ image file dropped onto an upload placeholder is consumed */
1575
+ e.preventDefault();
1576
+ const ph = e.target.closest?.('.img-ph');
1577
+ if (!ph || !this.ed.contains(ph) || this.options.readonly)
1578
+ return;
1579
+ const file = Array.from(e.dataTransfer?.files || []).find((f) => f.type.startsWith('image/'));
1580
+ if (file)
1581
+ this.readImageFileInto(file, ph);
1582
+ });
1583
+ this.on(this.ed, 'dragover', (e) => e.preventDefault());
1584
+ this.on(this.ed, 'mousedown', (e) => {
1585
+ const target = e.target;
1586
+ const table = target.closest?.('table');
1587
+ if (table && this.ed.contains(table))
1588
+ this.selectTableForResize(table);
1589
+ else
1590
+ this.clearTableHandles();
1591
+ const ph = target.closest?.('.img-ph');
1592
+ if (ph && this.ed.contains(ph) && !this.options.readonly) {
1593
+ e.preventDefault();
1594
+ this.selectImgPlaceholder(ph);
1595
+ }
1596
+ else {
1597
+ this.clearImgPlaceholderSel();
1598
+ }
1599
+ const img = target.closest?.('img');
1600
+ if (img && this.ed.contains(img) && !this.options.readonly) {
1601
+ e.preventDefault();
1602
+ this.selectImage(img);
1603
+ }
1604
+ else {
1605
+ this.clearImageHandles();
1606
+ }
1607
+ });
1608
+ this.on(this.ed, 'mouseover', (e) => {
1609
+ if (this.options.readonly)
1610
+ return;
1611
+ const img = e.target.closest?.('img');
1612
+ if (img && this.ed.contains(img)) {
1613
+ this.cancelImgHide();
1614
+ this.selectImage(img);
1615
+ }
1616
+ });
1617
+ this.on(this.ed, 'mouseout', (e) => {
1618
+ const img = e.target.closest?.('img');
1619
+ if (!img)
1620
+ return;
1621
+ const to = e.relatedTarget;
1622
+ if (to &&
1623
+ (this.imgCtx?.contains(to) ||
1624
+ this.imgHandles.some((h) => h.contains(to)) ||
1625
+ to.closest?.('img') === img))
1626
+ return;
1627
+ this.scheduleImgHide();
1628
+ });
1629
+ this.on(this.ed, 'scroll', () => {
1630
+ this.positionTableHandles();
1631
+ this.positionImageHandles();
1632
+ this.positionImgPhCtx();
1633
+ });
1634
+ this.onWin('scroll', () => {
1635
+ this.positionTableHandles();
1636
+ this.positionImageHandles();
1637
+ this.positionImgPhCtx();
1638
+ this.repositionSelToolbar();
1639
+ }, true);
1640
+ this.onWin('resize', () => {
1641
+ this.positionTableHandles();
1642
+ this.positionImageHandles();
1643
+ this.positionImgPhCtx();
1644
+ this.repositionSelToolbar();
1645
+ });
1646
+ this.on(this.imgInput, 'change', () => {
1647
+ const file = this.imgInput.files?.[0];
1648
+ if (!file)
1649
+ return;
1650
+ const ph = this.phUploadTarget;
1651
+ this.phUploadTarget = null;
1652
+ if (ph && this.ed.contains(ph)) {
1653
+ this.readImageFileInto(file, ph);
1654
+ return;
1655
+ }
1656
+ const fr = new FileReader();
1657
+ fr.onload = () => {
1658
+ this.restoreSel();
1659
+ document.execCommand('insertHTML', false, `<img src="${fr.result}" title="${file.name.replace(/"/g, '')}" alt="">`);
1660
+ this.onChange();
1661
+ };
1662
+ fr.readAsDataURL(file);
1663
+ });
1664
+ this.on(this.docInput, 'change', () => {
1665
+ const file = this.docInput.files?.[0];
1666
+ if (!file)
1667
+ return;
1668
+ this.importWordFile(file).catch(() => alert(this.t('importfail')));
1669
+ });
1670
+ this.onDoc('mousedown', (e) => {
1671
+ if (this.ed.contains(e.target) ||
1672
+ e.target === this.tblCorner ||
1673
+ this.tblColBars.includes(e.target) ||
1674
+ (this.tblCtx && this.tblCtx.contains(e.target)) ||
1675
+ this.imgHandles.includes(e.target) ||
1676
+ (this.imgCtx && this.imgCtx.contains(e.target)) ||
1677
+ (this.phCtx && this.phCtx.contains(e.target)) ||
1678
+ (this.selCtx && this.selCtx.contains(e.target)) ||
1679
+ (this.openPop && this.openPop.contains(e.target)) ||
1680
+ (this.openSubEl && this.openSubEl.contains(e.target)))
1681
+ return;
1682
+ this.clearTableHandles();
1683
+ this.clearImageHandles();
1684
+ this.clearImgPlaceholderSel();
1685
+ this.clearSelToolbar();
1686
+ });
1687
+ }
1688
+ on(target, event, handler) {
1689
+ target.addEventListener(event, handler);
1690
+ this.listeners.push(() => target.removeEventListener(event, handler));
1691
+ }
1692
+ onDoc(event, handler) {
1693
+ document.addEventListener(event, handler);
1694
+ this.listeners.push(() => document.removeEventListener(event, handler));
1695
+ }
1696
+ onWin(event, handler, capture) {
1697
+ window.addEventListener(event, handler, capture);
1698
+ this.listeners.push(() => window.removeEventListener(event, handler, capture));
1699
+ }
1700
+ /* ---------------------------------------------------------- public API */
1701
+ getContent() {
1702
+ return this.ed.innerHTML;
1703
+ }
1704
+ setContent(html) {
1705
+ this.ed.innerHTML = html || '<p><br></p>';
1706
+ this.refreshState();
1707
+ this.clearTableHandles();
1708
+ this.clearImageHandles();
1709
+ this.clearImgPlaceholderSel();
1710
+ this.clearSelToolbar();
1711
+ this.onChange();
1712
+ }
1713
+ insertContent(html) {
1714
+ this.restoreSel();
1715
+ document.execCommand('insertHTML', false, html);
1716
+ this.saveSel();
1717
+ this.refreshState();
1718
+ this.onChange();
1719
+ }
1720
+ setLanguage(lang) {
1721
+ this.lang = lang;
1722
+ this.initUI();
1723
+ }
1724
+ focus() {
1725
+ this.ed.focus();
1726
+ }
1727
+ async importWordFile(file) {
1728
+ const html = await importDocxToHtml(file);
1729
+ this.recordRevision();
1730
+ this.setContent(html);
1731
+ this.recordRevision();
1732
+ }
1733
+ restoreDraft() {
1734
+ let raw = null;
1735
+ try {
1736
+ raw = localStorage.getItem(this.draftStorageKey);
1737
+ }
1738
+ catch {
1739
+ /* storage unavailable */
1740
+ }
1741
+ if (!raw)
1742
+ return false;
1743
+ try {
1744
+ const draft = JSON.parse(raw);
1745
+ if (!draft.html)
1746
+ return false;
1747
+ this.recordRevision();
1748
+ this.setContent(draft.html);
1749
+ return true;
1750
+ }
1751
+ catch {
1752
+ return false;
1753
+ }
1754
+ }
1755
+ getRevisions() {
1756
+ return this.revisions.slice();
1757
+ }
1758
+ destroy() {
1759
+ if (this.isDestroyed)
1760
+ return;
1761
+ this.isDestroyed = true;
1762
+ if (this.revTimer != null)
1763
+ window.clearTimeout(this.revTimer);
1764
+ if (this.draftTimer != null)
1765
+ window.clearTimeout(this.draftTimer);
1766
+ this.closePop();
1767
+ this.closeDlg();
1768
+ this.clearTableHandles();
1769
+ this.clearImageHandles();
1770
+ this.clearImgPlaceholderSel();
1771
+ this.clearSelToolbar();
1772
+ this.listeners.forEach((fn) => fn());
1773
+ this.listeners = [];
1774
+ if (this.shell && this.shell.parentNode)
1775
+ this.shell.parentNode.removeChild(this.shell);
1776
+ if (this.imgInput && this.imgInput.parentNode)
1777
+ this.imgInput.parentNode.removeChild(this.imgInput);
1778
+ if (this.docInput && this.docInput.parentNode)
1779
+ this.docInput.parentNode.removeChild(this.docInput);
1780
+ this.contentStyleEl = null;
1781
+ }
1782
+ /* ---------------------------------------------------------- selection */
1783
+ saveSel() {
1784
+ const s = window.getSelection();
1785
+ if (s && s.rangeCount && this.ed.contains(s.anchorNode))
1786
+ this.savedRange = s.getRangeAt(0).cloneRange();
1787
+ }
1788
+ restoreSel() {
1789
+ if (!this.savedRange) {
1790
+ this.ed.focus();
1791
+ return;
1792
+ }
1793
+ this.ed.focus();
1794
+ const s = window.getSelection();
1795
+ if (!s)
1796
+ return;
1797
+ s.removeAllRanges();
1798
+ s.addRange(this.savedRange);
1799
+ }
1800
+ exec(cmd, val) {
1801
+ this.restoreSel();
1802
+ document.execCommand('styleWithCSS', false, cmd !== 'fontSize' ? 'true' : 'false');
1803
+ document.execCommand(cmd, false, val ?? undefined);
1804
+ this.saveSel();
1805
+ this.refreshState();
1806
+ this.onChange();
1807
+ }
1808
+ closestBlock(node) {
1809
+ while (node && node !== this.ed) {
1810
+ if (node.nodeType === 1 && /^(P|DIV|H[1-6]|LI|PRE|BLOCKQUOTE|TD|TH)$/i.test(node.tagName)) {
1811
+ return node;
1812
+ }
1813
+ node = node.parentNode;
1814
+ }
1815
+ return null;
1816
+ }
1817
+ selectedBlocks() {
1818
+ const s = window.getSelection();
1819
+ if (!s || !s.rangeCount || !this.ed.contains(s.anchorNode))
1820
+ return [];
1821
+ const r = s.getRangeAt(0);
1822
+ const blocks = Array.from(this.ed.querySelectorAll('p,h1,h2,h3,h4,h5,h6,li,pre,blockquote,td,th')).filter((b) => r.intersectsNode(b));
1823
+ if (!blocks.length) {
1824
+ const b = this.closestBlock(s.anchorNode);
1825
+ if (b)
1826
+ return [b];
1827
+ }
1828
+ return blocks;
1829
+ }
1830
+ onChange() {
1831
+ this.options.onChange(this.ed.innerHTML);
1832
+ this.scheduleRevision();
1833
+ this.scheduleDraftSave();
1834
+ }
1835
+ /* ---------------------------------------------------------- revisions / drafts */
1836
+ recordRevision() {
1837
+ const html = this.ed.innerHTML;
1838
+ const last = this.revisions[this.revisions.length - 1];
1839
+ if (last && last.html === html)
1840
+ return;
1841
+ this.revisions.push({ time: Date.now(), html });
1842
+ if (this.revisions.length > 30)
1843
+ this.revisions.shift();
1844
+ }
1845
+ scheduleRevision() {
1846
+ if (this.revTimer != null)
1847
+ window.clearTimeout(this.revTimer);
1848
+ this.revTimer = window.setTimeout(() => {
1849
+ this.revTimer = null;
1850
+ this.recordRevision();
1851
+ }, 1500);
1852
+ }
1853
+ scheduleDraftSave() {
1854
+ if (this.draftTimer != null)
1855
+ window.clearTimeout(this.draftTimer);
1856
+ this.draftTimer = window.setTimeout(() => {
1857
+ this.draftTimer = null;
1858
+ try {
1859
+ localStorage.setItem(this.draftStorageKey, JSON.stringify({ time: Date.now(), html: this.ed.innerHTML }));
1860
+ }
1861
+ catch {
1862
+ /* storage unavailable or full */
1863
+ }
1864
+ }, 800);
1865
+ }
1866
+ revisionDlg() {
1867
+ this.recordRevision();
1868
+ this.dialog(this.t('revhistory'), (body) => {
1869
+ if (!this.revisions.length) {
1870
+ body.innerHTML = `<p style="color:#556">${this.t('revempty')}</p>`;
1871
+ return;
1872
+ }
1873
+ const wrap = document.createElement('div');
1874
+ wrap.className = 'revwrap';
1875
+ const list = document.createElement('div');
1876
+ list.className = 'revlist';
1877
+ const prev = document.createElement('div');
1878
+ prev.className = 'revprev';
1879
+ const restoreBtn = document.createElement('button');
1880
+ restoreBtn.type = 'button';
1881
+ restoreBtn.className = 'revrestore';
1882
+ restoreBtn.textContent = this.t('restore');
1883
+ let selected = this.revisions.length - 1;
1884
+ const fmt = new Intl.DateTimeFormat(this.lang === 'ar' ? 'ar-AE' : 'en-GB', {
1885
+ year: 'numeric',
1886
+ month: 'short',
1887
+ day: 'numeric',
1888
+ hour: '2-digit',
1889
+ minute: '2-digit',
1890
+ second: '2-digit'
1891
+ });
1892
+ const items = {};
1893
+ const show = (i) => {
1894
+ selected = i;
1895
+ this.revisions.forEach((_, j) => items[j]?.classList.toggle('on', j === i));
1896
+ prev.innerHTML = this.revisions[i].html;
1897
+ restoreBtn.disabled = i === this.revisions.length - 1;
1898
+ };
1899
+ for (let i = this.revisions.length - 1; i >= 0; i--) {
1900
+ const rev = this.revisions[i];
1901
+ const b = document.createElement('button');
1902
+ b.type = 'button';
1903
+ const words = (new DOMParser().parseFromString(rev.html, 'text/html').body.textContent || '').trim();
1904
+ const wc = words ? words.split(/\s+/).length : 0;
1905
+ const isCurrent = i === this.revisions.length - 1;
1906
+ b.innerHTML = `<span>${isCurrent ? this.t('currentver') : fmt.format(new Date(rev.time))}</span><span class="wc">${wc} ${this.t('words')}</span>`;
1907
+ b.addEventListener('click', () => show(i));
1908
+ items[i] = b;
1909
+ list.appendChild(b);
1910
+ }
1911
+ list.append(restoreBtn);
1912
+ restoreBtn.addEventListener('click', () => {
1913
+ const rev = this.revisions[selected];
1914
+ if (!rev)
1915
+ return;
1916
+ this.recordRevision();
1917
+ this.setContent(rev.html);
1918
+ this.recordRevision();
1919
+ this.closeDlg();
1920
+ });
1921
+ wrap.append(list, prev);
1922
+ body.appendChild(wrap);
1923
+ show(selected);
1924
+ });
1925
+ }
1926
+ /* ---------------------------------------------------------- popups / menus */
1927
+ closeSub() {
1928
+ this.openSubEl?.remove();
1929
+ this.openSubEl = null;
1930
+ }
1931
+ closePop() {
1932
+ this.closeSub();
1933
+ if (this.openPop) {
1934
+ this.openPop.remove();
1935
+ this.openPop = null;
1936
+ this.popAnchor?.classList.remove('open');
1937
+ this.popAnchor = null;
1938
+ /* a menu item's mouseleave never fires once the menu is removed,
1939
+ so drop any row/column highlight it left behind */
1940
+ this.setOpTarget(null);
1941
+ }
1942
+ }
1943
+ openSubFor(item, anchor) {
1944
+ this.closeSub();
1945
+ const sub = document.createElement('div');
1946
+ sub.className = 'pop sub';
1947
+ sub.dir = this.dir();
1948
+ if (item.subBuild)
1949
+ item.subBuild(sub);
1950
+ else if (item.sub)
1951
+ this.menuItems(sub, item.sub);
1952
+ sub.addEventListener('mousedown', (e) => {
1953
+ if (e.target.tagName !== 'INPUT')
1954
+ e.preventDefault();
1955
+ });
1956
+ document.body.appendChild(sub);
1957
+ const r = anchor.getBoundingClientRect();
1958
+ const isRtl = this.dir() === 'rtl';
1959
+ // overlap the parent item slightly so the pointer can travel into the flyout
1960
+ let x = isRtl ? r.left - sub.offsetWidth + 2 : r.right - 2;
1961
+ x = Math.max(8, Math.min(x + scrollX, scrollX + innerWidth - sub.offsetWidth - 8));
1962
+ let y = r.top + scrollY - 6;
1963
+ y = Math.max(8 + scrollY, Math.min(y, scrollY + innerHeight - sub.offsetHeight - 8));
1964
+ sub.style.left = x + 'px';
1965
+ sub.style.top = y + 'px';
1966
+ this.openSubEl = sub;
1967
+ }
1968
+ popup(anchor, build) {
1969
+ if (this.openPop && this.popAnchor === anchor) {
1970
+ this.closePop();
1971
+ return;
1972
+ }
1973
+ this.closePop();
1974
+ const el = document.createElement('div');
1975
+ el.className = 'pop';
1976
+ el.dir = this.dir();
1977
+ build(el);
1978
+ document.body.appendChild(el);
1979
+ const r = anchor.getBoundingClientRect();
1980
+ // align the menu with its control: at least as wide as the anchor
1981
+ el.style.minWidth = Math.max(160, Math.round(r.width)) + 'px';
1982
+ el.style.top = r.bottom + scrollY + 2 + 'px';
1983
+ const isRtl = this.dir() === 'rtl';
1984
+ let x = isRtl ? r.right - el.offsetWidth : r.left;
1985
+ x = Math.max(8, Math.min(x + scrollX, scrollX + innerWidth - el.offsetWidth - 8));
1986
+ el.style.left = x + 'px';
1987
+ el.addEventListener('mousedown', (e) => {
1988
+ if (e.target.tagName !== 'INPUT')
1989
+ e.preventDefault();
1990
+ });
1991
+ this.openPop = el;
1992
+ this.popAnchor = anchor;
1993
+ anchor.classList.add('open');
1994
+ }
1995
+ menuItems(el, items) {
1996
+ items.forEach((it) => {
1997
+ if (it === '|') {
1998
+ el.insertAdjacentHTML('beforeend', '<div class="sep"></div>');
1999
+ return;
2000
+ }
2001
+ const hasSub = !!(it.sub || it.subBuild);
2002
+ const b = document.createElement('button');
2003
+ b.className = 'mi' + (it.on ? ' on' : '');
2004
+ b.innerHTML = `<span class="ic">${it.icon || ''}</span><span class="prev" ${it.previewStyle ? `style="${it.previewStyle}"` : ''}>${it.label}</span>${it.shortcut ? `<span class="sc">${it.shortcut}</span>` : ''}${hasSub ? `<span class="subarrow">${IC.chev}</span>` : ''}`;
2005
+ b.addEventListener('click', () => {
2006
+ if (!it.action)
2007
+ return;
2008
+ this.closePop();
2009
+ it.action();
2010
+ });
2011
+ b.addEventListener('mouseenter', () => {
2012
+ if (hasSub)
2013
+ this.openSubFor(it, b);
2014
+ else if (this.openSubEl && el !== this.openSubEl)
2015
+ this.closeSub();
2016
+ it.hover?.(true);
2017
+ });
2018
+ b.addEventListener('mouseleave', () => it.hover?.(false));
2019
+ el.appendChild(b);
2020
+ });
2021
+ }
2022
+ /* ---------------------------------------------------------- toolbar builders */
2023
+ tbtn(icon, tip, fn, id) {
2024
+ const b = document.createElement('button');
2025
+ b.className = 'tbtn';
2026
+ b.type = 'button';
2027
+ b.title = tip;
2028
+ b.innerHTML = icon;
2029
+ if (id)
2030
+ b.dataset.id = id;
2031
+ b.addEventListener('mousedown', (e) => e.preventDefault());
2032
+ b.addEventListener('click', fn);
2033
+ return b;
2034
+ }
2035
+ /** Font-size increase / decrease step button. */
2036
+ stepBtn(delta) {
2037
+ const sign = delta > 0 ? '+' : '−';
2038
+ const icon = delta > 0 ? IC.fontsizeup : IC.fontsizedown;
2039
+ const b = this.tbtn(icon, this.t('fontsize') + ' ' + sign, () => this.stepFontSize(delta));
2040
+ b.addEventListener('mousedown', () => this.saveSel());
2041
+ return b;
2042
+ }
2043
+ /** Toolbar icon button that opens a popup menu anchored to itself. */
2044
+ menuTbtn(icon, tip, menuFn, id) {
2045
+ const b = this.tbtn(icon, tip, () => menuFn(b), id);
2046
+ b.classList.add('hasmenu');
2047
+ b.insertAdjacentHTML('beforeend', IC.chev);
2048
+ b.addEventListener('mousedown', () => this.saveSel());
2049
+ return b;
2050
+ }
2051
+ /** Quick text-color chips plus a "more" chip that opens the full palette. */
2052
+ colorChips() {
2053
+ const chip = (title) => {
2054
+ const b = document.createElement('button');
2055
+ b.type = 'button';
2056
+ b.className = 'cchip';
2057
+ b.title = title;
2058
+ b.addEventListener('mousedown', (e) => {
2059
+ e.preventDefault();
2060
+ this.saveSel();
2061
+ });
2062
+ return b;
2063
+ };
2064
+ const chips = QUICK_COLORS.map((c) => {
2065
+ const b = chip(this.t('forecolor'));
2066
+ b.style.background = c;
2067
+ b.addEventListener('click', () => this.applyColor('fore', c));
2068
+ return b;
2069
+ });
2070
+ const more = chip(this.t('forecolor'));
2071
+ more.classList.add('more');
2072
+ more.dataset.cb = 'fore';
2073
+ more.innerHTML = IC.chev;
2074
+ more.addEventListener('click', () => this.colorMenu(more, 'fore'));
2075
+ return [...chips, more];
2076
+ }
2077
+ group(...els) {
2078
+ const g = document.createElement('div');
2079
+ g.className = 'tgrp';
2080
+ els.forEach((e) => {
2081
+ e.classList.add('gbtn');
2082
+ g.appendChild(e);
2083
+ });
2084
+ return g;
2085
+ }
2086
+ tsel(cls, id, defLabel, menuFn) {
2087
+ const b = document.createElement('button');
2088
+ b.className = 'tsel ' + cls;
2089
+ b.type = 'button';
2090
+ b.dataset.id = id;
2091
+ b.innerHTML = `<span class="lbl empty">${defLabel || ''}</span>${IC.chev}`;
2092
+ b.addEventListener('mousedown', (e) => {
2093
+ e.preventDefault();
2094
+ this.saveSel();
2095
+ });
2096
+ b.addEventListener('click', () => menuFn(b));
2097
+ return b;
2098
+ }
2099
+ setListStyle(kind, style) {
2100
+ this.restoreSel();
2101
+ const s = window.getSelection();
2102
+ if (!s)
2103
+ return;
2104
+ let list = this.closestBlock(s.anchorNode)?.closest(kind);
2105
+ if (!list) {
2106
+ this.exec(kind === 'ol' ? 'insertOrderedList' : 'insertUnorderedList');
2107
+ list = this.closestBlock(s.anchorNode)?.closest(kind);
2108
+ }
2109
+ if (list)
2110
+ list.style.listStyleType = style;
2111
+ this.saveSel();
2112
+ this.onChange();
2113
+ }
2114
+ listMenu(anchor, kind) {
2115
+ const cmd = kind === 'ol' ? 'insertOrderedList' : 'insertUnorderedList';
2116
+ // the toggle item creates the default style, so it is not repeated below
2117
+ const styles = kind === 'ol'
2118
+ ? [
2119
+ ['lower-alpha', IC.ol_alpha],
2120
+ ['lower-roman', IC.ol_roman],
2121
+ ['upper-alpha', IC.ol_alphau],
2122
+ ['upper-roman', IC.ol_romanu]
2123
+ ]
2124
+ : [
2125
+ ['circle', IC.ul_circle],
2126
+ ['square', IC.ul_square]
2127
+ ];
2128
+ const labels = this.tArr(kind === 'ol' ? 'liststyles_ol' : 'liststyles_ul').slice(1);
2129
+ const sel = window.getSelection();
2130
+ const listEl = sel ? this.closestBlock(sel.anchorNode)?.closest(kind) : null;
2131
+ const curStyle = listEl ? listEl.style.listStyleType : '';
2132
+ this.popup(anchor, (el) => {
2133
+ this.menuItems(el, [
2134
+ {
2135
+ label: this.t(kind === 'ol' ? 'numlist' : 'bullist'),
2136
+ icon: kind === 'ol' ? IC.numlist : IC.bullist,
2137
+ on: document.queryCommandState(cmd),
2138
+ action: () => this.exec(cmd)
2139
+ }
2140
+ ]);
2141
+ el.insertAdjacentHTML('beforeend', '<div class="sep"></div>');
2142
+ const grid = document.createElement('div');
2143
+ grid.className = 'mi-grid';
2144
+ styles.forEach(([s, icon], i) => {
2145
+ const b = document.createElement('button');
2146
+ b.className = 'mi' + (curStyle === s ? ' on' : '');
2147
+ b.innerHTML = `<span class="ic">${icon}</span><span class="prev">${labels[i]}</span>`;
2148
+ b.addEventListener('click', () => {
2149
+ this.closePop();
2150
+ this.setListStyle(kind, s);
2151
+ });
2152
+ grid.appendChild(b);
2153
+ });
2154
+ el.appendChild(grid);
2155
+ });
2156
+ }
2157
+ colorMenu(anchor, kind) {
2158
+ this.popup(anchor, (el) => {
2159
+ const grid = document.createElement('div');
2160
+ grid.className = 'cpal';
2161
+ COLORS.forEach((c) => {
2162
+ const b = document.createElement('button');
2163
+ b.style.background = c;
2164
+ b.title = c;
2165
+ b.addEventListener('click', () => {
2166
+ this.closePop();
2167
+ this.applyColor(kind, c);
2168
+ });
2169
+ grid.appendChild(b);
2170
+ });
2171
+ el.appendChild(grid);
2172
+ el.insertAdjacentHTML('beforeend', '<div class="sep"></div>');
2173
+ const foot = document.createElement('div');
2174
+ foot.className = 'cfoot';
2175
+ const cur = document.createElement('div');
2176
+ cur.className = 'cswatch cur';
2177
+ cur.title = this.t('currentcolor');
2178
+ const curColor = kind === 'fore' ? this.foreColor : this.backColor;
2179
+ cur.style.background = /^#[0-9a-f]{6}$/i.test(curColor) ? curColor : '#000000';
2180
+ cur.innerHTML = IC.check;
2181
+ const nocolor = document.createElement('button');
2182
+ nocolor.type = 'button';
2183
+ nocolor.className = 'cswatch nocolor';
2184
+ nocolor.title = this.t('removecolor');
2185
+ nocolor.addEventListener('click', () => {
2186
+ this.closePop();
2187
+ this.applyColor(kind, null);
2188
+ });
2189
+ const wheel = document.createElement('label');
2190
+ wheel.className = 'cswatch cwheel';
2191
+ wheel.title = this.t('customcolor');
2192
+ wheel.innerHTML = IC.palette;
2193
+ const picker = document.createElement('input');
2194
+ picker.type = 'color';
2195
+ picker.value = /^#[0-9a-f]{6}$/i.test(curColor) ? curColor : '#000000';
2196
+ picker.addEventListener('change', () => {
2197
+ this.closePop();
2198
+ this.applyColor(kind, picker.value);
2199
+ });
2200
+ wheel.appendChild(picker);
2201
+ foot.append(cur, nocolor, wheel);
2202
+ el.appendChild(foot);
2203
+ });
2204
+ }
2205
+ applyColor(kind, c) {
2206
+ if (kind === 'fore') {
2207
+ this.foreColor = c || '#000000';
2208
+ this.exec('foreColor', c || '#000000');
2209
+ }
2210
+ else {
2211
+ this.backColor = c || 'transparent';
2212
+ this.exec('hiliteColor', c || 'transparent');
2213
+ }
2214
+ this.syncColorSwatches(this.toolbar);
2215
+ if (this.selCtx)
2216
+ this.syncColorSwatches(this.selCtx);
2217
+ }
2218
+ syncColorSwatches(container) {
2219
+ container.querySelectorAll(`[data-cb=fore]`).forEach((el) => (el.style.background = this.foreColor));
2220
+ container.querySelectorAll(`[data-cb=back]`).forEach((el) => (el.style.background = this.backColor));
2221
+ }
2222
+ fontMenu(anchor) {
2223
+ this.popup(anchor, (el) => {
2224
+ this.menuItems(el, this.options.fontFamilyFormats.map(([name, val]) => ({
2225
+ label: name,
2226
+ previewStyle: `font-family:${val}`,
2227
+ on: this.currentFont() === name,
2228
+ action: () => this.exec('fontName', val)
2229
+ })));
2230
+ });
2231
+ }
2232
+ sizeMenu(anchor) {
2233
+ this.popup(anchor, (el) => {
2234
+ this.menuItems(el, SIZES.map((s) => ({
2235
+ label: s,
2236
+ on: this.currentSize() === s,
2237
+ action: () => this.applyFontSize(s)
2238
+ })));
2239
+ });
2240
+ }
2241
+ /** Font sizing needs a non-collapsed range. With a bare caret, grow the
2242
+ selection to the word under it; in an empty spot, select a zero-width
2243
+ space so the new size applies to what gets typed next. */
2244
+ ensureSizeTarget() {
2245
+ const s = window.getSelection();
2246
+ if (!s)
2247
+ return;
2248
+ if (!s.rangeCount) {
2249
+ const r = document.createRange();
2250
+ r.selectNodeContents(this.ed);
2251
+ r.collapse(true);
2252
+ s.addRange(r);
2253
+ }
2254
+ if (!s.isCollapsed)
2255
+ return;
2256
+ const r = s.getRangeAt(0);
2257
+ const node = r.startContainer;
2258
+ if (node.nodeType === 3) {
2259
+ const text = node.data;
2260
+ let a = r.startOffset;
2261
+ let b = r.startOffset;
2262
+ while (a > 0 && /\S/.test(text[a - 1]))
2263
+ a--;
2264
+ while (b < text.length && /\S/.test(text[b]))
2265
+ b++;
2266
+ if (b > a) {
2267
+ const nr = document.createRange();
2268
+ nr.setStart(node, a);
2269
+ nr.setEnd(node, b);
2270
+ s.removeAllRanges();
2271
+ s.addRange(nr);
2272
+ this.savedRange = nr.cloneRange();
2273
+ return;
2274
+ }
2275
+ }
2276
+ const zw = document.createTextNode(String.fromCharCode(0x200b));
2277
+ r.insertNode(zw);
2278
+ const nr = document.createRange();
2279
+ nr.setStart(zw, 0);
2280
+ nr.setEnd(zw, 1);
2281
+ s.removeAllRanges();
2282
+ s.addRange(nr);
2283
+ this.savedRange = nr.cloneRange();
2284
+ }
2285
+ applyFontSize(px) {
2286
+ this.restoreSel();
2287
+ this.ensureSizeTarget();
2288
+ document.execCommand('styleWithCSS', false, 'false');
2289
+ document.execCommand('fontSize', false, '7');
2290
+ const spans = [];
2291
+ this.ed.querySelectorAll('font[size="7"]').forEach((f) => {
2292
+ const span = document.createElement('span');
2293
+ span.style.fontSize = px;
2294
+ while (f.firstChild)
2295
+ span.appendChild(f.firstChild);
2296
+ f.replaceWith(span);
2297
+ spans.push(span);
2298
+ });
2299
+ if (spans.length) {
2300
+ // keep the selection anchored inside the new spans so the current
2301
+ // size reads back correctly (e.g. for repeated +/- stepping)
2302
+ const r = document.createRange();
2303
+ r.setStart(spans[0], 0);
2304
+ r.setEnd(spans[spans.length - 1], spans[spans.length - 1].childNodes.length);
2305
+ const s = window.getSelection();
2306
+ if (s) {
2307
+ s.removeAllRanges();
2308
+ s.addRange(r);
2309
+ }
2310
+ }
2311
+ this.saveSel();
2312
+ this.refreshState();
2313
+ this.onChange();
2314
+ }
2315
+ stepFontSize(delta) {
2316
+ const cur = parseInt(this.currentSize(), 10) || 14;
2317
+ const next = Math.min(MAX_FONT_PX, Math.max(MIN_FONT_PX, cur + delta));
2318
+ if (next === cur)
2319
+ return;
2320
+ this.applyFontSize(next + 'px');
2321
+ }
2322
+ /* ------------------------------------------------ case / spacing / link */
2323
+ transformCase(kind) {
2324
+ this.restoreSel();
2325
+ const s = window.getSelection();
2326
+ if (!s || !s.rangeCount || s.isCollapsed)
2327
+ return;
2328
+ const range = s.getRangeAt(0);
2329
+ const walker = document.createTreeWalker(this.ed, NodeFilter.SHOW_TEXT);
2330
+ const nodes = [];
2331
+ let n;
2332
+ while ((n = walker.nextNode()))
2333
+ if (range.intersectsNode(n))
2334
+ nodes.push(n);
2335
+ nodes.forEach((t) => {
2336
+ const start = t === range.startContainer ? range.startOffset : 0;
2337
+ const end = t === range.endContainer ? range.endOffset : t.data.length;
2338
+ if (end <= start)
2339
+ return;
2340
+ const mid = t.data.slice(start, end);
2341
+ let out = mid;
2342
+ if (kind === 'lower')
2343
+ out = mid.toLowerCase();
2344
+ else if (kind === 'upper')
2345
+ out = mid.toUpperCase();
2346
+ else
2347
+ out = mid.replace(/(^|\s)(\S)/g, (m, sp, ch) => sp + ch.toUpperCase());
2348
+ t.replaceData(start, end - start, out);
2349
+ });
2350
+ this.saveSel();
2351
+ this.refreshState();
2352
+ this.onChange();
2353
+ }
2354
+ caseMenu(anchor) {
2355
+ this.popup(anchor, (el) => {
2356
+ this.menuItems(el, [
2357
+ { label: this.t('lowercase'), icon: IC.lowercaseic, action: () => this.transformCase('lower') },
2358
+ { label: this.t('uppercase'), icon: IC.uppercaseic, action: () => this.transformCase('upper') },
2359
+ { label: this.t('capitalize'), icon: IC.capitalizeic, action: () => this.transformCase('capitalize') }
2360
+ ]);
2361
+ });
2362
+ }
2363
+ applyBlockStyle(prop, value) {
2364
+ this.restoreSel();
2365
+ const blocks = this.selectedBlocks();
2366
+ blocks.forEach((b) => {
2367
+ if (value)
2368
+ b.style[prop] = value;
2369
+ else
2370
+ b.style.removeProperty(prop === 'lineHeight' ? 'line-height' : 'word-spacing');
2371
+ });
2372
+ this.saveSel();
2373
+ this.onChange();
2374
+ }
2375
+ currentBlockStyle(prop) {
2376
+ const s = window.getSelection();
2377
+ const b = s ? this.closestBlock(s.anchorNode) : null;
2378
+ return b ? b.style[prop] || '' : '';
2379
+ }
2380
+ lineHeightMenu(anchor) {
2381
+ const cur = this.currentBlockStyle('lineHeight');
2382
+ this.popup(anchor, (el) => {
2383
+ this.menuItems(el, [
2384
+ { label: this.t('normal'), on: !cur, action: () => this.applyBlockStyle('lineHeight', '') },
2385
+ ...LINE_HEIGHTS.map((v) => ({
2386
+ label: v,
2387
+ on: cur === v,
2388
+ action: () => this.applyBlockStyle('lineHeight', v)
2389
+ }))
2390
+ ]);
2391
+ });
2392
+ }
2393
+ wordSpacingMenu(anchor) {
2394
+ const cur = this.currentBlockStyle('wordSpacing');
2395
+ this.popup(anchor, (el) => {
2396
+ this.menuItems(el, [
2397
+ { label: this.t('normal'), on: !cur, action: () => this.applyBlockStyle('wordSpacing', '') },
2398
+ ...WORD_SPACINGS.map((v) => ({
2399
+ label: v,
2400
+ on: cur === v,
2401
+ action: () => this.applyBlockStyle('wordSpacing', v)
2402
+ }))
2403
+ ]);
2404
+ });
2405
+ }
2406
+ closestAnchor() {
2407
+ const s = window.getSelection();
2408
+ let node = s && this.ed.contains(s.anchorNode) ? s.anchorNode : null;
2409
+ while (node && node !== this.ed) {
2410
+ if (node.nodeType === 1 && node.tagName === 'A')
2411
+ return node;
2412
+ node = node.parentNode;
2413
+ }
2414
+ return null;
2415
+ }
2416
+ linkDlg() {
2417
+ this.saveSel();
2418
+ const anchorEl = this.closestAnchor();
2419
+ let urlIn;
2420
+ const buttons = [];
2421
+ if (anchorEl) {
2422
+ buttons.push({
2423
+ label: this.t('unlink'),
2424
+ action: () => {
2425
+ const r = document.createRange();
2426
+ r.selectNodeContents(anchorEl);
2427
+ const s = window.getSelection();
2428
+ if (s) {
2429
+ s.removeAllRanges();
2430
+ s.addRange(r);
2431
+ }
2432
+ document.execCommand('unlink');
2433
+ this.closeDlg();
2434
+ this.onChange();
2435
+ }
2436
+ });
2437
+ }
2438
+ buttons.push({ label: this.t('cancel'), action: () => this.closeDlg() }, {
2439
+ label: this.t('save'),
2440
+ primary: true,
2441
+ action: () => {
2442
+ const url = urlIn.value.trim();
2443
+ this.closeDlg();
2444
+ if (!url)
2445
+ return;
2446
+ if (anchorEl) {
2447
+ anchorEl.setAttribute('href', url);
2448
+ this.onChange();
2449
+ return;
2450
+ }
2451
+ this.restoreSel();
2452
+ const s = window.getSelection();
2453
+ if (!s || s.isCollapsed) {
2454
+ document.execCommand('insertHTML', false, `<a href="${url.replace(/"/g, '&quot;')}">${url
2455
+ .replace(/&/g, '&amp;')
2456
+ .replace(/</g, '&lt;')}</a>`);
2457
+ }
2458
+ else {
2459
+ document.execCommand('styleWithCSS', false, 'false');
2460
+ document.execCommand('createLink', false, url);
2461
+ }
2462
+ this.saveSel();
2463
+ this.onChange();
2464
+ }
2465
+ });
2466
+ this.dialog(this.t('linkttl'), (body) => {
2467
+ const row = document.createElement('div');
2468
+ row.style.cssText = 'display:flex;align-items:center;gap:10px;margin:10px 0;min-width:360px';
2469
+ const lbl = document.createElement('span');
2470
+ lbl.style.cssText = 'width:60px;color:#556;flex:none';
2471
+ lbl.textContent = this.t('linkurl');
2472
+ urlIn = document.createElement('input');
2473
+ urlIn.type = 'url';
2474
+ urlIn.placeholder = 'https://';
2475
+ urlIn.style.cssText = 'flex:1;border:1px solid #cfd6df;border-radius:6px;padding:7px 9px;font:14px inherit';
2476
+ urlIn.value = anchorEl?.getAttribute('href') || '';
2477
+ row.append(lbl, urlIn);
2478
+ body.appendChild(row);
2479
+ setTimeout(() => urlIn.focus(), 0);
2480
+ }, buttons);
2481
+ }
2482
+ blocksMenu(anchor) {
2483
+ this.popup(anchor, (el) => {
2484
+ this.menuItems(el, BLOCKS.map(([tag, key]) => ({
2485
+ label: this.blockLabel(tag),
2486
+ previewStyle: tag.startsWith('h')
2487
+ ? `font-weight:700;font-size:${22 - 2 * (+tag[1])}px`
2488
+ : tag === 'pre'
2489
+ ? 'font-family:monospace'
2490
+ : '',
2491
+ on: this.currentBlock() === tag,
2492
+ action: () => this.exec('formatBlock', '<' + tag + '>')
2493
+ })));
2494
+ });
2495
+ }
2496
+ blockLabel(tag) {
2497
+ if (tag === 'p')
2498
+ return this.t('para');
2499
+ if (tag === 'pre')
2500
+ return this.t('pre');
2501
+ return this.t('heading') + ' ' + tag[1];
2502
+ }
2503
+ tableGrid(anchor, after) {
2504
+ this.popup(anchor, (el) => this.buildTableGridInto(el, after));
2505
+ }
2506
+ buildTableGridInto(el, after) {
2507
+ {
2508
+ const wrap = document.createElement('div');
2509
+ wrap.className = 'tgridwrap';
2510
+ const grid = document.createElement('div');
2511
+ grid.className = 'tgrid';
2512
+ const lbl = document.createElement('div');
2513
+ lbl.className = 'tgridlbl';
2514
+ lbl.textContent = '1 × 1';
2515
+ const cells = [];
2516
+ for (let r = 0; r < 8; r++)
2517
+ for (let c = 0; c < 10; c++) {
2518
+ const sp = document.createElement('span');
2519
+ sp.dataset.r = String(r);
2520
+ sp.dataset.c = String(c);
2521
+ sp.addEventListener('mouseenter', () => {
2522
+ cells.forEach((x) => x.classList.toggle('hot', +x.dataset.r <= r && +x.dataset.c <= c));
2523
+ lbl.textContent = c + 1 + ' × ' + (r + 1);
2524
+ });
2525
+ sp.addEventListener('click', () => {
2526
+ this.closePop();
2527
+ this.insertTable(r + 1, c + 1);
2528
+ after?.();
2529
+ });
2530
+ cells.push(sp);
2531
+ grid.appendChild(sp);
2532
+ }
2533
+ wrap.append(grid, lbl);
2534
+ el.appendChild(wrap);
2535
+ }
2536
+ }
2537
+ insertTable(rows, cols) {
2538
+ this.restoreSel();
2539
+ /* table-layout:fixed keeps the columns evenly sized while typing —
2540
+ text wraps at the column edge instead of widening the column */
2541
+ let html = '<table style="border-collapse:collapse;width:100%;table-layout:fixed"><tbody>';
2542
+ for (let r = 0; r < rows; r++) {
2543
+ html += '<tr>';
2544
+ for (let c = 0; c < cols; c++)
2545
+ html += '<td style="border:1px solid #b9c2cc"><br></td>';
2546
+ html += '</tr>';
2547
+ }
2548
+ html += '</tbody></table><p><br></p>';
2549
+ document.execCommand('insertHTML', false, html);
2550
+ this.saveSel();
2551
+ this.onChange();
2552
+ }
2553
+ setDir(dir) {
2554
+ this.restoreSel();
2555
+ const blocks = this.selectedBlocks();
2556
+ if (blocks.length)
2557
+ blocks.forEach((b) => b.setAttribute('dir', dir));
2558
+ else
2559
+ this.ed.setAttribute('dir', dir);
2560
+ this.saveSel();
2561
+ this.refreshState();
2562
+ this.onChange();
2563
+ }
2564
+ /** Splits a toolbar layout string into groups of item names: `|` separates visual
2565
+ * groups, whitespace separates items within a group. Empty groups/tokens (e.g. from
2566
+ * leading/trailing/doubled separators) are dropped. Pure function. */
2567
+ parseToolbarString(spec) {
2568
+ return spec
2569
+ .split('|')
2570
+ .map((group) => group.trim().split(/\s+/).filter(Boolean))
2571
+ .filter((group) => group.length > 0);
2572
+ }
2573
+ /** Maps every supported toolbar item name to a builder for that one control. Each
2574
+ * entry is a direct lift of what used to be an inline call in `buildToolbar()` — no
2575
+ * behavior changes, just named lookup. Only `forecolor` yields multiple elements
2576
+ * (the quick color chips); everything else yields one. */
2577
+ buildToolbarRegistry() {
2578
+ const tableBtn = this.tbtn(IC.tableic, this.t('quicktable'), () => this.tableGrid(tableBtn));
2579
+ return {
2580
+ undo: () => this.tbtn(IC.undo, this.t('undo'), () => this.exec('undo')),
2581
+ redo: () => this.tbtn(IC.redo, this.t('redo'), () => this.exec('redo')),
2582
+ preview: () => this.tbtn(IC.prevw, this.t('preview'), () => this.previewDlg()),
2583
+ print: () => this.tbtn(IC.printic, this.t('print'), () => this.printDoc()),
2584
+ importword: () => this.tbtn(IC.wordic, this.t('importword'), () => this.pickWordDoc()),
2585
+ revhistory: () => this.tbtn(IC.historyic, this.t('revhistory'), () => this.revisionDlg()),
2586
+ fontfamily: () => this.tsel('w-font', 'fontsel', '', (b) => this.fontMenu(b)),
2587
+ fontsize: () => this.tsel('w-size', 'sizesel', '', (b) => this.sizeMenu(b)),
2588
+ fontsizeincrease: () => this.stepBtn(1),
2589
+ fontsizedecrease: () => this.stepBtn(-1),
2590
+ bold: () => this.tbtn(IC.bold, this.t('bold'), () => this.exec('bold'), 'bold'),
2591
+ italic: () => this.tbtn(IC.italic, this.t('italic'), () => this.exec('italic'), 'italic'),
2592
+ underline: () => this.tbtn(IC.underline, this.t('underline'), () => this.exec('underline'), 'underline'),
2593
+ strikethrough: () => this.tbtn(IC.strikethrough, this.t('strikethrough'), () => this.exec('strikeThrough'), 'strikeThrough'),
2594
+ forecolor: () => this.colorChips(),
2595
+ backcolor: () => this.menuTbtn(IC.backcolor + '<span class="colorbar" data-cb="back"></span>', this.t('backcolor'), (b) => this.colorMenu(b, 'back')),
2596
+ alignleft: () => this.tbtn(IC.alignleft, this.t('alignleft'), () => this.exec('justifyLeft'), 'justifyLeft'),
2597
+ aligncenter: () => this.tbtn(IC.aligncenter, this.t('aligncenter'), () => this.exec('justifyCenter'), 'justifyCenter'),
2598
+ alignright: () => this.tbtn(IC.alignright, this.t('alignright'), () => this.exec('justifyRight'), 'justifyRight'),
2599
+ alignjustify: () => this.tbtn(IC.alignjustify, this.t('alignjustify'), () => this.exec('justifyFull'), 'justifyFull'),
2600
+ bullist: () => this.menuTbtn(IC.bullist, this.t('bullist'), (b) => this.listMenu(b, 'ul'), 'insertUnorderedList'),
2601
+ numlist: () => this.menuTbtn(IC.numlist, this.t('numlist'), (b) => this.listMenu(b, 'ol'), 'insertOrderedList'),
2602
+ outdent: () => this.tbtn(IC.outdent, this.t('outdent'), () => this.exec('outdent')),
2603
+ indent: () => this.tbtn(IC.indent, this.t('indent'), () => this.exec('indent')),
2604
+ link: () => this.tbtn(IC.link, this.t('linkttl'), () => this.linkDlg()),
2605
+ blockquote: () => this.tbtn(IC.quote, this.t('quote'), () => this.toggleBlock('blockquote'), 'blockquote'),
2606
+ changecase: () => this.menuTbtn(IC.caseic, this.t('changecase'), (b) => this.caseMenu(b)),
2607
+ lineheight: () => this.menuTbtn(IC.lineheight, this.t('lineheight'), (b) => this.lineHeightMenu(b)),
2608
+ wordspacing: () => this.menuTbtn(IC.wordspacing, this.t('wordspacing'), (b) => this.wordSpacingMenu(b)),
2609
+ removeformat: () => this.tbtn(IC.clearformatic, this.t('removeformat'), () => this.exec('removeFormat')),
2610
+ blocks: () => this.tsel('w-block', 'blocksel', this.t('para'), (b) => this.blocksMenu(b)),
2611
+ ltr: () => this.tbtn(IC.ltr, this.t('ltr'), () => this.setDir('ltr'), 'ltr'),
2612
+ rtl: () => this.tbtn(IC.rtl, this.t('rtl'), () => this.setDir('rtl'), 'rtl'),
2613
+ quickimage: () => this.tbtn(IC.image, this.t('quickimage'), () => this.insertImagePlaceholder()),
2614
+ quicktable: () => tableBtn,
2615
+ charmap: () => this.tbtn(IC.charic, this.t('charmap'), () => this.charMap()),
2616
+ fullscreen: () => this.tbtn(IC.fullscreen, this.t('fullscreen'), () => this.toggleFullscreen(), 'fullscreen'),
2617
+ sourcecode: () => this.tbtn(IC.srcic, this.t('sourcecode'), () => this.sourceDlg())
2618
+ };
2619
+ }
2620
+ buildToolbar() {
2621
+ this.toolbar.innerHTML = '';
2622
+ const spec = this.options.toolbar === true ? DEFAULT_TOOLBAR : this.options.toolbar;
2623
+ const registry = this.buildToolbarRegistry();
2624
+ this.parseToolbarString(spec).forEach((tokens) => {
2625
+ const elements = [];
2626
+ tokens.forEach((token) => {
2627
+ const builder = registry[token];
2628
+ if (!builder) {
2629
+ console.warn(`FableEditor: unknown toolbar item "${token}"`);
2630
+ return;
2631
+ }
2632
+ const result = builder();
2633
+ if (Array.isArray(result))
2634
+ elements.push(...result);
2635
+ else
2636
+ elements.push(result);
2637
+ });
2638
+ if (elements.length)
2639
+ this.toolbar.appendChild(this.group(...elements));
2640
+ });
2641
+ this.syncColorSwatches(this.toolbar);
2642
+ }
2643
+ /* ---------------------------------------------------------- menubar */
2644
+ mItem(label, icon, action, shortcut) {
2645
+ return { label: this.t(label), icon, action, shortcut };
2646
+ }
2647
+ buildMenubar() {
2648
+ this.menubar.innerHTML = '';
2649
+ const menus = {
2650
+ file: [
2651
+ this.mItem('newdoc', IC.newic, () => {
2652
+ this.recordRevision();
2653
+ this.ed.innerHTML = '<p><br></p>';
2654
+ this.onChange();
2655
+ }),
2656
+ '|',
2657
+ this.mItem('importword', IC.wordic, () => this.pickWordDoc()),
2658
+ this.mItem('restoredraft', IC.draftic, () => {
2659
+ if (!this.restoreDraft())
2660
+ alert(this.t('draftempty'));
2661
+ }),
2662
+ this.mItem('revhistory', IC.historyic, () => this.revisionDlg()),
2663
+ '|',
2664
+ this.mItem('preview', IC.prevw, () => this.previewDlg()),
2665
+ this.mItem('print', IC.printic, () => this.printDoc(), 'Ctrl+P')
2666
+ ],
2667
+ edit: [
2668
+ this.mItem('undo', IC.undo, () => this.exec('undo'), 'Ctrl+Z'),
2669
+ this.mItem('redo', IC.redo, () => this.exec('redo'), 'Ctrl+Y'),
2670
+ '|',
2671
+ this.mItem('cut', IC.cutic, () => this.exec('cut'), 'Ctrl+X'),
2672
+ this.mItem('copy', IC.copyic, () => this.exec('copy'), 'Ctrl+C'),
2673
+ this.mItem('paste', IC.pasteic, () => alert(this.t('pastehint')), 'Ctrl+V'),
2674
+ '|',
2675
+ this.mItem('selectall', IC.selall, () => this.exec('selectAll'), 'Ctrl+A')
2676
+ ],
2677
+ view: [
2678
+ this.mItem('fullscreen', IC.fullscreen, () => this.toggleFullscreen()),
2679
+ this.mItem('preview', IC.prevw, () => this.previewDlg()),
2680
+ this.mItem('sourcecode', IC.srcic, () => this.sourceDlg())
2681
+ ],
2682
+ insert: [
2683
+ this.mItem('link', IC.link, () => this.linkDlg()),
2684
+ this.mItem('image', IC.image, () => this.insertImagePlaceholder()),
2685
+ { label: this.t('inserttable'), icon: IC.tableic, action: () => this.tableGrid(this.menubar.querySelector('[data-menu-key="table"]') || this.menubar) },
2686
+ this.mItem('hr', IC.hric, () => this.exec('insertHorizontalRule')),
2687
+ '|',
2688
+ this.mItem('charmap', IC.charic, () => this.charMap()),
2689
+ this.mItem('datetime', IC.dateic, () => this.exec('insertText', new Date().toLocaleString(this.lang === 'ar' ? 'ar-AE' : 'en-GB'))),
2690
+ '|',
2691
+ {
2692
+ label: this.t('pagebreak'),
2693
+ icon: IC.pbic,
2694
+ action: () => {
2695
+ this.restoreSel();
2696
+ document.execCommand('insertHTML', false, '<hr class="pagebreak"><p><br></p>');
2697
+ this.onChange();
2698
+ }
2699
+ },
2700
+ this.mItem('nbsp', IC.nbspic, () => this.exec('insertText', '\u00A0'))
2701
+ ],
2702
+ format: [
2703
+ this.mItem('bold', IC.bold, () => this.exec('bold'), 'Ctrl+B'),
2704
+ this.mItem('italic', IC.italic, () => this.exec('italic'), 'Ctrl+I'),
2705
+ this.mItem('underline', IC.underline, () => this.exec('underline'), 'Ctrl+U'),
2706
+ this.mItem('strikethrough', IC.strikethrough, () => this.exec('strikeThrough')),
2707
+ this.mItem('superscript', IC.superscriptic, () => this.exec('superscript')),
2708
+ this.mItem('subscript', IC.subscriptic, () => this.exec('subscript')),
2709
+ '|',
2710
+ this.mItem('lowercase', IC.lowercaseic, () => this.transformCase('lower')),
2711
+ this.mItem('uppercase', IC.uppercaseic, () => this.transformCase('upper')),
2712
+ this.mItem('capitalize', IC.capitalizeic, () => this.transformCase('capitalize')),
2713
+ '|',
2714
+ this.mItem('clearformat', IC.clearformatic, () => this.exec('removeFormat'))
2715
+ ],
2716
+ tools: [this.mItem('sourcecode', IC.srcic, () => this.sourceDlg()), this.mItem('wordcount', IC.wcic, () => this.wordCountDlg())],
2717
+ table: [
2718
+ { label: this.t('inserttable'), icon: IC.tableic, subBuild: (el) => this.buildTableGridInto(el) },
2719
+ '|',
2720
+ {
2721
+ label: this.t('cell'),
2722
+ sub: [{ label: this.t('cellprops'), icon: IC.tableic, action: () => this.cellPropsDlg() }]
2723
+ },
2724
+ {
2725
+ label: this.t('row'),
2726
+ sub: [
2727
+ { label: this.t('rowabove'), icon: IC.rowbefore, action: () => this.tableOp('rowabove'), hover: (on) => this.setOpTarget(on ? 'row' : null) },
2728
+ { label: this.t('rowbelow'), icon: IC.rowafter, action: () => this.tableOp('rowbelow'), hover: (on) => this.setOpTarget(on ? 'row' : null) },
2729
+ { label: this.t('delrow'), icon: IC.rowdelete, action: () => this.tableOp('delrow'), hover: (on) => this.setOpTarget(on ? 'row' : null) },
2730
+ '|',
2731
+ { label: this.t('rowprops'), icon: IC.tableic, action: () => this.rowPropsDlg(), hover: (on) => this.setOpTarget(on ? 'row' : null) }
2732
+ ]
2733
+ },
2734
+ {
2735
+ label: this.t('column'),
2736
+ sub: [
2737
+ { label: this.t('colbefore'), icon: IC.colbefore, action: () => this.tableOp('colbefore'), hover: (on) => this.setOpTarget(on ? 'col' : null) },
2738
+ { label: this.t('colafter'), icon: IC.colafter, action: () => this.tableOp('colafter'), hover: (on) => this.setOpTarget(on ? 'col' : null) },
2739
+ { label: this.t('delcol'), icon: IC.coldelete, action: () => this.tableOp('delcol'), hover: (on) => this.setOpTarget(on ? 'col' : null) },
2740
+ '|',
2741
+ { label: this.t('colprops'), icon: IC.tableic, action: () => this.colPropsDlg(), hover: (on) => this.setOpTarget(on ? 'col' : null) }
2742
+ ]
2743
+ },
2744
+ '|',
2745
+ {
2746
+ label: this.t('tablepropsttl'),
2747
+ icon: IC.tableic,
2748
+ action: () => {
2749
+ const cell = this.currentCell();
2750
+ if (!cell) {
2751
+ alert(this.t('nocell'));
2752
+ return;
2753
+ }
2754
+ this.tblActive = cell.closest('table');
2755
+ this.tablePropsDlg();
2756
+ }
2757
+ },
2758
+ { label: this.t('deltable'), icon: IC.tabledelete, action: () => this.tableOp('deltable') }
2759
+ ],
2760
+ help: [this.mItem('helpttl', IC.helpic, () => this.helpDlg(), 'Alt+0')]
2761
+ };
2762
+ const spec = this.options.menubar === true ? DEFAULT_MENUBAR : this.options.menubar;
2763
+ const seen = new Set();
2764
+ spec
2765
+ .split(/\s+/)
2766
+ .filter(Boolean)
2767
+ .forEach((key) => {
2768
+ if (seen.has(key))
2769
+ return;
2770
+ seen.add(key);
2771
+ if (!menus[key]) {
2772
+ console.warn(`FableEditor: unknown menubar item "${key}"`);
2773
+ return;
2774
+ }
2775
+ const b = document.createElement('button');
2776
+ b.type = 'button';
2777
+ b.textContent = this.t(key);
2778
+ b.dataset.menuKey = key;
2779
+ b.addEventListener('mousedown', (e) => {
2780
+ e.preventDefault();
2781
+ this.saveSel();
2782
+ });
2783
+ b.addEventListener('click', () => this.popup(b, (el) => this.menuItems(el, menus[key])));
2784
+ this.menubar.appendChild(b);
2785
+ });
2786
+ }
2787
+ /* ---------------------------------------------------------- table ops */
2788
+ tableOp(op) {
2789
+ this.restoreSel();
2790
+ const s = window.getSelection();
2791
+ if (!s)
2792
+ return;
2793
+ const cell = this.closestBlock(s.anchorNode)?.closest('td,th') ||
2794
+ (s.anchorNode && s.anchorNode.nodeType === 1 ? s.anchorNode.closest?.('td,th') : null);
2795
+ if (!cell)
2796
+ return;
2797
+ const row = cell.parentElement;
2798
+ const table = cell.closest('table');
2799
+ const idx = Array.from(row.children).indexOf(cell);
2800
+ const newRow = () => {
2801
+ const r = row.cloneNode(false);
2802
+ Array.from(row.children).forEach((src) => {
2803
+ const td = document.createElement('td');
2804
+ /* carry the source cell's inline style so borders / padding /
2805
+ column widths survive on inserted rows */
2806
+ const st = src.getAttribute('style');
2807
+ if (st)
2808
+ td.setAttribute('style', st);
2809
+ td.style.removeProperty('background-color');
2810
+ td.innerHTML = '<br>';
2811
+ r.appendChild(td);
2812
+ });
2813
+ return r;
2814
+ };
2815
+ if (op === 'rowabove')
2816
+ row.before(newRow());
2817
+ if (op === 'rowbelow')
2818
+ row.after(newRow());
2819
+ if (op === 'delrow') {
2820
+ const body = row.parentElement;
2821
+ row.remove();
2822
+ if (!body.querySelector('tr'))
2823
+ table.remove();
2824
+ }
2825
+ if (op === 'colbefore' || op === 'colafter') {
2826
+ table.querySelectorAll('tr').forEach((tr) => {
2827
+ const ref = tr.children[Math.min(idx, tr.children.length - 1)];
2828
+ const td = document.createElement('td');
2829
+ /* carry the reference cell's inline style so borders / padding
2830
+ survive on inserted columns; width is dropped so the new
2831
+ column shares the table space instead of doubling it */
2832
+ const st = ref.getAttribute('style');
2833
+ if (st)
2834
+ td.setAttribute('style', st);
2835
+ td.style.removeProperty('width');
2836
+ td.style.removeProperty('background-color');
2837
+ td.innerHTML = '<br>';
2838
+ op === 'colbefore' ? ref.before(td) : ref.after(td);
2839
+ });
2840
+ }
2841
+ if (op === 'delcol') {
2842
+ table.querySelectorAll('tr').forEach((tr) => tr.children[idx]?.remove());
2843
+ if (!table.querySelector('td,th'))
2844
+ table.remove();
2845
+ }
2846
+ if (op === 'deltable')
2847
+ table.remove();
2848
+ this.onChange();
2849
+ this.positionTableHandles();
2850
+ }
2851
+ applyTableProps(table, vals) {
2852
+ if (vals.width)
2853
+ table.style.width = /^\d+$/.test(vals.width) ? vals.width + 'px' : vals.width;
2854
+ else
2855
+ table.style.removeProperty('width');
2856
+ const cells = Array.from(table.querySelectorAll('td,th'));
2857
+ if (vals.cellpadding !== '')
2858
+ cells.forEach((c) => (c.style.padding = vals.cellpadding + 'px'));
2859
+ else
2860
+ cells.forEach((c) => c.style.removeProperty('padding'));
2861
+ if (vals.cellspacing !== '') {
2862
+ table.style.borderCollapse = 'separate';
2863
+ table.style.borderSpacing = vals.cellspacing + 'px';
2864
+ }
2865
+ else {
2866
+ table.style.borderCollapse = 'collapse';
2867
+ table.style.removeProperty('border-spacing');
2868
+ }
2869
+ if (vals.border !== '' && +vals.border > 0) {
2870
+ table.style.border = vals.border + 'px solid #b9c2cc';
2871
+ cells.forEach((c) => (c.style.border = vals.border + 'px solid #b9c2cc'));
2872
+ }
2873
+ else {
2874
+ table.style.removeProperty('border');
2875
+ cells.forEach((c) => c.style.removeProperty('border'));
2876
+ }
2877
+ table.style.removeProperty('margin-left');
2878
+ table.style.removeProperty('margin-right');
2879
+ if (vals.align === 'center') {
2880
+ table.style.marginLeft = 'auto';
2881
+ table.style.marginRight = 'auto';
2882
+ }
2883
+ else if (vals.align === 'right') {
2884
+ table.style.marginLeft = 'auto';
2885
+ table.style.marginRight = '0';
2886
+ }
2887
+ else if (vals.align === 'left') {
2888
+ table.style.marginLeft = '0';
2889
+ table.style.marginRight = 'auto';
2890
+ }
2891
+ }
2892
+ tablePropsDlg() {
2893
+ const table = this.tblActive;
2894
+ if (!table)
2895
+ return;
2896
+ let wIn;
2897
+ let padIn;
2898
+ let spIn;
2899
+ let bIn;
2900
+ let alignSel;
2901
+ const rowStyle = 'display:flex;align-items:center;gap:10px;margin:10px 0';
2902
+ const lblStyle = 'width:120px;color:#556;flex:none';
2903
+ const inputStyle = 'flex:1;border:1px solid #cfd6df;border-radius:6px;padding:7px 9px;font:14px inherit';
2904
+ const firstCell = table.querySelector('td,th');
2905
+ const curWidth = table.style.width || '';
2906
+ const curPad = firstCell && firstCell.style.padding ? parseFloat(firstCell.style.padding) : '';
2907
+ const curSp = table.style.borderSpacing ? parseFloat(table.style.borderSpacing) : '';
2908
+ const curBorder = table.style.borderWidth ? parseFloat(table.style.borderWidth) : '';
2909
+ const ml = table.style.marginLeft;
2910
+ const mr = table.style.marginRight;
2911
+ const curAlign = ml === 'auto' && mr === 'auto' ? 'center' : ml === 'auto' ? 'right' : mr === 'auto' && ml === '0' ? 'left' : '';
2912
+ this.dialog(this.t('tablepropsttl'), (body) => {
2913
+ const mk = (labelKey, el) => {
2914
+ const row = document.createElement('div');
2915
+ row.style.cssText = rowStyle;
2916
+ const lbl = document.createElement('span');
2917
+ lbl.style.cssText = lblStyle;
2918
+ lbl.textContent = this.t(labelKey);
2919
+ row.append(lbl, el);
2920
+ body.appendChild(row);
2921
+ };
2922
+ wIn = document.createElement('input');
2923
+ wIn.type = 'text';
2924
+ wIn.style.cssText = inputStyle;
2925
+ wIn.placeholder = 'e.g. 600px or 100%';
2926
+ wIn.value = curWidth;
2927
+ mk('tblwidth', wIn);
2928
+ padIn = document.createElement('input');
2929
+ padIn.type = 'number';
2930
+ padIn.min = '0';
2931
+ padIn.style.cssText = inputStyle;
2932
+ padIn.value = String(curPad);
2933
+ mk('tblcellpadding', padIn);
2934
+ spIn = document.createElement('input');
2935
+ spIn.type = 'number';
2936
+ spIn.min = '0';
2937
+ spIn.style.cssText = inputStyle;
2938
+ spIn.value = String(curSp);
2939
+ mk('tblcellspacing', spIn);
2940
+ bIn = document.createElement('input');
2941
+ bIn.type = 'number';
2942
+ bIn.min = '0';
2943
+ bIn.style.cssText = inputStyle;
2944
+ bIn.value = String(curBorder);
2945
+ mk('tblborder', bIn);
2946
+ alignSel = document.createElement('select');
2947
+ alignSel.style.cssText = inputStyle;
2948
+ [
2949
+ ['', 'alignnone'],
2950
+ ['left', 'alignleft'],
2951
+ ['center', 'aligncenter'],
2952
+ ['right', 'alignright']
2953
+ ].forEach(([v, k]) => {
2954
+ const o = document.createElement('option');
2955
+ o.value = v;
2956
+ o.textContent = this.t(k);
2957
+ if (v === curAlign)
2958
+ o.selected = true;
2959
+ alignSel.appendChild(o);
2960
+ });
2961
+ mk('tblalign', alignSel);
2962
+ }, [
2963
+ { label: this.t('cancel'), action: () => this.closeDlg() },
2964
+ {
2965
+ label: this.t('save'),
2966
+ primary: true,
2967
+ action: () => {
2968
+ this.applyTableProps(table, {
2969
+ width: wIn.value.trim(),
2970
+ cellpadding: padIn.value.trim(),
2971
+ cellspacing: spIn.value.trim(),
2972
+ border: bIn.value.trim(),
2973
+ align: alignSel.value
2974
+ });
2975
+ this.closeDlg();
2976
+ this.onChange();
2977
+ this.positionTableHandles();
2978
+ }
2979
+ }
2980
+ ]);
2981
+ }
2982
+ /* ------------------------------------------- cell / row / column props */
2983
+ currentCell() {
2984
+ let node = this.savedRange ? this.savedRange.startContainer : null;
2985
+ if (!node || !this.ed.contains(node)) {
2986
+ const s = window.getSelection();
2987
+ node = s && this.ed.contains(s.anchorNode) ? s.anchorNode : null;
2988
+ }
2989
+ while (node && node !== this.ed) {
2990
+ if (node.nodeType === 1 && /^(TD|TH)$/.test(node.tagName)) {
2991
+ return node;
2992
+ }
2993
+ node = node.parentNode;
2994
+ }
2995
+ return null;
2996
+ }
2997
+ toHexColor(c) {
2998
+ if (/^#[0-9a-fA-F]{6}$/.test(c))
2999
+ return c;
3000
+ const m = /rgba?\((\d+)[,\s]+(\d+)[,\s]+(\d+)/.exec(c);
3001
+ if (!m)
3002
+ return '';
3003
+ return '#' + [m[1], m[2], m[3]].map((n) => (+n).toString(16).padStart(2, '0')).join('');
3004
+ }
3005
+ formRow(body, label, el) {
3006
+ const row = document.createElement('div');
3007
+ row.style.cssText = 'display:flex;align-items:center;gap:10px;margin:10px 0;min-width:360px';
3008
+ const lbl = document.createElement('span');
3009
+ lbl.style.cssText = 'width:120px;color:#556;flex:none';
3010
+ lbl.textContent = label;
3011
+ row.append(lbl, el);
3012
+ body.appendChild(row);
3013
+ }
3014
+ formInput(value, placeholder = '') {
3015
+ const i = document.createElement('input');
3016
+ i.type = 'text';
3017
+ i.style.cssText = 'flex:1;border:1px solid #cfd6df;border-radius:6px;padding:7px 9px;font:14px inherit;min-width:0';
3018
+ i.value = value;
3019
+ i.placeholder = placeholder;
3020
+ return i;
3021
+ }
3022
+ formSelect(options, cur) {
3023
+ const sel = document.createElement('select');
3024
+ sel.style.cssText = 'flex:1;border:1px solid #cfd6df;border-radius:6px;padding:7px 9px;font:14px inherit;background:#fff';
3025
+ options.forEach(([v, label]) => {
3026
+ const o = document.createElement('option');
3027
+ o.value = v;
3028
+ o.textContent = label;
3029
+ if (v === cur)
3030
+ o.selected = true;
3031
+ sel.appendChild(o);
3032
+ });
3033
+ return sel;
3034
+ }
3035
+ formBg(cur) {
3036
+ const wrap = document.createElement('div');
3037
+ wrap.style.cssText = 'flex:1;display:flex;align-items:center;gap:8px';
3038
+ const hex = this.toHexColor(cur);
3039
+ const color = document.createElement('input');
3040
+ color.type = 'color';
3041
+ color.value = hex || '#ffffff';
3042
+ color.style.cssText = 'width:36px;height:29px;border:1px solid #cfd6df;border-radius:4px;padding:1px;background:none;cursor:pointer';
3043
+ const lbl = document.createElement('label');
3044
+ lbl.style.cssText = 'display:flex;align-items:center;gap:6px;cursor:pointer;color:#556';
3045
+ const fill = document.createElement('input');
3046
+ fill.type = 'checkbox';
3047
+ fill.checked = !!hex;
3048
+ lbl.append(fill, document.createTextNode(this.t('fill')));
3049
+ color.addEventListener('input', () => (fill.checked = true));
3050
+ wrap.append(color, lbl);
3051
+ return { wrap, color, fill };
3052
+ }
3053
+ setOrClear(el, prop, val) {
3054
+ if (val)
3055
+ el.style.setProperty(prop, /^(width|height)$/.test(prop) && /^\d+$/.test(val) ? val + 'px' : val);
3056
+ else
3057
+ el.style.removeProperty(prop);
3058
+ }
3059
+ cellPropsDlg() {
3060
+ const cell = this.currentCell();
3061
+ if (!cell) {
3062
+ alert(this.t('nocell'));
3063
+ return;
3064
+ }
3065
+ let wIn;
3066
+ let hIn;
3067
+ let alignSel;
3068
+ let vSel;
3069
+ let bg;
3070
+ this.dialog(this.t('cellprops').replace('…', ''), (body) => {
3071
+ wIn = this.formInput(cell.style.width, 'e.g. 120px / 25%');
3072
+ this.formRow(body, this.t('tblwidth'), wIn);
3073
+ hIn = this.formInput(cell.style.height, 'e.g. 40px');
3074
+ this.formRow(body, this.t('tblheight'), hIn);
3075
+ alignSel = this.formSelect([
3076
+ ['', this.t('alignnone')],
3077
+ ['left', this.t('alignleft')],
3078
+ ['center', this.t('aligncenter')],
3079
+ ['right', this.t('alignright')]
3080
+ ], cell.style.textAlign || '');
3081
+ this.formRow(body, this.t('tblalign'), alignSel);
3082
+ vSel = this.formSelect([
3083
+ ['', this.t('alignnone')],
3084
+ ['top', this.t('valigntop')],
3085
+ ['middle', this.t('valignmiddle')],
3086
+ ['bottom', this.t('valignbottom')]
3087
+ ], cell.style.verticalAlign || '');
3088
+ this.formRow(body, this.t('valign'), vSel);
3089
+ bg = this.formBg(cell.style.backgroundColor || '');
3090
+ this.formRow(body, this.t('backcolor'), bg.wrap);
3091
+ }, [
3092
+ { label: this.t('cancel'), action: () => this.closeDlg() },
3093
+ {
3094
+ label: this.t('save'),
3095
+ primary: true,
3096
+ action: () => {
3097
+ this.setOrClear(cell, 'width', wIn.value.trim());
3098
+ this.setOrClear(cell, 'height', hIn.value.trim());
3099
+ this.setOrClear(cell, 'text-align', alignSel.value);
3100
+ this.setOrClear(cell, 'vertical-align', vSel.value);
3101
+ this.setOrClear(cell, 'background-color', bg.fill.checked ? bg.color.value : '');
3102
+ this.closeDlg();
3103
+ this.onChange();
3104
+ this.positionTableHandles();
3105
+ }
3106
+ }
3107
+ ]);
3108
+ }
3109
+ rowPropsDlg() {
3110
+ const cell = this.currentCell();
3111
+ if (!cell) {
3112
+ alert(this.t('nocell'));
3113
+ return;
3114
+ }
3115
+ const row = cell.parentElement;
3116
+ let hIn;
3117
+ let bg;
3118
+ this.dialog(this.t('rowprops').replace('…', ''), (body) => {
3119
+ hIn = this.formInput(row.style.height, 'e.g. 40px');
3120
+ this.formRow(body, this.t('tblheight'), hIn);
3121
+ bg = this.formBg(row.style.backgroundColor || '');
3122
+ this.formRow(body, this.t('backcolor'), bg.wrap);
3123
+ }, [
3124
+ { label: this.t('cancel'), action: () => this.closeDlg() },
3125
+ {
3126
+ label: this.t('save'),
3127
+ primary: true,
3128
+ action: () => {
3129
+ this.setOrClear(row, 'height', hIn.value.trim());
3130
+ this.setOrClear(row, 'background-color', bg.fill.checked ? bg.color.value : '');
3131
+ this.closeDlg();
3132
+ this.onChange();
3133
+ this.positionTableHandles();
3134
+ }
3135
+ }
3136
+ ]);
3137
+ }
3138
+ colPropsDlg() {
3139
+ const cell = this.currentCell();
3140
+ if (!cell) {
3141
+ alert(this.t('nocell'));
3142
+ return;
3143
+ }
3144
+ const row = cell.parentElement;
3145
+ const table = cell.closest('table');
3146
+ const idx = Array.from(row.children).indexOf(cell);
3147
+ const colCells = Array.from(table.rows)
3148
+ .map((r) => r.cells[idx])
3149
+ .filter(Boolean);
3150
+ let wIn;
3151
+ let bg;
3152
+ this.dialog(this.t('colprops').replace('…', ''), (body) => {
3153
+ wIn = this.formInput(cell.style.width, 'e.g. 120px / 25%');
3154
+ this.formRow(body, this.t('tblwidth'), wIn);
3155
+ bg = this.formBg(cell.style.backgroundColor || '');
3156
+ this.formRow(body, this.t('backcolor'), bg.wrap);
3157
+ }, [
3158
+ { label: this.t('cancel'), action: () => this.closeDlg() },
3159
+ {
3160
+ label: this.t('save'),
3161
+ primary: true,
3162
+ action: () => {
3163
+ colCells.forEach((c) => {
3164
+ this.setOrClear(c, 'width', wIn.value.trim());
3165
+ this.setOrClear(c, 'background-color', bg.fill.checked ? bg.color.value : '');
3166
+ });
3167
+ this.closeDlg();
3168
+ this.onChange();
3169
+ this.positionTableHandles();
3170
+ }
3171
+ }
3172
+ ]);
3173
+ }
3174
+ /* ---------------------------------------------------------- dialogs */
3175
+ closeDlg() {
3176
+ if (this.dlgOvl) {
3177
+ this.dlgOvl.remove();
3178
+ this.dlgOvl = null;
3179
+ }
3180
+ }
3181
+ dialog(title, bodyBuild, buttons) {
3182
+ this.closeDlg();
3183
+ this.closePop();
3184
+ this.dlgOvl = document.createElement('div');
3185
+ this.dlgOvl.className = 'ovl';
3186
+ const d = document.createElement('div');
3187
+ d.className = 'dlg';
3188
+ d.dir = this.dir();
3189
+ d.innerHTML = `<header><span>${title}</span><button type="button">×</button></header>
3190
+ <div class="body"></div><footer></footer>`;
3191
+ d.querySelector('header button').onclick = () => this.closeDlg();
3192
+ bodyBuild(d.querySelector('.body'));
3193
+ (buttons || [{ label: this.t('close'), primary: true, action: () => this.closeDlg() }]).forEach((b) => {
3194
+ const btn = document.createElement('button');
3195
+ btn.type = 'button';
3196
+ btn.textContent = b.label;
3197
+ if (b.primary)
3198
+ btn.className = 'pri';
3199
+ btn.onclick = b.action;
3200
+ d.querySelector('footer').appendChild(btn);
3201
+ });
3202
+ this.dlgOvl.appendChild(d);
3203
+ this.dlgOvl.addEventListener('mousedown', (e) => {
3204
+ if (e.target === this.dlgOvl)
3205
+ this.closeDlg();
3206
+ });
3207
+ document.body.appendChild(this.dlgOvl);
3208
+ }
3209
+ sourceDlg() {
3210
+ let ta;
3211
+ this.dialog(this.t('srcttl'), (body) => {
3212
+ ta = document.createElement('textarea');
3213
+ ta.value = this.ed.innerHTML.replace(/></g, '>\n<');
3214
+ body.appendChild(ta);
3215
+ }, [
3216
+ { label: this.t('cancel'), action: () => this.closeDlg() },
3217
+ {
3218
+ label: this.t('save'),
3219
+ primary: true,
3220
+ action: () => {
3221
+ this.ed.innerHTML = ta.value;
3222
+ this.closeDlg();
3223
+ this.onChange();
3224
+ }
3225
+ }
3226
+ ]);
3227
+ }
3228
+ helpDlg() {
3229
+ this.dialog(this.t('helpttl'), (body) => {
3230
+ const rows = [
3231
+ ['Ctrl+B', this.t('bold')],
3232
+ ['Ctrl+I', this.t('italic')],
3233
+ ['Ctrl+U', this.t('underline')],
3234
+ ['Ctrl+Z', this.t('undo')],
3235
+ ['Ctrl+Y', this.t('redo')],
3236
+ ['Ctrl+A', this.t('selectall')],
3237
+ ['Alt+0', this.t('helpttl')]
3238
+ ];
3239
+ body.innerHTML = `<p style="color:#556">${this.t('shortcuts')}</p>
3240
+ <table class="kbd">${rows.map((r) => `<tr><td>${r[1]}</td><td><kbd>${r[0]}</kbd></td></tr>`).join('')}</table>`;
3241
+ });
3242
+ }
3243
+ wordCountDlg() {
3244
+ this.dialog(this.t('wcttl'), (body) => {
3245
+ body.innerHTML = `<table class="kbd">
3246
+ <tr><td>${this.t('wcwords')}</td><td>${this.countWords()}</td></tr>
3247
+ <tr><td>${this.t('wcchars')}</td><td>${this.ed.textContent?.replace(/\s/g, '').length ?? 0}</td></tr></table>`;
3248
+ });
3249
+ }
3250
+ previewDlg() {
3251
+ this.dialog(this.t('previewttl'), (body) => {
3252
+ const box = document.createElement('div');
3253
+ box.style.cssText =
3254
+ 'width:640px;max-width:78vw;max-height:52vh;overflow:auto;border:1px solid #e3e3e3;border-radius:6px;padding:14px;font-family:Helvetica,Arial,sans-serif;font-size:14px';
3255
+ box.innerHTML = this.ed.innerHTML;
3256
+ body.appendChild(box);
3257
+ });
3258
+ }
3259
+ charMap() {
3260
+ this.dialog(this.t('charmap').replace('…', ''), (body) => {
3261
+ const g = document.createElement('div');
3262
+ g.className = 'chgrid';
3263
+ CHARS.forEach((ch) => {
3264
+ const b = document.createElement('button');
3265
+ b.textContent = ch;
3266
+ b.onclick = () => {
3267
+ this.closeDlg();
3268
+ this.exec('insertText', ch);
3269
+ };
3270
+ g.appendChild(b);
3271
+ });
3272
+ body.appendChild(g);
3273
+ });
3274
+ }
3275
+ printDoc() {
3276
+ const w = window.open('', '_blank');
3277
+ if (!w)
3278
+ return;
3279
+ w.document.write(`<html dir="${this.dir()}"><body style="font-family:Helvetica,Arial,sans-serif;font-size:14px">${this.ed.innerHTML}</body></html>`);
3280
+ w.document.close();
3281
+ w.focus();
3282
+ w.print();
3283
+ }
3284
+ pickImage() {
3285
+ this.imgInput.value = '';
3286
+ this.imgInput.click();
3287
+ }
3288
+ /* ---------------------------------------------- image upload placeholder */
3289
+ insertImagePlaceholder() {
3290
+ this.restoreSel();
3291
+ const id = 'img-ph-' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
3292
+ document.execCommand('insertHTML', false, `<div class="img-ph" id="${id}" contenteditable="false">${IC.image}<span>${this.t('dropimage')}</span></div>`);
3293
+ const ph = this.ed.querySelector('#' + id);
3294
+ if (ph) {
3295
+ ph.removeAttribute('id');
3296
+ this.selectImgPlaceholder(ph);
3297
+ }
3298
+ this.onChange();
3299
+ }
3300
+ clearImgPlaceholderSel() {
3301
+ this.phCtx?.remove();
3302
+ this.phCtx = null;
3303
+ this.phActive?.classList.remove('active');
3304
+ this.phActive = null;
3305
+ }
3306
+ selectImgPlaceholder(ph) {
3307
+ if (this.phActive === ph)
3308
+ return;
3309
+ this.clearImgPlaceholderSel();
3310
+ this.phActive = ph;
3311
+ ph.classList.add('active');
3312
+ this.phCtx = document.createElement('div');
3313
+ this.phCtx.className = 'imgctx img-ph-ctx';
3314
+ this.phCtx.dir = this.dir();
3315
+ document.body.appendChild(this.phCtx);
3316
+ this.renderPhCtxButtons();
3317
+ }
3318
+ renderPhCtxButtons() {
3319
+ const el = this.phCtx;
3320
+ if (!el)
3321
+ return;
3322
+ el.innerHTML = '';
3323
+ el.append(this.ctxBtn(IC.uploadic, this.t('uploadimg'), () => {
3324
+ this.phUploadTarget = this.phActive;
3325
+ this.pickImage();
3326
+ }), this.ctxBtn(IC.link, this.t('imagelink'), () => this.renderPhCtxUrlInput()));
3327
+ this.positionImgPhCtx();
3328
+ }
3329
+ renderPhCtxUrlInput() {
3330
+ const el = this.phCtx;
3331
+ if (!el)
3332
+ return;
3333
+ el.innerHTML = '';
3334
+ const inp = document.createElement('input');
3335
+ inp.type = 'url';
3336
+ inp.className = 'urlinp';
3337
+ inp.placeholder = this.t('imageurlph');
3338
+ const ok = document.createElement('button');
3339
+ ok.type = 'button';
3340
+ ok.className = 'txtbtn';
3341
+ ok.textContent = this.t('insertimg');
3342
+ const submit = () => {
3343
+ const url = inp.value.trim();
3344
+ const ph = this.phActive;
3345
+ if (!ph)
3346
+ return;
3347
+ if (!/^(https?:|data:image\/|blob:)/i.test(url)) {
3348
+ inp.classList.add('err');
3349
+ inp.focus();
3350
+ return;
3351
+ }
3352
+ ok.disabled = true;
3353
+ inp.disabled = true;
3354
+ /* only place the image once the URL actually loads, so the user
3355
+ gets a working preview instead of a broken-image icon */
3356
+ const probe = new Image();
3357
+ probe.onload = () => this.replacePlaceholderWithImage(ph, url, '');
3358
+ probe.onerror = () => {
3359
+ ok.disabled = false;
3360
+ inp.disabled = false;
3361
+ inp.classList.add('err');
3362
+ inp.focus();
3363
+ };
3364
+ probe.src = url;
3365
+ };
3366
+ ok.addEventListener('click', submit);
3367
+ inp.addEventListener('input', () => inp.classList.remove('err'));
3368
+ inp.addEventListener('keydown', (e) => {
3369
+ if (e.key === 'Enter') {
3370
+ e.preventDefault();
3371
+ submit();
3372
+ }
3373
+ else if (e.key === 'Escape') {
3374
+ e.stopPropagation();
3375
+ this.renderPhCtxButtons();
3376
+ }
3377
+ });
3378
+ el.append(inp, ok);
3379
+ this.positionImgPhCtx();
3380
+ inp.focus();
3381
+ }
3382
+ readImageFileInto(file, ph) {
3383
+ if (ph.classList.contains('uploading'))
3384
+ return;
3385
+ if (this.imageUploadHandler) {
3386
+ this.uploadImageFile(file, ph);
3387
+ return;
3388
+ }
3389
+ const fr = new FileReader();
3390
+ fr.onload = () => {
3391
+ if (this.ed.contains(ph))
3392
+ this.replacePlaceholderWithImage(ph, fr.result, file.name.replace(/"/g, ''));
3393
+ };
3394
+ fr.readAsDataURL(file);
3395
+ }
3396
+ /** Runs the host-supplied imageUploadHandler instead of inlining base64,
3397
+ * so apps can route the file to S3 / Azure Blob / their own server. */
3398
+ uploadImageFile(file, ph) {
3399
+ const title = file.name.replace(/"/g, '');
3400
+ ph.classList.remove('upload-error');
3401
+ ph.classList.add('uploading');
3402
+ const span = ph.querySelector('span');
3403
+ if (span)
3404
+ span.textContent = this.t('uploading');
3405
+ this.imageUploadHandler(file)
3406
+ .then((url) => {
3407
+ if (!this.ed.contains(ph))
3408
+ return;
3409
+ this.replacePlaceholderWithImage(ph, url, title);
3410
+ })
3411
+ .catch((err) => {
3412
+ if (!this.ed.contains(ph))
3413
+ return;
3414
+ ph.classList.remove('uploading');
3415
+ ph.classList.add('upload-error');
3416
+ if (span)
3417
+ span.textContent = this.t('uploadfailed');
3418
+ this.onImageUploadError?.(err, file);
3419
+ });
3420
+ }
3421
+ replacePlaceholderWithImage(ph, src, title) {
3422
+ const img = document.createElement('img');
3423
+ img.src = src;
3424
+ img.alt = '';
3425
+ if (title)
3426
+ img.title = title;
3427
+ ph.replaceWith(img);
3428
+ if (this.phActive === ph)
3429
+ this.clearImgPlaceholderSel();
3430
+ this.refreshState();
3431
+ this.onChange();
3432
+ }
3433
+ positionImgPhCtx() {
3434
+ if (this.phActive && !document.body.contains(this.phActive)) {
3435
+ this.clearImgPlaceholderSel();
3436
+ return;
3437
+ }
3438
+ if (!this.phActive || !this.phCtx)
3439
+ return;
3440
+ const r = this.phActive.getBoundingClientRect();
3441
+ const cw = this.phCtx.offsetWidth;
3442
+ const ch = this.phCtx.offsetHeight;
3443
+ let cx = r.left + scrollX + (r.width - cw) / 2;
3444
+ cx = Math.max(8 + scrollX, Math.min(cx, scrollX + innerWidth - cw - 8));
3445
+ let cy = r.bottom + scrollY + 8;
3446
+ if (r.bottom + 8 + ch > innerHeight - 4)
3447
+ cy = Math.max(this.ctxMinTop(), r.top + scrollY - ch - 8);
3448
+ this.phCtx.style.left = cx + 'px';
3449
+ this.phCtx.style.top = cy + 'px';
3450
+ }
3451
+ pickWordDoc() {
3452
+ this.docInput.value = '';
3453
+ this.docInput.click();
3454
+ }
3455
+ /* ---------------------------------------------------------- fullscreen */
3456
+ toggleFullscreen() {
3457
+ this.shell.classList.toggle('fullscreen');
3458
+ const b = this.toolbar.querySelector('[data-id=fullscreen]');
3459
+ b?.classList.toggle('on', this.shell.classList.contains('fullscreen'));
3460
+ this.positionTableHandles();
3461
+ }
3462
+ /* ---------------------------------------------------------- table resize */
3463
+ clearTableHandles() {
3464
+ this.tblCorner?.remove();
3465
+ this.tblCorner = null;
3466
+ this.tblColBars.forEach((b) => b.remove());
3467
+ this.tblColBars = [];
3468
+ this.tblCtx?.remove();
3469
+ this.tblCtx = null;
3470
+ this.tblCellMark?.remove();
3471
+ this.tblCellMark = null;
3472
+ this.tblOpMark?.remove();
3473
+ this.tblOpMark = null;
3474
+ this.tblOpMarkKind = null;
3475
+ this.tblActive?.classList.remove('tbl-selected');
3476
+ this.tblActive = null;
3477
+ }
3478
+ /** Blue outline around the cell that holds the caret, so it is clear
3479
+ which cell / row / column the table operations will target. */
3480
+ positionCellMarker() {
3481
+ if (!this.tblCellMark)
3482
+ return;
3483
+ const cell = this.currentCell();
3484
+ if (!cell || !this.tblActive || cell.closest('table') !== this.tblActive) {
3485
+ this.tblCellMark.style.display = 'none';
3486
+ return;
3487
+ }
3488
+ const r = cell.getBoundingClientRect();
3489
+ this.tblCellMark.style.display = 'block';
3490
+ this.tblCellMark.style.left = r.left + scrollX + 'px';
3491
+ this.tblCellMark.style.top = r.top + scrollY + 'px';
3492
+ this.tblCellMark.style.width = r.width + 'px';
3493
+ this.tblCellMark.style.height = r.height + 'px';
3494
+ }
3495
+ /** Dashed highlight over the whole row / column an operation targets,
3496
+ shown while its button or menu item is hovered. */
3497
+ setOpTarget(kind) {
3498
+ this.tblOpMarkKind = kind;
3499
+ this.positionOpMark();
3500
+ }
3501
+ positionOpMark() {
3502
+ const kind = this.tblOpMarkKind;
3503
+ const cell = kind ? this.currentCell() : null;
3504
+ if (!kind || !cell || !this.tblActive || cell.closest('table') !== this.tblActive) {
3505
+ this.tblOpMark?.remove();
3506
+ this.tblOpMark = null;
3507
+ return;
3508
+ }
3509
+ if (!this.tblOpMark) {
3510
+ this.tblOpMark = document.createElement('div');
3511
+ this.tblOpMark.className = 'tbl-opmark';
3512
+ document.body.appendChild(this.tblOpMark);
3513
+ }
3514
+ const tr = this.tblActive.getBoundingClientRect();
3515
+ const cr = cell.getBoundingClientRect();
3516
+ const rr = cell.parentElement.getBoundingClientRect();
3517
+ const left = kind === 'row' ? rr.left : cr.left;
3518
+ const top = kind === 'row' ? rr.top : tr.top;
3519
+ const width = kind === 'row' ? rr.width : cr.width;
3520
+ const height = kind === 'row' ? rr.height : tr.height;
3521
+ this.tblOpMark.style.left = left + scrollX + 'px';
3522
+ this.tblOpMark.style.top = top + scrollY + 'px';
3523
+ this.tblOpMark.style.width = width + 'px';
3524
+ this.tblOpMark.style.height = height + 'px';
3525
+ }
3526
+ /** Lowest allowed top for floating context toolbars — keeps them from
3527
+ covering the editor menubar/toolbar when the target sits on the first line. */
3528
+ ctxMinTop() {
3529
+ return Math.max(scrollY + 4, this.ed.getBoundingClientRect().top + scrollY - 2);
3530
+ }
3531
+ ctxBtn(icon, tip, fn, id) {
3532
+ const b = document.createElement('button');
3533
+ b.type = 'button';
3534
+ b.title = tip;
3535
+ b.innerHTML = icon;
3536
+ if (id)
3537
+ b.dataset.id = id;
3538
+ b.addEventListener('mousedown', (e) => e.preventDefault());
3539
+ b.addEventListener('click', fn);
3540
+ return b;
3541
+ }
3542
+ ctxSep() {
3543
+ const s = document.createElement('div');
3544
+ s.className = 'sep';
3545
+ return s;
3546
+ }
3547
+ buildTableCtxToolbar() {
3548
+ const el = document.createElement('div');
3549
+ el.className = 'tblctx';
3550
+ el.dir = this.dir();
3551
+ /* hovering a row/column button highlights the row/column it targets */
3552
+ const hl = (b, kind) => {
3553
+ b.addEventListener('mouseenter', () => this.setOpTarget(kind));
3554
+ b.addEventListener('mouseleave', () => this.setOpTarget(null));
3555
+ return b;
3556
+ };
3557
+ el.append(hl(this.ctxBtn(IC.rowbefore, this.t('rowabove'), () => this.tableOp('rowabove')), 'row'), hl(this.ctxBtn(IC.rowafter, this.t('rowbelow'), () => this.tableOp('rowbelow')), 'row'), hl(this.ctxBtn(IC.rowdelete, this.t('delrow'), () => this.tableOp('delrow')), 'row'), this.ctxSep(), hl(this.ctxBtn(IC.colbefore, this.t('colbefore'), () => this.tableOp('colbefore')), 'col'), hl(this.ctxBtn(IC.colafter, this.t('colafter'), () => this.tableOp('colafter')), 'col'), hl(this.ctxBtn(IC.coldelete, this.t('delcol'), () => this.tableOp('delcol')), 'col'), this.ctxSep(), this.ctxBtn(IC.tabledelete, this.t('deltable'), () => this.tableOp('deltable')), this.ctxBtn(IC.tableic, this.t('tablepropsttl'), () => this.tablePropsDlg()));
3558
+ document.body.appendChild(el);
3559
+ return el;
3560
+ }
3561
+ positionTableHandles() {
3562
+ if (!this.tblActive || !document.body.contains(this.tblActive)) {
3563
+ this.clearTableHandles();
3564
+ return;
3565
+ }
3566
+ const rtl = this.ed.getAttribute('dir') === 'rtl';
3567
+ const r = this.tblActive.getBoundingClientRect();
3568
+ const top = r.top + scrollY;
3569
+ const left = r.left + scrollX;
3570
+ const right = r.right + scrollX;
3571
+ const bottom = r.bottom + scrollY;
3572
+ if (this.tblCorner) {
3573
+ this.tblCorner.style.top = bottom - 5 + 'px';
3574
+ this.tblCorner.style.left = (rtl ? left - 5 : right - 5) + 'px';
3575
+ this.tblCorner.style.cursor = rtl ? 'nesw-resize' : 'nwse-resize';
3576
+ }
3577
+ if (this.tblCtx) {
3578
+ const cw = this.tblCtx.offsetWidth;
3579
+ const ch = this.tblCtx.offsetHeight;
3580
+ let cx = left + (right - left) / 2 - cw / 2;
3581
+ cx = Math.max(8 + scrollX, Math.min(cx, scrollX + innerWidth - cw - 8));
3582
+ let cy = top - ch - 8;
3583
+ if (cy < this.ctxMinTop())
3584
+ cy = bottom + 8;
3585
+ this.tblCtx.style.left = cx + 'px';
3586
+ this.tblCtx.style.top = cy + 'px';
3587
+ }
3588
+ this.tblColBars.forEach((b) => b.remove());
3589
+ this.tblColBars = [];
3590
+ const row = this.tblActive.rows[0];
3591
+ if (row) {
3592
+ for (let i = 0; i < row.cells.length - 1; i++) {
3593
+ const rA = row.cells[i].getBoundingClientRect();
3594
+ const boundaryX = (rtl ? rA.left : rA.right) + scrollX;
3595
+ const bar = document.createElement('div');
3596
+ bar.className = 'tbl-colbar';
3597
+ bar.style.top = top + 'px';
3598
+ bar.style.left = boundaryX + 'px';
3599
+ bar.style.height = bottom - top + 'px';
3600
+ document.body.appendChild(bar);
3601
+ bar.addEventListener('mousedown', (e) => this.startColBarDrag(e, i));
3602
+ this.tblColBars.push(bar);
3603
+ }
3604
+ }
3605
+ this.positionCellMarker();
3606
+ this.positionOpMark();
3607
+ }
3608
+ selectTableForResize(table) {
3609
+ if (this.tblActive === table)
3610
+ return;
3611
+ this.clearTableHandles();
3612
+ this.tblActive = table;
3613
+ table.classList.add('tbl-selected');
3614
+ this.tblCorner = document.createElement('div');
3615
+ this.tblCorner.className = 'tbl-handle';
3616
+ document.body.appendChild(this.tblCorner);
3617
+ this.tblCorner.addEventListener('mousedown', (e) => this.startTableCornerDrag(e));
3618
+ this.tblCellMark = document.createElement('div');
3619
+ this.tblCellMark.className = 'tbl-cellmark';
3620
+ this.tblCellMark.style.display = 'none';
3621
+ document.body.appendChild(this.tblCellMark);
3622
+ this.tblCtx = this.buildTableCtxToolbar();
3623
+ this.positionTableHandles();
3624
+ }
3625
+ withNoTextSelect(fn) {
3626
+ const prev = document.body.style.userSelect;
3627
+ document.body.style.userSelect = 'none';
3628
+ fn(() => {
3629
+ document.body.style.userSelect = prev;
3630
+ });
3631
+ }
3632
+ startTableCornerDrag(e) {
3633
+ e.preventDefault();
3634
+ const table = this.tblActive;
3635
+ if (!table)
3636
+ return;
3637
+ const rtl = this.ed.getAttribute('dir') === 'rtl';
3638
+ const startX = e.clientX;
3639
+ const startW = table.getBoundingClientRect().width;
3640
+ table.style.tableLayout = table.style.tableLayout || 'fixed';
3641
+ this.withNoTextSelect((restore) => {
3642
+ const mv = (ev) => {
3643
+ const dx = rtl ? -(ev.clientX - startX) : ev.clientX - startX;
3644
+ table.style.width = Math.max(60, Math.round(startW + dx)) + 'px';
3645
+ this.positionTableHandles();
3646
+ };
3647
+ const up = () => {
3648
+ window.removeEventListener('mousemove', mv);
3649
+ window.removeEventListener('mouseup', up);
3650
+ restore();
3651
+ this.onChange();
3652
+ };
3653
+ window.addEventListener('mousemove', mv);
3654
+ window.addEventListener('mouseup', up);
3655
+ });
3656
+ }
3657
+ startColBarDrag(e, i) {
3658
+ e.preventDefault();
3659
+ e.stopPropagation();
3660
+ const table = this.tblActive;
3661
+ if (!table)
3662
+ return;
3663
+ const rtl = this.ed.getAttribute('dir') === 'rtl';
3664
+ const rows = Array.from(table.rows);
3665
+ const colA = rows.map((r) => r.cells[i]).filter(Boolean);
3666
+ const colB = rows.map((r) => r.cells[i + 1]).filter(Boolean);
3667
+ if (!colA.length || !colB.length)
3668
+ return;
3669
+ const startX = e.clientX;
3670
+ const wA = colA[0].getBoundingClientRect().width;
3671
+ const wB = colB[0].getBoundingClientRect().width;
3672
+ table.style.tableLayout = 'fixed';
3673
+ this.withNoTextSelect((restore) => {
3674
+ const mv = (ev) => {
3675
+ const dx = (rtl ? -1 : 1) * (ev.clientX - startX);
3676
+ colA.forEach((c) => (c.style.width = Math.max(24, Math.round(wA + dx)) + 'px'));
3677
+ colB.forEach((c) => (c.style.width = Math.max(24, Math.round(wB - dx)) + 'px'));
3678
+ this.positionTableHandles();
3679
+ };
3680
+ const up = () => {
3681
+ window.removeEventListener('mousemove', mv);
3682
+ window.removeEventListener('mouseup', up);
3683
+ restore();
3684
+ this.onChange();
3685
+ };
3686
+ window.addEventListener('mousemove', mv);
3687
+ window.addEventListener('mouseup', up);
3688
+ });
3689
+ }
3690
+ /* ---------------------------------------------------------- image toolbar / resize */
3691
+ cancelImgHide() {
3692
+ if (this.imgHideTimer != null) {
3693
+ window.clearTimeout(this.imgHideTimer);
3694
+ this.imgHideTimer = null;
3695
+ }
3696
+ }
3697
+ scheduleImgHide() {
3698
+ this.cancelImgHide();
3699
+ this.imgHideTimer = window.setTimeout(() => this.clearImageHandles(), 250);
3700
+ }
3701
+ clearImageHandles() {
3702
+ this.cancelImgHide();
3703
+ this.imgHandles.forEach((h) => h.remove());
3704
+ this.imgHandles = [];
3705
+ this.imgCtx?.remove();
3706
+ this.imgCtx = null;
3707
+ this.imgActive?.classList.remove('img-selected');
3708
+ this.imgActive = null;
3709
+ }
3710
+ selectImage(img) {
3711
+ if (this.imgActive === img)
3712
+ return;
3713
+ this.clearImageHandles();
3714
+ this.imgActive = img;
3715
+ img.classList.add('img-selected');
3716
+ this.imgHandles = IMG_CORNERS.map((corner) => {
3717
+ const h = document.createElement('div');
3718
+ h.className = 'img-handle img-handle-' + corner;
3719
+ h.dataset.corner = corner;
3720
+ h.addEventListener('mousedown', (e) => this.startImageResizeDrag(e, corner));
3721
+ h.addEventListener('mouseenter', () => this.cancelImgHide());
3722
+ h.addEventListener('mouseleave', () => this.scheduleImgHide());
3723
+ document.body.appendChild(h);
3724
+ return h;
3725
+ });
3726
+ this.imgCtx = this.buildImageCtxToolbar(img);
3727
+ this.positionImageHandles();
3728
+ }
3729
+ buildImageCtxToolbar(img) {
3730
+ const el = document.createElement('div');
3731
+ el.className = 'imgctx';
3732
+ el.dir = this.dir();
3733
+ const rebuild = () => {
3734
+ const rebuilt = this.buildImageCtxToolbar(img);
3735
+ this.imgCtx?.replaceWith(rebuilt);
3736
+ this.imgCtx = rebuilt;
3737
+ this.positionImageHandles();
3738
+ };
3739
+ const align = (a) => {
3740
+ this.setImageAlign(img, a);
3741
+ rebuild();
3742
+ };
3743
+ const isLeft = img.style.float === 'left';
3744
+ const isRight = img.style.float === 'right';
3745
+ const isCenter = !isLeft && !isRight && img.style.marginLeft === 'auto' && img.style.marginRight === 'auto';
3746
+ const mkBtn = (icon, tip, on, fn) => {
3747
+ const b = this.ctxBtn(icon, tip, fn);
3748
+ b.classList.toggle('on', on);
3749
+ return b;
3750
+ };
3751
+ 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.rotateleft, this.t('rotateleft'), false, () => {
3752
+ this.rotateImage(img, -90);
3753
+ rebuild();
3754
+ }), mkBtn(IC.rotateright, this.t('rotateright'), false, () => {
3755
+ this.rotateImage(img, 90);
3756
+ rebuild();
3757
+ }), this.ctxSep(), mkBtn(IC.fliphorizontal, this.t('fliphorizontal'), img.dataset.flipx === '1', () => {
3758
+ this.flipImage(img, 'x');
3759
+ rebuild();
3760
+ }), mkBtn(IC.flipvertical, this.t('flipvertical'), img.dataset.flipy === '1', () => {
3761
+ this.flipImage(img, 'y');
3762
+ rebuild();
3763
+ }), this.ctxSep(), mkBtn(IC.trash, this.t('deleteimg'), false, () => {
3764
+ img.remove();
3765
+ this.clearImageHandles();
3766
+ this.onChange();
3767
+ }));
3768
+ el.addEventListener('mousedown', (e) => e.preventDefault());
3769
+ el.addEventListener('mouseenter', () => this.cancelImgHide());
3770
+ el.addEventListener('mouseleave', () => this.scheduleImgHide());
3771
+ document.body.appendChild(el);
3772
+ return el;
3773
+ }
3774
+ setImageAlign(img, align) {
3775
+ img.style.float = '';
3776
+ img.style.display = '';
3777
+ img.style.marginLeft = '';
3778
+ img.style.marginRight = '';
3779
+ img.style.marginBottom = '';
3780
+ if (align === 'left') {
3781
+ img.style.float = 'left';
3782
+ img.style.marginInlineEnd = '1em';
3783
+ img.style.marginBottom = '0.5em';
3784
+ }
3785
+ else if (align === 'right') {
3786
+ img.style.float = 'right';
3787
+ img.style.marginInlineStart = '1em';
3788
+ img.style.marginBottom = '0.5em';
3789
+ }
3790
+ else {
3791
+ img.style.display = 'block';
3792
+ img.style.marginLeft = 'auto';
3793
+ img.style.marginRight = 'auto';
3794
+ }
3795
+ this.onChange();
3796
+ }
3797
+ applyImgTransform(img) {
3798
+ const rot = parseInt(img.dataset.rotate || '0', 10);
3799
+ const fx = img.dataset.flipx === '1' ? -1 : 1;
3800
+ const fy = img.dataset.flipy === '1' ? -1 : 1;
3801
+ img.style.transform = rot || fx < 0 || fy < 0 ? `rotate(${rot}deg) scale(${fx}, ${fy})` : '';
3802
+ }
3803
+ rotateImage(img, delta) {
3804
+ const rot = (((parseInt(img.dataset.rotate || '0', 10) + delta) % 360) + 360) % 360;
3805
+ img.dataset.rotate = String(rot);
3806
+ this.applyImgTransform(img);
3807
+ this.positionImageHandles();
3808
+ this.onChange();
3809
+ }
3810
+ flipImage(img, axis) {
3811
+ const key = axis === 'x' ? 'flipx' : 'flipy';
3812
+ img.dataset[key] = img.dataset[key] === '1' ? '' : '1';
3813
+ this.applyImgTransform(img);
3814
+ this.positionImageHandles();
3815
+ this.onChange();
3816
+ }
3817
+ positionImageHandles() {
3818
+ if (!this.imgActive || !document.body.contains(this.imgActive)) {
3819
+ this.clearImageHandles();
3820
+ return;
3821
+ }
3822
+ const r = this.imgActive.getBoundingClientRect();
3823
+ const top = r.top + scrollY;
3824
+ const left = r.left + scrollX;
3825
+ const right = r.right + scrollX;
3826
+ const bottom = r.bottom + scrollY;
3827
+ const cornerPos = {
3828
+ nw: [left, top],
3829
+ ne: [right, top],
3830
+ sw: [left, bottom],
3831
+ se: [right, bottom]
3832
+ };
3833
+ this.imgHandles.forEach((h) => {
3834
+ const [x, y] = cornerPos[h.dataset.corner];
3835
+ h.style.left = x - 5 + 'px';
3836
+ h.style.top = y - 5 + 'px';
3837
+ });
3838
+ if (this.imgCtx) {
3839
+ const cw = this.imgCtx.offsetWidth;
3840
+ const ch = this.imgCtx.offsetHeight;
3841
+ let cx = left + (right - left) / 2 - cw / 2;
3842
+ cx = Math.max(8 + scrollX, Math.min(cx, scrollX + innerWidth - cw - 8));
3843
+ let cy = top - ch - 8;
3844
+ if (cy < this.ctxMinTop())
3845
+ cy = bottom + 8;
3846
+ this.imgCtx.style.left = cx + 'px';
3847
+ this.imgCtx.style.top = cy + 'px';
3848
+ }
3849
+ }
3850
+ startImageResizeDrag(e, corner) {
3851
+ e.preventDefault();
3852
+ const img = this.imgActive;
3853
+ if (!img)
3854
+ return;
3855
+ this.cancelImgHide();
3856
+ const startX = e.clientX;
3857
+ const startW = img.getBoundingClientRect().width;
3858
+ const sign = corner === 'nw' || corner === 'sw' ? -1 : 1;
3859
+ this.withNoTextSelect((restore) => {
3860
+ const mv = (ev) => {
3861
+ img.style.width = Math.max(24, Math.round(startW + sign * (ev.clientX - startX))) + 'px';
3862
+ img.style.height = 'auto';
3863
+ this.positionImageHandles();
3864
+ };
3865
+ const up = () => {
3866
+ window.removeEventListener('mousemove', mv);
3867
+ window.removeEventListener('mouseup', up);
3868
+ restore();
3869
+ this.onChange();
3870
+ };
3871
+ window.addEventListener('mousemove', mv);
3872
+ window.addEventListener('mouseup', up);
3873
+ });
3874
+ }
3875
+ /* ---------------------------------------------------------- selection bubble toolbar */
3876
+ toggleBlock(tag) {
3877
+ this.exec('formatBlock', this.currentBlock() === tag ? '<p>' : '<' + tag + '>');
3878
+ }
3879
+ clearSelToolbar() {
3880
+ this.selCtx?.remove();
3881
+ this.selCtx = null;
3882
+ }
3883
+ updateSelToolbar(sel) {
3884
+ if (this.options.readonly || sel.isCollapsed || !sel.rangeCount) {
3885
+ this.clearSelToolbar();
3886
+ return;
3887
+ }
3888
+ const range = sel.getRangeAt(0);
3889
+ const r = range.getBoundingClientRect();
3890
+ if (!r.width && !r.height) {
3891
+ this.clearSelToolbar();
3892
+ return;
3893
+ }
3894
+ if (!this.selCtx)
3895
+ this.selCtx = this.buildSelToolbar();
3896
+ this.positionSelToolbar(range);
3897
+ }
3898
+ buildSelToolbar() {
3899
+ const el = document.createElement('div');
3900
+ el.className = 'selctx';
3901
+ el.dir = this.dir();
3902
+ const foreBtn = this.ctxBtn('<b style="font:600 15px/1 Georgia,serif">A</b><span class="colorbar" data-cb="fore"></span>', this.t('forecolor'), () => this.colorMenu(foreBtn, 'fore'));
3903
+ const backBtn = this.ctxBtn(IC.backcolor, this.t('backcolor'), () => this.colorMenu(backBtn, 'back'));
3904
+ backBtn.querySelector('svg')?.insertAdjacentHTML('afterend', '<span class="colorbar" data-cb="back"></span>');
3905
+ const caseBtn = this.ctxBtn(IC.caseic, this.t('changecase'), () => this.caseMenu(caseBtn));
3906
+ const lhBtn = this.ctxBtn(IC.lineheight, this.t('lineheight'), () => this.lineHeightMenu(lhBtn));
3907
+ const wsBtn = this.ctxBtn(IC.wordspacing, this.t('wordspacing'), () => this.wordSpacingMenu(wsBtn));
3908
+ 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);
3909
+ el.addEventListener('mousedown', (e) => e.preventDefault());
3910
+ document.body.appendChild(el);
3911
+ this.syncColorSwatches(el);
3912
+ return el;
3913
+ }
3914
+ positionSelToolbar(range) {
3915
+ if (!this.selCtx)
3916
+ return;
3917
+ const r = range.getBoundingClientRect();
3918
+ const top = r.top + scrollY;
3919
+ const left = r.left + scrollX;
3920
+ const right = r.right + scrollX;
3921
+ const bottom = r.bottom + scrollY;
3922
+ const cw = this.selCtx.offsetWidth;
3923
+ const ch = this.selCtx.offsetHeight;
3924
+ let cx = left + (right - left) / 2 - cw / 2;
3925
+ cx = Math.max(8 + scrollX, Math.min(cx, scrollX + innerWidth - cw - 8));
3926
+ let cy = top - ch - 8;
3927
+ if (cy < this.ctxMinTop())
3928
+ cy = bottom + 8;
3929
+ this.selCtx.style.left = cx + 'px';
3930
+ this.selCtx.style.top = cy + 'px';
3931
+ }
3932
+ repositionSelToolbar() {
3933
+ if (!this.selCtx)
3934
+ return;
3935
+ const s = window.getSelection();
3936
+ if (s && s.rangeCount && !s.isCollapsed && this.ed.contains(s.anchorNode))
3937
+ this.positionSelToolbar(s.getRangeAt(0));
3938
+ }
3939
+ /* ---------------------------------------------------------- state sync */
3940
+ currentBlock() {
3941
+ const s = window.getSelection();
3942
+ if (!s || !s.anchorNode || !this.ed.contains(s.anchorNode))
3943
+ return 'p';
3944
+ const b = this.closestBlock(s.anchorNode);
3945
+ return b ? b.tagName.toLowerCase().replace('div', 'p') : 'p';
3946
+ }
3947
+ /** Element whose computed font reflects the current caret, falling back to the editor root before any selection lands inside it. */
3948
+ fontRefEl() {
3949
+ const s = window.getSelection();
3950
+ if (s && s.anchorNode && this.ed.contains(s.anchorNode)) {
3951
+ return s.anchorNode.nodeType === 1 ? s.anchorNode : s.anchorNode.parentElement;
3952
+ }
3953
+ return this.ed;
3954
+ }
3955
+ currentFont() {
3956
+ const fam = getComputedStyle(this.fontRefEl()).fontFamily.toLowerCase();
3957
+ const hit = this.options.fontFamilyFormats.find(([n, v]) => fam.includes(v.split(',')[0].toLowerCase()));
3958
+ return hit ? hit[0] : '';
3959
+ }
3960
+ currentSize() {
3961
+ const px = Math.round(parseFloat(getComputedStyle(this.fontRefEl()).fontSize));
3962
+ return px > 0 ? px + 'px' : '';
3963
+ }
3964
+ refreshState() {
3965
+ const s = window.getSelection();
3966
+ const inEd = s ? this.ed.contains(s.anchorNode) : false;
3967
+ const blkTag = this.currentBlock();
3968
+ const containers = [this.toolbar, this.selCtx].filter(Boolean);
3969
+ containers.forEach((container) => {
3970
+ [
3971
+ 'bold',
3972
+ 'italic',
3973
+ 'underline',
3974
+ 'strikeThrough',
3975
+ 'justifyLeft',
3976
+ 'justifyCenter',
3977
+ 'justifyRight',
3978
+ 'justifyFull',
3979
+ 'insertOrderedList',
3980
+ 'insertUnorderedList'
3981
+ ].forEach((c) => {
3982
+ const b = container.querySelector(`[data-id=${c}]`);
3983
+ if (b)
3984
+ b.classList.toggle('on', inEd && document.queryCommandState(c));
3985
+ });
3986
+ ['h2', 'h3', 'blockquote'].forEach((tag) => {
3987
+ const b = container.querySelector(`[data-id=${tag}]`);
3988
+ if (b)
3989
+ b.classList.toggle('on', inEd && blkTag === tag);
3990
+ });
3991
+ });
3992
+ const blk = s ? this.closestBlock(s.anchorNode) : null;
3993
+ const dir = blk?.closest('[dir]')?.getAttribute('dir') || this.ed.getAttribute('dir') || this.dir();
3994
+ this.toolbar.querySelector('[data-id=ltr]')?.classList.toggle('on', inEd && dir === 'ltr');
3995
+ this.toolbar.querySelector('[data-id=rtl]')?.classList.toggle('on', inEd && dir === 'rtl');
3996
+ const setLbl = (id, val, def) => {
3997
+ const l = this.toolbar.querySelector(`[data-id=${id}] .lbl`);
3998
+ if (!l)
3999
+ return;
4000
+ l.textContent = val || def || '';
4001
+ l.classList.toggle('empty', !val);
4002
+ };
4003
+ setLbl('fontsel', this.currentFont());
4004
+ setLbl('sizesel', this.currentSize().replace('px', ''));
4005
+ if (inEd) {
4006
+ setLbl('blocksel', BLOCKS.some((b) => b[0] === blkTag) ? this.blockLabel(blkTag) : this.t('para'));
4007
+ }
4008
+ this.sbWords.textContent = this.countWords() + ' ' + this.t('words');
4009
+ }
4010
+ countWords() {
4011
+ const txt = (this.ed.textContent || '').trim();
4012
+ return txt ? txt.split(/\s+/).length : 0;
4013
+ }
4014
+ /* ---------------------------------------------------------- paste */
4015
+ handlePaste(e) {
4016
+ const cd = e.clipboardData;
4017
+ if (!cd)
4018
+ return;
4019
+ e.preventDefault();
4020
+ const html = cd.getData('text/html');
4021
+ const imgItem = Array.from(cd.items).find((i) => i.type.startsWith('image/'));
4022
+ if (imgItem && !html) {
4023
+ const fr = new FileReader();
4024
+ fr.onload = () => {
4025
+ document.execCommand('insertHTML', false, `<img src="${fr.result}" alt="">`);
4026
+ this.onChange();
4027
+ };
4028
+ fr.readAsDataURL(imgItem.getAsFile());
4029
+ return;
4030
+ }
4031
+ if (html) {
4032
+ document.execCommand('insertHTML', false, cleanPastedHTML(html, this.dir()));
4033
+ }
4034
+ else {
4035
+ document.execCommand('insertHTML', false, normalizeTextPaste(cd.getData('text/plain')));
4036
+ }
4037
+ this.onChange();
4038
+ }
4039
+ /* ---------------------------------------------------------- init */
4040
+ initUI() {
4041
+ this.closePop();
4042
+ this.closeDlg();
4043
+ this.clearTableHandles();
4044
+ this.clearImgPlaceholderSel();
4045
+ this.shell.dir = this.dir();
4046
+ this.ed.dir = this.dir();
4047
+ if (this.options.menubar)
4048
+ this.buildMenubar();
4049
+ else
4050
+ this.menubar.style.display = 'none';
4051
+ if (this.options.toolbar)
4052
+ this.buildToolbar();
4053
+ else
4054
+ this.toolbar.style.display = 'none';
4055
+ this.sbHelp.textContent = this.t('statushelp');
4056
+ this.refreshState();
4057
+ }
4058
+ }
4059
+
4060
+ class FableEditorComponent {
4061
+ container;
4062
+ language = 'en';
4063
+ height = 302;
4064
+ menubar = true;
4065
+ toolbar = true;
4066
+ statusbar = true;
4067
+ readonly = false;
4068
+ init = {};
4069
+ value = '';
4070
+ editorChange = new EventEmitter();
4071
+ editorReady = new EventEmitter();
4072
+ editor;
4073
+ lastContent = '';
4074
+ ngOnInit() {
4075
+ this.lastContent = this.value;
4076
+ this.editor = new FableEditor({
4077
+ target: this.container.nativeElement,
4078
+ language: this.language,
4079
+ height: this.height,
4080
+ menubar: this.menubar,
4081
+ toolbar: this.toolbar,
4082
+ statusbar: this.statusbar,
4083
+ readonly: this.readonly,
4084
+ initialContent: this.value,
4085
+ ...this.init,
4086
+ onChange: (content) => {
4087
+ this.lastContent = content;
4088
+ this.editorChange.emit(content);
4089
+ },
4090
+ onReady: (api) => this.editorReady.emit(api)
4091
+ });
4092
+ }
4093
+ ngOnChanges(changes) {
4094
+ if (!this.editor)
4095
+ return;
4096
+ if (changes['language'] && !changes['language'].firstChange) {
4097
+ this.editor.setLanguage(this.language);
4098
+ }
4099
+ if (changes['value'] && !changes['value'].firstChange && this.value !== this.lastContent) {
4100
+ this.lastContent = this.value;
4101
+ this.editor.setContent(this.value ?? '');
4102
+ }
4103
+ }
4104
+ ngOnDestroy() {
4105
+ this.editor?.destroy();
4106
+ this.editor = undefined;
4107
+ }
4108
+ getContent() {
4109
+ return this.editor?.getContent() ?? '';
4110
+ }
4111
+ setContent(html) {
4112
+ this.lastContent = html;
4113
+ this.editor?.setContent(html ?? '');
4114
+ }
4115
+ insertContent(html) {
4116
+ this.editor?.insertContent(html);
4117
+ }
4118
+ setLanguage(lang) {
4119
+ this.editor?.setLanguage(lang);
4120
+ }
4121
+ focus() {
4122
+ this.editor?.focus();
4123
+ }
4124
+ writeValue(value) {
4125
+ this.editor?.setContent(value ?? '');
4126
+ }
4127
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: FableEditorComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
4128
+ 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"] });
4129
+ }
4130
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: FableEditorComponent, decorators: [{
4131
+ type: Component,
4132
+ args: [{ selector: 'fable-editor', standalone: true, template: `<div #container></div>`, styles: [":host{display:block}\n"] }]
4133
+ }], propDecorators: { container: [{
4134
+ type: ViewChild,
4135
+ args: ['container', { static: true }]
4136
+ }], language: [{
4137
+ type: Input
4138
+ }], height: [{
4139
+ type: Input
4140
+ }], menubar: [{
4141
+ type: Input
4142
+ }], toolbar: [{
4143
+ type: Input
4144
+ }], statusbar: [{
4145
+ type: Input
4146
+ }], readonly: [{
4147
+ type: Input
4148
+ }], init: [{
4149
+ type: Input
4150
+ }], value: [{
4151
+ type: Input
4152
+ }], editorChange: [{
4153
+ type: Output
4154
+ }], editorReady: [{
4155
+ type: Output
4156
+ }] } });
4157
+
4158
+ class FableEditorModule {
4159
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: FableEditorModule, deps: [], target: i0.ɵɵFactoryTarget.NgModule });
4160
+ static ɵmod = i0.ɵɵngDeclareNgModule({ minVersion: "14.0.0", version: "18.2.14", ngImport: i0, type: FableEditorModule, imports: [FableEditorComponent], exports: [FableEditorComponent] });
4161
+ static ɵinj = i0.ɵɵngDeclareInjector({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: FableEditorModule });
4162
+ }
4163
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: FableEditorModule, decorators: [{
4164
+ type: NgModule,
4165
+ args: [{
4166
+ imports: [FableEditorComponent],
4167
+ exports: [FableEditorComponent]
4168
+ }]
4169
+ }] });
4170
+
4171
+ /**
4172
+ * Generated bundle index. Do not edit.
4173
+ */
4174
+
4175
+ export { FableEditorComponent, FableEditorModule };
4176
+ //# sourceMappingURL=fable-editor.mjs.map