tulih-editor 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +331 -0
  3. package/dist/tulih-editor.css +1 -0
  4. package/dist/tulih-editor.es.js +3051 -0
  5. package/dist/tulih-editor.es.js.map +1 -0
  6. package/dist/tulih-editor.umd.js +8 -0
  7. package/dist/tulih-editor.umd.js.map +1 -0
  8. package/dist/types/core/Editor.d.ts +20 -0
  9. package/dist/types/core/PluginManager.d.ts +22 -0
  10. package/dist/types/core/helpers.d.ts +22 -0
  11. package/dist/types/index.d.ts +5 -0
  12. package/dist/types/plugins/align.d.ts +3 -0
  13. package/dist/types/plugins/autoLinkify.d.ts +3 -0
  14. package/dist/types/plugins/autosave.d.ts +3 -0
  15. package/dist/types/plugins/block.d.ts +3 -0
  16. package/dist/types/plugins/caseTransform.d.ts +3 -0
  17. package/dist/types/plugins/codeBlock.d.ts +3 -0
  18. package/dist/types/plugins/colors.d.ts +3 -0
  19. package/dist/types/plugins/darkMode.d.ts +3 -0
  20. package/dist/types/plugins/direction.d.ts +3 -0
  21. package/dist/types/plugins/dragDrop.d.ts +3 -0
  22. package/dist/types/plugins/emoji.d.ts +3 -0
  23. package/dist/types/plugins/emojiAutocomplete.d.ts +3 -0
  24. package/dist/types/plugins/findReplace.d.ts +3 -0
  25. package/dist/types/plugins/floatingToolbar.d.ts +3 -0
  26. package/dist/types/plugins/fontFamily.d.ts +3 -0
  27. package/dist/types/plugins/fontSize.d.ts +3 -0
  28. package/dist/types/plugins/fullscreen.d.ts +3 -0
  29. package/dist/types/plugins/history.d.ts +3 -0
  30. package/dist/types/plugins/hr.d.ts +3 -0
  31. package/dist/types/plugins/iframe.d.ts +3 -0
  32. package/dist/types/plugins/image.d.ts +3 -0
  33. package/dist/types/plugins/imageProps.d.ts +3 -0
  34. package/dist/types/plugins/imageTools.d.ts +3 -0
  35. package/dist/types/plugins/indent.d.ts +3 -0
  36. package/dist/types/plugins/index.d.ts +2 -0
  37. package/dist/types/plugins/inline.d.ts +3 -0
  38. package/dist/types/plugins/inlineCode.d.ts +3 -0
  39. package/dist/types/plugins/keyboardShortcuts.d.ts +3 -0
  40. package/dist/types/plugins/lineHeight.d.ts +3 -0
  41. package/dist/types/plugins/link.d.ts +3 -0
  42. package/dist/types/plugins/linkTooltip.d.ts +3 -0
  43. package/dist/types/plugins/list.d.ts +3 -0
  44. package/dist/types/plugins/markdown.d.ts +3 -0
  45. package/dist/types/plugins/mediaEmbed.d.ts +3 -0
  46. package/dist/types/plugins/pasteImage.d.ts +3 -0
  47. package/dist/types/plugins/pastePlain.d.ts +3 -0
  48. package/dist/types/plugins/pre.d.ts +3 -0
  49. package/dist/types/plugins/readOnly.d.ts +3 -0
  50. package/dist/types/plugins/shortcutCustomizer.d.ts +3 -0
  51. package/dist/types/plugins/shortcutsHelp.d.ts +3 -0
  52. package/dist/types/plugins/source.d.ts +3 -0
  53. package/dist/types/plugins/specialChars.d.ts +3 -0
  54. package/dist/types/plugins/statusBar.d.ts +3 -0
  55. package/dist/types/plugins/subSuper.d.ts +3 -0
  56. package/dist/types/plugins/table.d.ts +3 -0
  57. package/dist/types/plugins/tableBg.d.ts +3 -0
  58. package/dist/types/plugins/tableTools.d.ts +3 -0
  59. package/dist/types/plugins/toolbarCollapse.d.ts +3 -0
  60. package/dist/types/plugins/unlink.d.ts +3 -0
  61. package/dist/types/plugins/wordCount.d.ts +3 -0
  62. package/dist/types/types.d.ts +226 -0
  63. package/package.json +66 -0
  64. package/src/core/Editor.ts +460 -0
  65. package/src/core/PluginManager.ts +140 -0
  66. package/src/core/helpers.ts +209 -0
  67. package/src/css.d.ts +2 -0
  68. package/src/index.ts +87 -0
  69. package/src/plugins/align.ts +72 -0
  70. package/src/plugins/autoLinkify.ts +34 -0
  71. package/src/plugins/autosave.ts +69 -0
  72. package/src/plugins/block.ts +32 -0
  73. package/src/plugins/caseTransform.ts +54 -0
  74. package/src/plugins/codeBlock.ts +93 -0
  75. package/src/plugins/colors.ts +68 -0
  76. package/src/plugins/darkMode.ts +123 -0
  77. package/src/plugins/direction.ts +30 -0
  78. package/src/plugins/dragDrop.ts +68 -0
  79. package/src/plugins/emoji.ts +188 -0
  80. package/src/plugins/emojiAutocomplete.ts +183 -0
  81. package/src/plugins/findReplace.ts +229 -0
  82. package/src/plugins/floatingToolbar.ts +258 -0
  83. package/src/plugins/fontFamily.ts +41 -0
  84. package/src/plugins/fontSize.ts +32 -0
  85. package/src/plugins/fullscreen.ts +36 -0
  86. package/src/plugins/history.ts +14 -0
  87. package/src/plugins/hr.ts +118 -0
  88. package/src/plugins/iframe.ts +88 -0
  89. package/src/plugins/image.ts +107 -0
  90. package/src/plugins/imageProps.ts +119 -0
  91. package/src/plugins/imageTools.ts +344 -0
  92. package/src/plugins/indent.ts +29 -0
  93. package/src/plugins/index.ts +101 -0
  94. package/src/plugins/inline.ts +17 -0
  95. package/src/plugins/inlineCode.ts +21 -0
  96. package/src/plugins/keyboardShortcuts.ts +92 -0
  97. package/src/plugins/lineHeight.ts +40 -0
  98. package/src/plugins/link.ts +344 -0
  99. package/src/plugins/linkTooltip.ts +63 -0
  100. package/src/plugins/list.ts +141 -0
  101. package/src/plugins/markdown.ts +61 -0
  102. package/src/plugins/mediaEmbed.ts +44 -0
  103. package/src/plugins/pasteImage.ts +61 -0
  104. package/src/plugins/pastePlain.ts +43 -0
  105. package/src/plugins/pre.ts +11 -0
  106. package/src/plugins/readOnly.ts +46 -0
  107. package/src/plugins/shortcutCustomizer.ts +125 -0
  108. package/src/plugins/shortcutsHelp.ts +51 -0
  109. package/src/plugins/source.ts +77 -0
  110. package/src/plugins/specialChars.ts +64 -0
  111. package/src/plugins/statusBar.ts +85 -0
  112. package/src/plugins/subSuper.ts +20 -0
  113. package/src/plugins/table.ts +166 -0
  114. package/src/plugins/tableBg.ts +11 -0
  115. package/src/plugins/tableTools.ts +475 -0
  116. package/src/plugins/toolbarCollapse.ts +14 -0
  117. package/src/plugins/unlink.ts +29 -0
  118. package/src/plugins/wordCount.ts +34 -0
  119. package/src/styles/base.css +258 -0
  120. package/src/styles/editor.css +309 -0
  121. package/src/styles/index.css +6 -0
  122. package/src/types.ts +278 -0
