@oix1987/yjd 1.0.3 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +15 -0
- package/README.md +223 -142
- package/core.js +82 -0
- package/dist/core.esm.js +2 -0
- package/dist/core.esm.js.map +1 -0
- package/dist/rich-editor.esm.js +1 -1
- package/dist/rich-editor.esm.js.map +1 -1
- package/dist/rich-editor.min.js +1 -1
- package/dist/rich-editor.min.js.map +1 -1
- package/index.d.ts +230 -103
- package/index.js +297 -0
- package/lib/core/editor.js +1885 -0
- package/lib/core/format.js +540 -0
- package/lib/core/module.js +81 -0
- package/lib/core/registry.js +158 -0
- package/lib/formats/background.js +213 -0
- package/lib/formats/bold.js +49 -0
- package/lib/formats/capitalization.js +579 -0
- package/lib/formats/color.js +183 -0
- package/lib/formats/emoji.js +282 -0
- package/lib/formats/font-family.js +548 -0
- package/lib/formats/heading.js +502 -0
- package/lib/formats/image.js +341 -0
- package/lib/formats/import.js +385 -0
- package/lib/formats/indent.js +297 -0
- package/lib/formats/italic.js +27 -0
- package/lib/formats/line-height.js +562 -0
- package/lib/formats/link.js +251 -0
- package/lib/formats/list.js +635 -0
- package/lib/formats/strike.js +31 -0
- package/lib/formats/subscript.js +40 -0
- package/lib/formats/superscript.js +39 -0
- package/lib/formats/table.js +293 -0
- package/lib/formats/tag.js +304 -0
- package/lib/formats/text-align.js +422 -0
- package/lib/formats/text-size.js +498 -0
- package/lib/formats/underline.js +30 -0
- package/lib/formats/video.js +381 -0
- package/lib/modules/block-toolbar.js +639 -0
- package/lib/modules/code-view.js +447 -0
- package/lib/modules/find-replace.js +273 -0
- package/lib/modules/history.js +425 -0
- package/lib/modules/mention.js +200 -0
- package/lib/modules/resize-handles.js +701 -0
- package/lib/modules/slash-menu.js +183 -0
- package/lib/modules/table-toolbar.js +635 -0
- package/lib/modules/toolbar.js +607 -0
- package/lib/serialize.js +241 -0
- package/lib/static.js +28 -0
- package/lib/styles-loader.js +142 -0
- package/{dist → lib}/styles.css +1392 -35
- package/lib/styles.css.js +2 -0
- package/lib/styles.min.css +1 -0
- package/lib/ui/color-picker.js +296 -0
- package/lib/ui/customselect.js +351 -0
- package/lib/ui/emoji-picker.js +196 -0
- package/lib/ui/icons.js +145 -0
- package/lib/ui/image-popup.js +435 -0
- package/lib/ui/import-popup.js +288 -0
- package/lib/ui/link-popup.js +139 -0
- package/lib/ui/list-picker.js +307 -0
- package/lib/ui/select-button.js +68 -0
- package/lib/ui/table-popup.js +171 -0
- package/lib/ui/tag-popup.js +249 -0
- package/lib/ui/text-align-picker.js +278 -0
- package/lib/ui/video-popup.js +413 -0
- package/lib/utils/exec-command.js +72 -0
- package/lib/utils/history-helper.js +50 -0
- package/lib/utils/popup-helper.js +219 -0
- package/lib/utils/popup-positioning.js +234 -0
- package/lib/utils/sanitize.js +164 -0
- package/package.json +51 -32
- package/umd-entry.js +19 -0
|
@@ -0,0 +1,607 @@
|
|
|
1
|
+
import Module from '../core/module.js';
|
|
2
|
+
import ColorPicker from '../ui/color-picker.js';
|
|
3
|
+
import IconUtils from '../ui/icons.js';
|
|
4
|
+
import createCustomButton from '../ui/select-button.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Toolbar Module - Pure UI component with dual toolbar support
|
|
8
|
+
* Only handles toolbar creation and event emission
|
|
9
|
+
* No business logic or state management
|
|
10
|
+
*/
|
|
11
|
+
class Toolbar extends Module {
|
|
12
|
+
static DEFAULTS = {
|
|
13
|
+
container: null,
|
|
14
|
+
toolbar1: [
|
|
15
|
+
// Most-used inline formatting leads; the block-style (Paragraph) picker
|
|
16
|
+
// sits after it rather than first.
|
|
17
|
+
{ group: 'text-format', items: ['bold', 'italic', 'underline', 'strike'] },
|
|
18
|
+
{ group: 'paragraph', items: ['heading'] },
|
|
19
|
+
{ group: 'colors', items: ['color', 'background'] },
|
|
20
|
+
{ group: 'link', items: ['link'] },
|
|
21
|
+
{ group: 'paragraph-ops', items: ['list', 'indent-increase', 'indent-decrease', 'text-align'] },
|
|
22
|
+
{ group: 'insert', items: ['image', 'table'] },
|
|
23
|
+
// Undo/redo live on the right and stay hidden until there's history.
|
|
24
|
+
{ group: 'history', items: ['undo', 'redo'] },
|
|
25
|
+
{ group: 'more', items: ['more'] }
|
|
26
|
+
],
|
|
27
|
+
toolbar2: [
|
|
28
|
+
{ group: 'font', items: ['font-family', 'text-size', 'line-height'] },
|
|
29
|
+
{ group: 'script', items: ['subscript', 'superscript', 'capitalization'] },
|
|
30
|
+
{ group: 'media', items: ['emoji', 'video', 'tag', 'horizontal-rule'] },
|
|
31
|
+
{ group: 'tools', items: ['clear-format', 'text-direction', 'find', 'code-view'] }
|
|
32
|
+
]
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
constructor(editor, options = {}) {
|
|
36
|
+
super(editor, options);
|
|
37
|
+
this.buttons = new Map();
|
|
38
|
+
this.toolbar2Visible = false;
|
|
39
|
+
this.events = new Map(); // Add event system
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
// Handle toolbar configuration
|
|
43
|
+
if (Array.isArray(options.toolbar)) {
|
|
44
|
+
// If toolbar array is provided, use only those items - COMPLETELY OVERRIDE DEFAULTS
|
|
45
|
+
this.options = {
|
|
46
|
+
container: null,
|
|
47
|
+
toolbar1: [
|
|
48
|
+
{ group: 'text-format', items: options.toolbar }
|
|
49
|
+
],
|
|
50
|
+
toolbar2: []
|
|
51
|
+
};
|
|
52
|
+
} else if (options.toolbar1 || options.toolbar2) {
|
|
53
|
+
// If specific toolbar1/toolbar2 config is provided, use it - COMPLETELY OVERRIDE DEFAULTS
|
|
54
|
+
this.options = {
|
|
55
|
+
container: null,
|
|
56
|
+
toolbar1: options.toolbar1 || [],
|
|
57
|
+
toolbar2: options.toolbar2 || []
|
|
58
|
+
};
|
|
59
|
+
} else {
|
|
60
|
+
// Use full default configuration
|
|
61
|
+
this.options = { ...Toolbar.DEFAULTS, ...options };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
this.init();
|
|
66
|
+
this.preloadIcons();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
init() {
|
|
70
|
+
this.container = this.createToolbarContainer();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Preload icons for better performance
|
|
75
|
+
*/
|
|
76
|
+
async preloadIcons() {
|
|
77
|
+
// Icons are now inline, no need to preload
|
|
78
|
+
// This method is kept for backward compatibility
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Create main toolbar container with both toolbars
|
|
83
|
+
*/
|
|
84
|
+
createToolbarContainer() {
|
|
85
|
+
const container = document.createElement('div');
|
|
86
|
+
container.className = 'rich-editor-toolbar-container';
|
|
87
|
+
container.setAttribute('role', 'toolbar');
|
|
88
|
+
container.setAttribute('aria-label', 'Text formatting');
|
|
89
|
+
|
|
90
|
+
// Prevent toolbar from taking focus away from editor
|
|
91
|
+
this.editor.preventFocusLoss(container);
|
|
92
|
+
|
|
93
|
+
// Keep the editor's text selection when a toolbar button is pressed (mouse
|
|
94
|
+
// OR touch). Without this, tapping e.g. Bold on mobile can clear the
|
|
95
|
+
// selection before the click handler runs, so the format applies to nothing.
|
|
96
|
+
// preventing pointerdown's default stops the focus/selection change while
|
|
97
|
+
// the click still fires normally.
|
|
98
|
+
container.addEventListener('pointerdown', (e) => {
|
|
99
|
+
if (e.target.closest('button')) e.preventDefault();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// Primary (always-visible) row and overflow ("more") row
|
|
103
|
+
this.toolbar1 = document.createElement('div');
|
|
104
|
+
this.toolbar1.className = 'rich-editor-toolbar-1';
|
|
105
|
+
this.toolbar2 = document.createElement('div');
|
|
106
|
+
this.toolbar2.className = 'rich-editor-toolbar-2';
|
|
107
|
+
this.toolbar2.style.display = 'none';
|
|
108
|
+
|
|
109
|
+
// Build every group (toolbar1 first = higher priority, then toolbar2) into
|
|
110
|
+
// the primary row. reflow() later moves whatever doesn't fit into the
|
|
111
|
+
// overflow row, so the toolbar adapts to any width instead of wrapping.
|
|
112
|
+
this.flowGroups = [];
|
|
113
|
+
const merged = [...(this.options.toolbar1 || []), ...(this.options.toolbar2 || [])];
|
|
114
|
+
merged.forEach(group => {
|
|
115
|
+
if (!group || !group.group || !Array.isArray(group.items)) return;
|
|
116
|
+
// The "more" toggle is managed separately (added at the end).
|
|
117
|
+
if (group.items.length === 1 && group.items[0] === 'more') return;
|
|
118
|
+
const groupContainer = document.createElement('div');
|
|
119
|
+
groupContainer.className = `toolbar-group toolbar-group-${group.group}`;
|
|
120
|
+
group.items.forEach(item => {
|
|
121
|
+
if (typeof item === 'string') this.addButton(groupContainer, item);
|
|
122
|
+
});
|
|
123
|
+
this.toolbar1.appendChild(groupContainer);
|
|
124
|
+
this.flowGroups.push(groupContainer);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// The "more" button lives at the end of the primary row; shown only when
|
|
128
|
+
// there is overflow.
|
|
129
|
+
this.addMoreButton(this.toolbar1);
|
|
130
|
+
this.moreBtn = this.buttons.get('more');
|
|
131
|
+
if (this.moreBtn) this.moreBtn.classList.add('more-btn');
|
|
132
|
+
|
|
133
|
+
container.appendChild(this.toolbar1);
|
|
134
|
+
container.appendChild(this.toolbar2);
|
|
135
|
+
|
|
136
|
+
// Responsive reflow: re-distribute groups whenever the toolbar resizes.
|
|
137
|
+
if (typeof ResizeObserver !== 'undefined') {
|
|
138
|
+
this._ro = new ResizeObserver(() => this._scheduleReflow());
|
|
139
|
+
this._ro.observe(container);
|
|
140
|
+
}
|
|
141
|
+
requestAnimationFrame(() => this.reflow());
|
|
142
|
+
|
|
143
|
+
// Keyboard navigation (arrow keys move between buttons; roving tabindex).
|
|
144
|
+
this._setupKeyboardNav(container);
|
|
145
|
+
|
|
146
|
+
return container;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* All currently focusable (visible, enabled) toolbar buttons in order.
|
|
151
|
+
*/
|
|
152
|
+
_focusableButtons() {
|
|
153
|
+
return Array.from(
|
|
154
|
+
this.container.querySelectorAll('.rich-editor-toolbar-btn, .custom-select-button')
|
|
155
|
+
).filter(b => !b.disabled && b.offsetParent !== null);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Roving tabindex: only one button is in the tab order at a time.
|
|
160
|
+
*/
|
|
161
|
+
_updateRoving() {
|
|
162
|
+
const btns = this._focusableButtons();
|
|
163
|
+
btns.forEach((b, i) => { b.tabIndex = i === 0 ? 0 : -1; });
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Arrow-key navigation across the toolbar (ARIA toolbar pattern).
|
|
168
|
+
*/
|
|
169
|
+
_setupKeyboardNav(container) {
|
|
170
|
+
container.addEventListener('keydown', (e) => {
|
|
171
|
+
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(e.key)) return;
|
|
172
|
+
const btns = this._focusableButtons();
|
|
173
|
+
if (!btns.length) return;
|
|
174
|
+
const cur = btns.indexOf(document.activeElement);
|
|
175
|
+
let next;
|
|
176
|
+
if (e.key === 'Home') next = 0;
|
|
177
|
+
else if (e.key === 'End') next = btns.length - 1;
|
|
178
|
+
else if (cur === -1) next = 0;
|
|
179
|
+
else next = e.key === 'ArrowRight'
|
|
180
|
+
? (cur + 1) % btns.length
|
|
181
|
+
: (cur - 1 + btns.length) % btns.length;
|
|
182
|
+
e.preventDefault();
|
|
183
|
+
btns.forEach(b => { b.tabIndex = -1; });
|
|
184
|
+
btns[next].tabIndex = 0;
|
|
185
|
+
btns[next].focus();
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Debounce reflow to one run per animation frame.
|
|
191
|
+
*/
|
|
192
|
+
_scheduleReflow() {
|
|
193
|
+
if (this._reflowQueued) return;
|
|
194
|
+
this._reflowQueued = true;
|
|
195
|
+
requestAnimationFrame(() => {
|
|
196
|
+
this._reflowQueued = false;
|
|
197
|
+
this.reflow();
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Distribute groups between the primary row and the overflow ("more") row so
|
|
203
|
+
* the primary row always fits on a single line at the current width.
|
|
204
|
+
*/
|
|
205
|
+
reflow() {
|
|
206
|
+
if (!this.toolbar1 || !this.flowGroups || !this.moreBtn) return;
|
|
207
|
+
const GAP = 12; // matches .toolbar-group spacing
|
|
208
|
+
|
|
209
|
+
// Pull every group back into the primary row (in priority order) to measure.
|
|
210
|
+
this.flowGroups.forEach(g => this.toolbar1.insertBefore(g, this.moreBtn));
|
|
211
|
+
|
|
212
|
+
// On small screens, skip the "More" split entirely — keep every tool in one
|
|
213
|
+
// horizontally-scrollable row (how Google Docs / Notion handle mobile)
|
|
214
|
+
// instead of wrapping into a cramped multi-row panel.
|
|
215
|
+
if (typeof window !== 'undefined' && window.matchMedia &&
|
|
216
|
+
window.matchMedia('(max-width: 640px)').matches) {
|
|
217
|
+
this.moreBtn.style.display = 'none';
|
|
218
|
+
this.toolbar2.style.display = 'none';
|
|
219
|
+
this.toolbar2Visible = false;
|
|
220
|
+
this._syncMoreButton();
|
|
221
|
+
this._updateRoving();
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const cs = getComputedStyle(this.toolbar1);
|
|
226
|
+
const avail = this.toolbar1.clientWidth -
|
|
227
|
+
(parseFloat(cs.paddingLeft) || 0) - (parseFloat(cs.paddingRight) || 0);
|
|
228
|
+
if (avail <= 0) return; // not laid out yet; will reflow on resize
|
|
229
|
+
|
|
230
|
+
let total = 0;
|
|
231
|
+
this.flowGroups.forEach((g, i) => { total += g.offsetWidth + (i > 0 ? GAP : 0); });
|
|
232
|
+
|
|
233
|
+
if (total <= avail) {
|
|
234
|
+
// Everything fits — no overflow needed.
|
|
235
|
+
this.moreBtn.style.display = 'none';
|
|
236
|
+
this.toolbar2.style.display = 'none';
|
|
237
|
+
this.toolbar2Visible = false;
|
|
238
|
+
this._syncMoreButton();
|
|
239
|
+
this._updateRoving();
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Overflow needed — keep groups that fit (reserving room for "more").
|
|
244
|
+
const budget = avail - ((this.moreBtn.offsetWidth || 32) + GAP);
|
|
245
|
+
let used = 0;
|
|
246
|
+
let cut = this.flowGroups.length;
|
|
247
|
+
for (let i = 0; i < this.flowGroups.length; i++) {
|
|
248
|
+
const w = this.flowGroups[i].offsetWidth + (i > 0 ? GAP : 0);
|
|
249
|
+
if (used + w > budget) { cut = i; break; }
|
|
250
|
+
used += w;
|
|
251
|
+
}
|
|
252
|
+
if (cut < 1) cut = 1; // always keep at least one group visible
|
|
253
|
+
|
|
254
|
+
for (let i = cut; i < this.flowGroups.length; i++) {
|
|
255
|
+
this.toolbar2.appendChild(this.flowGroups[i]);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
this.moreBtn.style.display = '';
|
|
259
|
+
this.toolbar2.style.display = this.toolbar2Visible ? 'flex' : 'none';
|
|
260
|
+
this._syncMoreButton();
|
|
261
|
+
this._updateRoving();
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Sync the "more" button visual state with toolbar2 visibility.
|
|
266
|
+
*/
|
|
267
|
+
_syncMoreButton() {
|
|
268
|
+
const m = this.moreBtn;
|
|
269
|
+
if (!m) return;
|
|
270
|
+
m.setAttribute('aria-expanded', this.toolbar2Visible ? 'true' : 'false');
|
|
271
|
+
if (this.toolbar2Visible) {
|
|
272
|
+
m.classList.add('active');
|
|
273
|
+
m.title = 'Hide more options';
|
|
274
|
+
} else {
|
|
275
|
+
m.classList.remove('active');
|
|
276
|
+
m.title = 'More options';
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Create toolbar element
|
|
282
|
+
*/
|
|
283
|
+
createToolbar(className, toolbarItems) {
|
|
284
|
+
const toolbar = document.createElement('div');
|
|
285
|
+
toolbar.className = className;
|
|
286
|
+
|
|
287
|
+
// Create button groups based on toolbar config
|
|
288
|
+
if (Array.isArray(toolbarItems)) {
|
|
289
|
+
toolbarItems.forEach(group => {
|
|
290
|
+
if (group && group.group && Array.isArray(group.items)) {
|
|
291
|
+
// Create group container
|
|
292
|
+
const groupContainer = document.createElement('div');
|
|
293
|
+
groupContainer.className = `toolbar-group toolbar-group-${group.group}`;
|
|
294
|
+
|
|
295
|
+
// Add buttons to group
|
|
296
|
+
group.items.forEach(item => {
|
|
297
|
+
if (typeof item === 'string') {
|
|
298
|
+
this.addButton(groupContainer, item);
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
toolbar.appendChild(groupContainer);
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
return toolbar;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Add button to toolbar
|
|
312
|
+
*/
|
|
313
|
+
addButton(container, format) {
|
|
314
|
+
// Special handling for more button
|
|
315
|
+
if (format === 'more') {
|
|
316
|
+
return this.addMoreButton(container);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Custom buttons with dropdowns
|
|
320
|
+
const customButtons = {
|
|
321
|
+
'heading': { text: 'Paragraph', width: '124px', title: 'Paragraph style', icon: 'heading' },
|
|
322
|
+
'font-family': { text: 'Font Family', width: '156px', title: 'Font', icon: 'font-family' },
|
|
323
|
+
'line-height': { text: 'Line Height', width: '116px', title: 'Line spacing', icon: 'line-height' },
|
|
324
|
+
'capitalization': { text: 'Capitalization', width: '146px', title: 'Letter case', icon: 'capitalization' },
|
|
325
|
+
'text-size': { text: 'Text Size', width: '116px', title: 'Font size', icon: 'text-size' }
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
if (customButtons[format]) {
|
|
329
|
+
const config = customButtons[format];
|
|
330
|
+
const customButton = createCustomButton(config.text, { width: config.width, icon: config.icon });
|
|
331
|
+
customButton.dataset.command = format;
|
|
332
|
+
customButton.classList.add('rich-editor-toolbar-btn', `${format}-btn`);
|
|
333
|
+
customButton.title = config.title;
|
|
334
|
+
customButton.setAttribute('aria-label', config.title);
|
|
335
|
+
customButton.setAttribute('aria-haspopup', 'true');
|
|
336
|
+
|
|
337
|
+
customButton.addEventListener('click', (e) => {
|
|
338
|
+
e.preventDefault();
|
|
339
|
+
this.emit('toolbar-click', { command: format, button: customButton });
|
|
340
|
+
// Maintain editor focus after button click
|
|
341
|
+
setTimeout(() => {
|
|
342
|
+
this.editor.focus();
|
|
343
|
+
}, 0);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
this.buttons.set(format, customButton);
|
|
347
|
+
container.appendChild(customButton);
|
|
348
|
+
return customButton;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Icon buttons with popups
|
|
352
|
+
const iconButtons = {
|
|
353
|
+
'text-align': { icon: 'align-left', title: 'Align Left' },
|
|
354
|
+
'list': { icon: 'list', title: 'List' }
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
if (iconButtons[format]) {
|
|
358
|
+
const config = iconButtons[format];
|
|
359
|
+
const button = document.createElement('button');
|
|
360
|
+
button.type = 'button';
|
|
361
|
+
button.className = `rich-editor-toolbar-btn ${format}-btn`;
|
|
362
|
+
button.dataset.command = format;
|
|
363
|
+
button.title = config.title;
|
|
364
|
+
button.setAttribute('aria-label', config.title);
|
|
365
|
+
|
|
366
|
+
const svgContent = IconUtils.getIcon(config.icon);
|
|
367
|
+
if (svgContent) {
|
|
368
|
+
button.innerHTML = `<span class="icon">${svgContent}</span>`;
|
|
369
|
+
} else {
|
|
370
|
+
button.textContent = format === 'text-align' ? '≡' : '•';
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
button.addEventListener('click', (e) => {
|
|
374
|
+
e.preventDefault();
|
|
375
|
+
this.emit('toolbar-click', { command: format, button: button });
|
|
376
|
+
// Maintain editor focus after button click
|
|
377
|
+
setTimeout(() => {
|
|
378
|
+
this.editor.focus();
|
|
379
|
+
}, 0);
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
this.buttons.set(format, button);
|
|
383
|
+
container.appendChild(button);
|
|
384
|
+
return button;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Regular icon buttons
|
|
388
|
+
const button = document.createElement('button');
|
|
389
|
+
button.type = 'button';
|
|
390
|
+
button.className = `rich-editor-toolbar-btn ${format}-btn`;
|
|
391
|
+
button.dataset.command = format;
|
|
392
|
+
|
|
393
|
+
// Add icon
|
|
394
|
+
const iconElement = IconUtils.createIconElement(format, {
|
|
395
|
+
width: '16px',
|
|
396
|
+
height: '16px'
|
|
397
|
+
});
|
|
398
|
+
button.appendChild(iconElement);
|
|
399
|
+
|
|
400
|
+
// Set title based on format
|
|
401
|
+
const titles = {
|
|
402
|
+
'bold': 'Bold (Ctrl+B)',
|
|
403
|
+
'italic': 'Italic (Ctrl+I)',
|
|
404
|
+
'underline': 'Underline (Ctrl+U)',
|
|
405
|
+
'strike': 'Strikethrough',
|
|
406
|
+
'subscript': 'Subscript',
|
|
407
|
+
'superscript': 'Superscript',
|
|
408
|
+
'color': 'Text Color',
|
|
409
|
+
'background': 'Background Color',
|
|
410
|
+
'link': 'Insert/Edit Link',
|
|
411
|
+
'table': 'Insert Table',
|
|
412
|
+
'undo': 'Undo (Ctrl+Z)',
|
|
413
|
+
'redo': 'Redo (Ctrl+Y)',
|
|
414
|
+
'indent-increase': 'Increase Indent',
|
|
415
|
+
'indent-decrease': 'Decrease Indent',
|
|
416
|
+
'emoji': 'Insert Emoji',
|
|
417
|
+
'image': 'Insert Image',
|
|
418
|
+
'video': 'Insert Video',
|
|
419
|
+
'tag': 'Insert Tag',
|
|
420
|
+
'horizontal-rule': 'Insert Horizontal Rule',
|
|
421
|
+
'clear-format': 'Clear Formatting',
|
|
422
|
+
'text-direction': 'Toggle Text Direction (LTR/RTL)',
|
|
423
|
+
'find': 'Find & Replace (Ctrl+F)',
|
|
424
|
+
|
|
425
|
+
'import': 'Import Files',
|
|
426
|
+
'code-view': 'Switch to HTML Editor',
|
|
427
|
+
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
button.title = titles[format] || format;
|
|
431
|
+
button.setAttribute('aria-label', button.title);
|
|
432
|
+
|
|
433
|
+
// Colour buttons get a swatch bar that reflects the colour at the caret.
|
|
434
|
+
if (format === 'color' || format === 'background') {
|
|
435
|
+
const swatch = document.createElement('span');
|
|
436
|
+
swatch.className = 'rte-swatch';
|
|
437
|
+
button.appendChild(swatch);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// Add fallback for code-view
|
|
441
|
+
if (format === 'code-view') {
|
|
442
|
+
setTimeout(() => {
|
|
443
|
+
if (!iconElement.innerHTML.trim()) {
|
|
444
|
+
iconElement.innerHTML = '</>';
|
|
445
|
+
iconElement.style.fontSize = '12px';
|
|
446
|
+
iconElement.style.fontWeight = 'bold';
|
|
447
|
+
}
|
|
448
|
+
}, 1000);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
button.addEventListener('click', (e) => {
|
|
452
|
+
e.preventDefault();
|
|
453
|
+
this.emit('toolbar-click', { command: format, button });
|
|
454
|
+
// Maintain editor focus after button click
|
|
455
|
+
setTimeout(() => {
|
|
456
|
+
this.editor.focus();
|
|
457
|
+
}, 0);
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
this.buttons.set(format, button);
|
|
461
|
+
container.appendChild(button);
|
|
462
|
+
return button;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Add more button to toggle toolbar 2
|
|
467
|
+
*/
|
|
468
|
+
addMoreButton(container) {
|
|
469
|
+
const button = document.createElement('button');
|
|
470
|
+
button.type = 'button';
|
|
471
|
+
button.className = 'rich-editor-toolbar-btn more-btn';
|
|
472
|
+
button.dataset.command = 'more';
|
|
473
|
+
|
|
474
|
+
const iconElement = IconUtils.createIconElement('more', {
|
|
475
|
+
width: '16px',
|
|
476
|
+
height: '16px'
|
|
477
|
+
});
|
|
478
|
+
button.appendChild(iconElement);
|
|
479
|
+
button.title = 'More Options';
|
|
480
|
+
button.setAttribute('aria-label', 'More Options');
|
|
481
|
+
button.setAttribute('aria-expanded', 'false');
|
|
482
|
+
|
|
483
|
+
button.addEventListener('click', (e) => {
|
|
484
|
+
e.preventDefault();
|
|
485
|
+
this.toggleToolbar2();
|
|
486
|
+
// Maintain editor focus after button click
|
|
487
|
+
setTimeout(() => {
|
|
488
|
+
this.editor.focus();
|
|
489
|
+
}, 0);
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
this.buttons.set('more', button);
|
|
493
|
+
container.appendChild(button);
|
|
494
|
+
return button;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Toggle toolbar 2 visibility
|
|
499
|
+
*/
|
|
500
|
+
toggleToolbar2() {
|
|
501
|
+
// Nothing to toggle when there's no overflow.
|
|
502
|
+
if (this.moreBtn && this.moreBtn.style.display === 'none') return;
|
|
503
|
+
|
|
504
|
+
this.toolbar2Visible = !this.toolbar2Visible;
|
|
505
|
+
this.toolbar2.style.display = this.toolbar2Visible ? 'flex' : 'none';
|
|
506
|
+
this._syncMoreButton();
|
|
507
|
+
this._updateRoving();
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Get toolbar container element
|
|
512
|
+
*/
|
|
513
|
+
getContainer() {
|
|
514
|
+
return this.container;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* Get button by command
|
|
519
|
+
*/
|
|
520
|
+
getButton(command) {
|
|
521
|
+
return this.buttons.get(command);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* Set button active state
|
|
526
|
+
*/
|
|
527
|
+
setButtonActive(command, isActive) {
|
|
528
|
+
const button = this.buttons.get(command);
|
|
529
|
+
if (button && button.classList) {
|
|
530
|
+
if (isActive) {
|
|
531
|
+
button.classList.add('active');
|
|
532
|
+
} else {
|
|
533
|
+
button.classList.remove('active');
|
|
534
|
+
}
|
|
535
|
+
button.setAttribute('aria-pressed', isActive ? 'true' : 'false');
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* Set button disabled state
|
|
541
|
+
*/
|
|
542
|
+
setButtonDisabled(command, isDisabled) {
|
|
543
|
+
const button = this.buttons.get(command);
|
|
544
|
+
if (button) {
|
|
545
|
+
button.disabled = isDisabled;
|
|
546
|
+
button.style.opacity = isDisabled ? '0.5' : '1';
|
|
547
|
+
button.style.cursor = isDisabled ? 'not-allowed' : 'pointer';
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* Set button title
|
|
553
|
+
*/
|
|
554
|
+
setButtonTitle(command, title) {
|
|
555
|
+
const button = this.buttons.get(command);
|
|
556
|
+
if (button) {
|
|
557
|
+
button.title = title;
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* Check if toolbar 2 is visible
|
|
563
|
+
*/
|
|
564
|
+
isToolbar2Visible() {
|
|
565
|
+
return this.toolbar2Visible;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* Event system methods
|
|
570
|
+
*/
|
|
571
|
+
on(event, callback) {
|
|
572
|
+
if (!this.events.has(event)) {
|
|
573
|
+
this.events.set(event, []);
|
|
574
|
+
}
|
|
575
|
+
this.events.get(event).push(callback);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
emit(event, data) {
|
|
579
|
+
const callbacks = this.events.get(event);
|
|
580
|
+
if (callbacks) {
|
|
581
|
+
callbacks.forEach(callback => {
|
|
582
|
+
try {
|
|
583
|
+
callback(data);
|
|
584
|
+
} catch (error) {
|
|
585
|
+
console.error(`Error in toolbar event ${event}:`, error);
|
|
586
|
+
}
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* Destroy toolbar
|
|
593
|
+
*/
|
|
594
|
+
destroy() {
|
|
595
|
+
if (this._ro) {
|
|
596
|
+
this._ro.disconnect();
|
|
597
|
+
this._ro = null;
|
|
598
|
+
}
|
|
599
|
+
if (this.container && this.container.parentNode) {
|
|
600
|
+
this.container.parentNode.removeChild(this.container);
|
|
601
|
+
}
|
|
602
|
+
this.buttons.clear();
|
|
603
|
+
this.events.clear();
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
export default Toolbar;
|