@@ -0,0 +1,460 @@
1
+ import * as H from './helpers';
2
+ import type {
3
+ EditorOptions,
4
+ ResolvedOptions,
5
+ Features,
6
+ EditorUtils,
7
+ PluginContext,
8
+ SanitizeOptions,
9
+ TulihEditorStatic,
10
+ EditorInstance,
11
+ TeEditorElement,
12
+ TeWrapperElement,
13
+ } from '../types';
14
+
15
+ function buildEditorHTML(toolbarHTML: string, modalHTML: string): string {
16
+ return '' +
17
+ '<div class="te-container">' +
18
+ ' <div class="te-toolbar mb-0">' +
19
+ toolbarHTML +
20
+ ' </div>' +
21
+ ' <div class="te-editor" contenteditable="true" placeholder="Start typing..."></div>' +
22
+ modalHTML +
23
+ '</div>';
24
+ }
25
+
26
+ const DEFAULT_FEATURES: Features = {
27
+ block: false, inline: false, inlineCode: false, subSuper: false, removeFormat: false,
28
+ codeBlock: false, mediaEmbed: false, dragDrop: false,
29
+ readOnly: false, linkTooltip: false, emojiAutocomplete: false, statusBar: false,
30
+ pastePlain: false, findReplace: false, indent: false, list: false, autoLinkify: false, caseTransform: false, shortcutsHelp: false,
31
+ lineHeight: false, fontSize: false, fontFamily: false, specialChars: false,
32
+ colors: false, tableBg: false, align: false, markdown: false,
33
+ link: false, image: false, imageProps: false, imageTools: false,
34
+ iframe: false, table: false, tableTools: false, hr: false,
35
+ unlink: false, emoji: false, direction: false,
36
+ source: false, history: false, fullscreen: false,
37
+ toolbarCollapse: false, autosave: false, floatingToolbar: false,
38
+ darkMode: false, shortcutCustomizer: false, minimal: false,
39
+ };
40
+
41
+ const DEFAULT_SANITIZE: SanitizeOptions = {
42
+ allowedTags: ['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'pre', 'code', 'strong', 'em', 'u', 's', 'span', 'a', 'img', 'iframe', 'ul', 'ol', 'li', 'table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td', 'br', 'hr'],
43
+ allowedAttributes: ['href', 'src', 'alt', 'title', 'width', 'height', 'class', 'id', 'style', 'target', 'rel', 'allow', 'allowfullscreen', 'frameborder'],
44
+ };
45
+ const DEFAULT_URL_SCHEMES = ['http', 'https', 'mailto', 'tel'];
46
+ const DEFAULT_IMAGE_SCHEMES = ['http', 'https', 'data'];
47
+ const DEFAULT_IFRAME_ALLOWLIST = ['youtube.com', 'www.youtube.com', 'youtu.be', 'player.vimeo.com', 'vimeo.com'];
48
+ const DEFAULT_IFRAME_SANDBOX = 'allow-scripts allow-same-origin allow-presentation';
49
+ const DEFAULT_IFRAME_ALLOW = 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share';
50
+
51
+ export default class Editor implements EditorInstance {
52
+ features!: Features;
53
+ options!: ResolvedOptions;
54
+ utils!: EditorUtils;
55
+ container: HTMLElement | null = null;
56
+ editor: HTMLElement | null = null;
57
+ toolbar: HTMLElement | null = null;
58
+ textarea!: HTMLTextAreaElement;
59
+ private _ctx: PluginContext | null = null;
60
+
61
+ constructor(target: string | Element, options: EditorOptions | undefined, TulihEditor: TulihEditorStatic) {
62
+ const el = (typeof target === 'string' ? document.querySelector(target) : target) as HTMLElement | null;
63
+ if (!el) return;
64
+ const isTextarea = String(el.tagName).toUpperCase() === 'TEXTAREA';
65
+ const hasAttr = el.matches && el.matches('textarea[data-tulih-editor]');
66
+ if (!isTextarea && !hasAttr) return;
67
+ const sibling = el.nextElementSibling;
68
+ if (sibling && sibling.querySelector && sibling.querySelector('.te-editor')) return;
69
+
70
+ const opts: EditorOptions = options || {};
71
+ const textarea = el as HTMLTextAreaElement;
72
+
73
+ const isMinimal = opts.minimal || (opts.features && opts.features.minimal);
74
+ if (isMinimal) {
75
+ this.features = Object.assign({}, DEFAULT_FEATURES, { floatingToolbar: true, link: true, linkTooltip: true }, opts.features || {});
76
+ } else {
77
+ const allOn: Features = {};
78
+ Object.keys(DEFAULT_FEATURES).forEach((k) => {
79
+ if (k !== 'minimal') allOn[k] = true;
80
+ });
81
+ this.features = Object.assign({}, allOn, opts.features || {});
82
+ }
83
+ this.options = {
84
+ sanitize: Object.assign({}, DEFAULT_SANITIZE, opts.sanitize || {}),
85
+ urlSchemes: Array.isArray(opts.allowedUrlSchemes) ? opts.allowedUrlSchemes : DEFAULT_URL_SCHEMES,
86
+ imageSchemes: Array.isArray(opts.allowedImageSchemes) ? opts.allowedImageSchemes : DEFAULT_IMAGE_SCHEMES,
87
+ iframeAllowlist: Array.isArray(opts.iframeAllowlist) ? opts.iframeAllowlist : DEFAULT_IFRAME_ALLOWLIST,
88
+ iframeSandbox: typeof opts.iframeSandbox === 'string' ? opts.iframeSandbox : DEFAULT_IFRAME_SANDBOX,
89
+ iframeAllow: typeof opts.iframeAllow === 'string' ? opts.iframeAllow : DEFAULT_IFRAME_ALLOW,
90
+ readOnly: !!opts.readOnly,
91
+ };
92
+
93
+ const self = this;
94
+
95
+ function sanitizeHTML(html: string, sanOpts?: Partial<SanitizeOptions>): string {
96
+ const allowedTags = new Set((sanOpts && sanOpts.allowedTags) || []);
97
+ const allowedAttrs = new Set((sanOpts && sanOpts.allowedAttributes) || []);
98
+ const container = document.createElement('div');
99
+ container.innerHTML = String(html || '');
100
+ const walker = document.createTreeWalker(container, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_COMMENT, null);
101
+ const toUnwrap: Element[] = [];
102
+ const comments: Node[] = [];
103
+ while (walker.nextNode()) {
104
+ const node = walker.currentNode as Element;
105
+ if (node.nodeType === 8) { comments.push(node); continue; }
106
+ const tag = node.tagName ? node.tagName.toLowerCase() : '';
107
+ if (tag && !allowedTags.has(tag)) {
108
+ toUnwrap.push(node);
109
+ continue;
110
+ }
111
+ if (node.attributes) {
112
+ const attrs = Array.prototype.slice.call(node.attributes) as Attr[];
113
+ attrs.forEach((attr) => {
114
+ const aname = attr.name.toLowerCase();
115
+ if (aname.startsWith('on')) { node.removeAttribute(attr.name); return; }
116
+ if (!allowedAttrs.has(aname)) { node.removeAttribute(attr.name); return; }
117
+ if (aname === 'href' && attr.value) { if (!isSafeUrl(attr.value, self.options.urlSchemes)) { node.removeAttribute(attr.name); } }
118
+ if (aname === 'src' && attr.value && tag === 'img') { if (!isSafeUrl(attr.value, self.options.imageSchemes)) { node.removeAttribute(attr.name); } }
119
+ });
120
+ }
121
+ if (tag === 'iframe') {
122
+ const src = node.getAttribute('src') || '';
123
+ if (!isAllowedIframeUrl(src)) {
124
+ toUnwrap.push(node);
125
+ } else {
126
+ if (self.options.iframeSandbox) node.setAttribute('sandbox', self.options.iframeSandbox);
127
+ if (self.options.iframeAllow) node.setAttribute('allow', self.options.iframeAllow);
128
+ node.setAttribute('frameborder', '0');
129
+ (node as HTMLElement).style.maxWidth = '100%';
130
+ (node as HTMLElement).style.border = '0';
131
+ }
132
+ }
133
+ }
134
+ comments.forEach((c) => { if (c.parentNode) c.parentNode.removeChild(c); });
135
+ toUnwrap.forEach((node) => {
136
+ if (!node.parentNode) return;
137
+ while (node.firstChild) { node.parentNode.insertBefore(node.firstChild, node); }
138
+ node.parentNode.removeChild(node);
139
+ });
140
+ return container.innerHTML;
141
+ }
142
+
143
+ function isSafeUrl(url: string, schemes: string[]): boolean {
144
+ try {
145
+ const u = new URL(url, window.location.origin);
146
+ return schemes.indexOf(u.protocol.replace(':', '')) !== -1;
147
+ } catch (_) { return false; }
148
+ }
149
+
150
+ function isAllowedIframeUrl(url: string): boolean {
151
+ try {
152
+ const u = new URL(url, window.location.origin);
153
+ const host = u.hostname.toLowerCase();
154
+ return self.options.iframeAllowlist.some((allowed) => host === allowed || host.endsWith('.' + allowed));
155
+ } catch (_) { return false; }
156
+ }
157
+
158
+ this.utils = { sanitizeHTML, isSafeUrl, isAllowedIframeUrl };
159
+
160
+ const toolbarHTML = TulihEditor.pluginManager.getToolbarHTML(self.features, opts.toolbar);
161
+ const modalHTML = TulihEditor.pluginManager.getModalHTML(self.features);
162
+ const wrapper = document.createElement('div') as TeWrapperElement;
163
+ wrapper.innerHTML = buildEditorHTML(toolbarHTML, modalHTML);
164
+ el.style.display = 'none';
165
+ el.parentNode!.insertBefore(wrapper, el.nextSibling);
166
+
167
+ const editorEl = wrapper.querySelector('.te-editor') as TeEditorElement;
168
+ const toolbarEl = wrapper.querySelector('.te-toolbar') as HTMLElement;
169
+
170
+ try { const raw = textarea.value || ''; const safeInit = sanitizeHTML(raw, self.options.sanitize); editorEl.innerHTML = safeInit; } catch (_) { /* ignore */ }
171
+ try { H.normalizeParagraphs(editorEl); H.ensureInitialParagraph(editorEl); } catch (_) { /* ignore */ }
172
+ try { H.ensureInitialParagraph(editorEl); } catch (_) { /* ignore */ }
173
+
174
+ if (opts.height) {
175
+ editorEl.style.height = typeof opts.height === 'number' ? opts.height + 'px' : String(opts.height);
176
+ editorEl.style.overflow = 'auto';
177
+ }
178
+
179
+ this.container = wrapper;
180
+ this.editor = editorEl;
181
+ this.toolbar = toolbarEl;
182
+ this.textarea = textarea;
183
+
184
+ try { document.execCommand('defaultParagraphSeparator', false, 'p'); } catch (_) { /* ignore */ }
185
+
186
+ function updateActiveStates(): void {
187
+ try {
188
+ ['bold', 'italic', 'underline', 'strikeThrough'].forEach((cmd) => {
189
+ const btn = toolbarEl.querySelector('[data-cmd="' + cmd + '"]');
190
+ if (!btn) return;
191
+ if (document.queryCommandState(cmd)) btn.classList.add('active');
192
+ else btn.classList.remove('active');
193
+ });
194
+ const undoBtn = toolbarEl.querySelector('[data-cmd="undo"]') as HTMLButtonElement | null;
195
+ const redoBtn = toolbarEl.querySelector('[data-cmd="redo"]') as HTMLButtonElement | null;
196
+ const h = editorEl._tableHist;
197
+ if (undoBtn) {
198
+ const canUndo = document.queryCommandEnabled('undo') || (h && h.undo.length > 0);
199
+ undoBtn.toggleAttribute('disabled', !canUndo);
200
+ }
201
+ if (redoBtn) {
202
+ const canRedo = document.queryCommandEnabled('redo') || (h && h.redo.length > 0);
203
+ redoBtn.toggleAttribute('disabled', !canRedo);
204
+ }
205
+ const sel = window.getSelection();
206
+ let node: Node | null = sel && sel.anchorNode;
207
+ if (node && node.nodeType === 3) node = node.parentNode;
208
+ const elNode = node as Element | null;
209
+ const inOL = elNode && elNode.closest && elNode.closest('ol');
210
+ const inUL = elNode && elNode.closest && elNode.closest('ul');
211
+ const olBtn = toolbarEl.querySelector('[data-cmd="insertOrderedList"]');
212
+ const ulBtn = toolbarEl.querySelector('[data-cmd="insertUnorderedList"]');
213
+ if (olBtn) olBtn.classList.toggle('active', !!inOL);
214
+ if (ulBtn) ulBtn.classList.toggle('active', !!inUL);
215
+ } catch (_) { /* ignore */ }
216
+ }
217
+
218
+ document.addEventListener('selectionchange', () => {
219
+ if (wrapper.contains(document.activeElement)) { updateActiveStates(); }
220
+ });
221
+
222
+ toolbarEl.querySelectorAll('[data-cmd]').forEach((btn) => {
223
+ btn.addEventListener('click', () => {
224
+ const cmd = btn.getAttribute('data-cmd');
225
+ const val = btn.getAttribute('data-val') || undefined;
226
+ if (cmd === 'createLink' || cmd === 'insertImage' || cmd === 'insertIframe' || cmd === 'insertTable' || cmd === 'editSource' || cmd === 'indent' || cmd === 'outdent' || cmd === 'findReplace' || cmd === 'togglePastePlain' || cmd === 'specialChars' || cmd === 'fullscreen' || cmd === 'emoji' || cmd === 'codeBlock' || cmd === 'toggleReadOnly' || cmd === 'shortcutsHelp' || cmd === 'alignLeft' || cmd === 'alignCenter' || cmd === 'alignRight' || cmd === 'alignJustify' || cmd === 'toggleDark' || cmd === 'customizeShortcuts') {
227
+ return;
228
+ }
229
+ function clearFrHighlights(): void {
230
+ editorEl.querySelectorAll('.te-fr-match, .te-fr-active').forEach((m) => {
231
+ const parent = m.parentNode;
232
+ if (!parent) return;
233
+ while (m.firstChild) parent.insertBefore(m.firstChild, m);
234
+ parent.removeChild(m);
235
+ });
236
+ }
237
+ if (cmd === 'undo') {
238
+ const h = editorEl._tableHist;
239
+ if (h && h.undo.length) {
240
+ clearFrHighlights();
241
+ h.redo.push(editorEl.innerHTML);
242
+ editorEl.contentEditable = 'false';
243
+ editorEl.innerHTML = h.undo.pop() as string;
244
+ editorEl.contentEditable = 'true';
245
+ clearFrHighlights();
246
+ editorEl.focus();
247
+ updateActiveStates();
248
+ return;
249
+ }
250
+ }
251
+ if (cmd === 'redo') {
252
+ const h = editorEl._tableHist;
253
+ if (h && h.redo.length) {
254
+ clearFrHighlights();
255
+ h.undo.push(editorEl.innerHTML);
256
+ editorEl.contentEditable = 'false';
257
+ editorEl.innerHTML = h.redo.pop() as string;
258
+ editorEl.contentEditable = 'true';
259
+ clearFrHighlights();
260
+ editorEl.focus();
261
+ updateActiveStates();
262
+ return;
263
+ }
264
+ }
265
+ document.execCommand(cmd as string, false, val);
266
+ editorEl.focus();
267
+ updateActiveStates();
268
+ });
269
+ });
270
+
271
+ wrapper.querySelectorAll('[data-te-close]').forEach((x) => {
272
+ x.addEventListener('click', () => {
273
+ const modal = (x as Element).closest('.te-modal');
274
+ if (modal) modal.classList.remove('is-open');
275
+ });
276
+ });
277
+
278
+ try { H.ensureInitialParagraph(editorEl); } catch (_) { /* ignore */ }
279
+
280
+ function saveSel(): void {
281
+ const s = window.getSelection();
282
+ if (s && s.rangeCount > 0) { wrapper.__savedRange = s.getRangeAt(0).cloneRange(); }
283
+ }
284
+ function restoreSel(): void {
285
+ const r = wrapper.__savedRange;
286
+ if (!r) return;
287
+ const s = window.getSelection();
288
+ if (!s) return;
289
+ s.removeAllRanges();
290
+ s.addRange(r);
291
+ }
292
+
293
+ function insertImageFile(file: File | Blob): void {
294
+ const handler = TulihEditor.onUploadImage || function (f: File | Blob, cb: (url: string) => void): void {
295
+ const r = new FileReader();
296
+ r.onload = () => { cb(r.result as string); };
297
+ r.readAsDataURL(f);
298
+ };
299
+ handler(file, (url) => {
300
+ if (!url) return;
301
+ const name = (file as File).name || '';
302
+ const html = '<img src="' + url.replace(/"/g, '&quot;') + '" alt="' + name.replace(/"/g, '&quot;') + '" style="max-width:100%;" />';
303
+ document.execCommand('insertHTML', false, html);
304
+ });
305
+ }
306
+
307
+ function cleanWordHtml(html: string): string {
308
+ if (!html) return '';
309
+ let out = String(html);
310
+ if (out.indexOf('mso-') === -1 && out.indexOf('xmlns:o') === -1 && out.indexOf('google-docs') === -1) return out;
311
+ out = out.replace(/<!--[\s\S]*?-->/g, '');
312
+ out = out.replace(/<meta[^>]*>/gi, '');
313
+ out = out.replace(/<link[^>]*>/gi, '');
314
+ out = out.replace(/<o:[^>]*>[\s\S]*?<\/o:[^>]*>/gi, '');
315
+ out = out.replace(/<w:[^>]*>[\s\S]*?<\/w:[^>]*>/gi, '');
316
+ out = out.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '');
317
+ out = out.replace(/ class="[^"]*Mso[^"]*"/gi, '');
318
+ out = out.replace(/ class="[^"]*" style=""/gi, '');
319
+ out = out.replace(/ style="[^"]*mso-[^;]*[^"]*"/gi, '');
320
+ out = out.replace(/<span[^>]*style="[^"]*mso-[^"]*"[^>]*>/gi, '<span>');
321
+ out = out.replace(/<span[^>]*><\/span>/gi, '');
322
+ out = out.replace(/(<(p|h[1-6]|div|span|b|i|u|strong|em))[^>]*>/gi, '$1>');
323
+ return out;
324
+ }
325
+
326
+ editorEl.addEventListener('paste', (e: ClipboardEvent) => {
327
+ try {
328
+ const files = e.clipboardData && e.clipboardData.files;
329
+ if (files && files.length > 0) {
330
+ let pasted = false;
331
+ for (let fi = 0; fi < files.length; fi++) {
332
+ if (/^image\//i.test(files[fi].type)) {
333
+ if (!pasted) { e.preventDefault(); pasted = true; }
334
+ insertImageFile(files[fi]);
335
+ }
336
+ }
337
+ if (pasted) return;
338
+ }
339
+ const items = e.clipboardData && e.clipboardData.items;
340
+ if (items && items.length > 0) {
341
+ for (let ii = 0; ii < items.length; ii++) {
342
+ if (items[ii].kind === 'file' && /^image\//i.test(items[ii].type)) {
343
+ e.preventDefault();
344
+ const imgFile = items[ii].getAsFile();
345
+ if (imgFile) { insertImageFile(imgFile); }
346
+ return;
347
+ }
348
+ }
349
+ }
350
+ if (window.navigator && window.navigator.clipboard && typeof window.navigator.clipboard.read === 'function') {
351
+ window.navigator.clipboard.read().then((clipItems) => {
352
+ for (let ci = 0; ci < clipItems.length; ci++) {
353
+ const ciTypes = clipItems[ci].types || [];
354
+ for (let ct = 0; ct < ciTypes.length; ct++) {
355
+ if (/^image\//i.test(ciTypes[ct])) {
356
+ clipItems[ci].getType(ciTypes[ct]).then((blob) => {
357
+ insertImageFile(blob);
358
+ });
359
+ break;
360
+ }
361
+ }
362
+ }
363
+ }).catch(() => { /* ignore */ });
364
+ }
365
+ e.preventDefault();
366
+ const rawHtml = (e.clipboardData && e.clipboardData.getData('text/html')) || '';
367
+ const text = (e.clipboardData && e.clipboardData.getData('text/plain')) || '';
368
+ const toInsert = cleanWordHtml(rawHtml) || (text ? '<p>' + text.replace(/\n/g, '<br>') + '</p>' : '');
369
+ const safe = sanitizeHTML(toInsert, self.options.sanitize);
370
+ document.execCommand('insertHTML', false, safe);
371
+ setTimeout(() => { try { H.ensureInitialParagraph(editorEl); H.normalizeParagraphs(editorEl); } catch (_) { /* ignore */ } }, 0);
372
+ } catch (_) { setTimeout(() => { H.normalizeParagraphs(editorEl); }, 0); }
373
+ });
374
+
375
+ editorEl.addEventListener('focus', () => { try { H.ensureInitialParagraph(editorEl); } catch (_) { /* ignore */ } });
376
+ editorEl.addEventListener('input', () => {
377
+ setTimeout(() => { try { H.ensureInitialParagraph(editorEl); H.normalizeParagraphs(editorEl); } catch (_) { /* ignore */ } }, 0);
378
+ });
379
+
380
+ const ctx: PluginContext = {
381
+ wrapper,
382
+ editor: editorEl,
383
+ toolbar: toolbarEl,
384
+ textarea,
385
+ features: self.features,
386
+ options: self.options,
387
+ utils: self.utils,
388
+ TulihEditor,
389
+ saveSel,
390
+ restoreSel,
391
+ updateActiveStates,
392
+ };
393
+
394
+ if (isMinimal) {
395
+ wrapper.classList.add('te-minimal');
396
+ }
397
+
398
+ self._ctx = ctx;
399
+
400
+ TulihEditor.pluginManager.initAll(ctx);
401
+
402
+ TulihEditor.instances.push(this);
403
+ }
404
+
405
+ getContent(): string {
406
+ return this.editor ? this.editor.innerHTML : '';
407
+ }
408
+
409
+ setContent(html: string): void {
410
+ if (!this.editor) return;
411
+ const safe = this.utils.sanitizeHTML(html, this.options.sanitize);
412
+ this.editor.innerHTML = safe;
413
+ try { H.ensureInitialParagraph(this.editor); H.normalizeParagraphs(this.editor); } catch (_) { /* ignore */ }
414
+ }
415
+
416
+ getText(): string {
417
+ return this.editor ? this.editor.textContent || '' : '';
418
+ }
419
+
420
+ getWordCount(): number {
421
+ const text = this.getText().trim();
422
+ if (!text) return 0;
423
+ return text.split(/\s+/).length;
424
+ }
425
+
426
+ getCharCount(): number {
427
+ return this.getText().length;
428
+ }
429
+
430
+ setReadOnly(ro: boolean): void {
431
+ if (!this._ctx) return;
432
+ const ctx = this._ctx;
433
+ const isRo = ctx.editor.contentEditable === 'false';
434
+ if (ro === isRo) return;
435
+ const btn = ctx.wrapper.querySelector('[data-cmd="toggleReadOnly"]') as HTMLElement | null;
436
+ if (btn) btn.click();
437
+ }
438
+
439
+ isReadOnly(): boolean {
440
+ return !!(this._ctx && this._ctx.editor && this._ctx.editor.contentEditable === 'false');
441
+ }
442
+
443
+ destroy(): void {
444
+ if (!this.container || !this.container.parentNode) return;
445
+ const instances = this._ctx ? this._ctx.TulihEditor.instances : [];
446
+ const myIdx = instances.indexOf(this);
447
+ if (myIdx !== -1) instances.splice(myIdx, 1);
448
+ if (this._ctx) this._ctx.TulihEditor.pluginManager.destroyAll(this._ctx);
449
+ const textarea = this.textarea;
450
+ if (textarea && this.editor) {
451
+ textarea.value = this.editor.innerHTML;
452
+ textarea.style.display = '';
453
+ }
454
+ this.container.parentNode.removeChild(this.container);
455
+ this.editor = null;
456
+ this.container = null;
457
+ this.toolbar = null;
458
+ this._ctx = null;
459
+ }
460
+ }
@@ -0,0 +1,140 @@
1
+ import type { Plugin, PluginContext, Features, PluginManagerInstance } from '../types';
2
+
3
+ /**
4
+ * Registry and lifecycle manager for plugins. Resolves toolbar order,
5
+ * dependency order, and one-time CSS injection.
6
+ */
7
+ export default class PluginManager implements PluginManagerInstance {
8
+ private _plugins: Plugin[] = [];
9
+ private _map: Record<string, Plugin> = {};
10
+ private _injectedCSS = new Set<string>();
11
+ private _cssInjected = false;
12
+
13
+ register(name: string, plugin: Plugin): this {
14
+ if (this._map[name]) {
15
+ throw new Error('Plugin "' + name + '" is already registered');
16
+ }
17
+ plugin.name = name;
18
+ plugin.deps = plugin.deps || [];
19
+ plugin.order = plugin.order || 100;
20
+ this._plugins.push(plugin);
21
+ this._map[name] = plugin;
22
+ return this;
23
+ }
24
+
25
+ unregister(name: string): void {
26
+ const plugin = this._map[name];
27
+ if (!plugin) return;
28
+ if (plugin.destroy) plugin.destroy(undefined as unknown as PluginContext);
29
+ const idx = this._plugins.indexOf(plugin);
30
+ if (idx !== -1) this._plugins.splice(idx, 1);
31
+ delete this._map[name];
32
+ }
33
+
34
+ get(name: string): Plugin | null {
35
+ return this._map[name] || null;
36
+ }
37
+
38
+ has(name: string): boolean {
39
+ return !!this._map[name];
40
+ }
41
+
42
+ getNames(): string[] {
43
+ return Object.keys(this._map);
44
+ }
45
+
46
+ private _resolveOrder(): Plugin[] {
47
+ const sorted: Plugin[] = [];
48
+ const visited: Record<string, boolean> = {};
49
+ const visiting: Record<string, boolean> = {};
50
+ const map = this._map;
51
+
52
+ function visit(name: string): void {
53
+ if (visited[name]) return;
54
+ if (visiting[name]) throw new Error('Circular dependency: ' + name);
55
+ visiting[name] = true;
56
+ const p = map[name];
57
+ if (p && p.deps) {
58
+ p.deps.forEach((dep) => {
59
+ if (!map[dep]) throw new Error('Plugin "' + name + '" depends on "' + dep + '" but not registered');
60
+ visit(dep);
61
+ });
62
+ }
63
+ visiting[name] = false;
64
+ visited[name] = true;
65
+ sorted.push(map[name]);
66
+ }
67
+
68
+ const ordered = this._plugins.slice().sort((a, b) => (a.order || 0) - (b.order || 0));
69
+ ordered.forEach((p) => visit(p.name));
70
+
71
+ return sorted;
72
+ }
73
+
74
+ initAll(ctx: PluginContext): void {
75
+ this._resolveOrder().forEach((plugin) => {
76
+ if (plugin.init) plugin.init(ctx);
77
+ });
78
+ }
79
+
80
+ destroyAll(ctx: PluginContext): void {
81
+ const reversed = this._plugins.slice().reverse();
82
+ reversed.forEach((plugin) => {
83
+ if (plugin.destroy) plugin.destroy(ctx);
84
+ });
85
+ }
86
+
87
+ getToolbarHTML(features: Features, toolbar?: string[][]): string {
88
+ if (toolbar && Array.isArray(toolbar)) {
89
+ const parts: string[] = [];
90
+ toolbar.forEach((group, i) => {
91
+ if (i > 0) parts.push('<div class="te-divider"></div>');
92
+ group.forEach((name) => {
93
+ const p = this._map[name];
94
+ if (!p || features[name] === false) return;
95
+ const html = typeof p.toolbarHTML === 'function' ? p.toolbarHTML(features) : p.toolbarHTML;
96
+ if (html) parts.push(html);
97
+ });
98
+ });
99
+ return parts.join('\n');
100
+ }
101
+
102
+ const plugins = this._resolveOrder().filter((p) => features[p.name] !== false && p.toolbarHTML);
103
+ const parts: string[] = [];
104
+ let lastGroup = -1;
105
+ plugins.forEach((p) => {
106
+ const html = typeof p.toolbarHTML === 'function' ? p.toolbarHTML(features) : p.toolbarHTML;
107
+ if (!html) return;
108
+ const group = Math.floor((p.order || 0) / 10);
109
+ if (lastGroup !== -1 && group !== lastGroup) {
110
+ parts.push('<div class="te-divider"></div>');
111
+ }
112
+ parts.push(html);
113
+ lastGroup = group;
114
+ });
115
+ return parts.join('\n');
116
+ }
117
+
118
+ getModalHTML(features: Features): string {
119
+ const plugins = this._resolveOrder().filter((p) => features[p.name] !== false && p.modalHTML);
120
+ return plugins
121
+ .map((p) => (typeof p.modalHTML === 'function' ? p.modalHTML(features) : p.modalHTML))
122
+ .filter(Boolean)
123
+ .join('\n');
124
+ }
125
+
126
+ injectAllCSS(): void {
127
+ if (this._cssInjected) return;
128
+ this._cssInjected = true;
129
+ this._resolveOrder().forEach((p) => {
130
+ if (!p.css || this._injectedCSS.has(p.name)) return;
131
+ this._injectedCSS.add(p.name);
132
+ const cssText = typeof p.css === 'function' ? p.css({}) : p.css;
133
+ if (!cssText) return;
134
+ const style = document.createElement('style');
135
+ style.setAttribute('data-te-css', p.name);
136
+ style.textContent = cssText;
137
+ document.head.appendChild(style);
138
+ });
139
+ }
140
+ }