autumnnote 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 (50) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +874 -0
  3. package/dist/autumnnote.css +1 -0
  4. package/dist/autumnnote.es.js +5888 -0
  5. package/dist/autumnnote.es.js.map +1 -0
  6. package/dist/autumnnote.umd.js +74 -0
  7. package/dist/autumnnote.umd.js.map +1 -0
  8. package/package.json +55 -0
  9. package/src/js/Context.js +497 -0
  10. package/src/js/core/dom.js +315 -0
  11. package/src/js/core/env.js +25 -0
  12. package/src/js/core/func.js +153 -0
  13. package/src/js/core/key.js +66 -0
  14. package/src/js/core/lists.js +121 -0
  15. package/src/js/core/markdown.js +294 -0
  16. package/src/js/core/range.js +194 -0
  17. package/src/js/core/sanitise.js +78 -0
  18. package/src/js/editing/History.js +205 -0
  19. package/src/js/editing/Style.js +329 -0
  20. package/src/js/editing/Table.js +59 -0
  21. package/src/js/editing/Typing.js +142 -0
  22. package/src/js/index.js +126 -0
  23. package/src/js/module/Buttons.js +300 -0
  24. package/src/js/module/Clipboard.js +460 -0
  25. package/src/js/module/CodeTooltip.js +428 -0
  26. package/src/js/module/Codeview.js +122 -0
  27. package/src/js/module/ContextMenu.js +470 -0
  28. package/src/js/module/Editor.js +528 -0
  29. package/src/js/module/EmojiDialog.js +726 -0
  30. package/src/js/module/FindReplace.js +440 -0
  31. package/src/js/module/Fullscreen.js +80 -0
  32. package/src/js/module/IconDialog.js +620 -0
  33. package/src/js/module/ImageDialog.js +208 -0
  34. package/src/js/module/ImageResizer.js +216 -0
  35. package/src/js/module/ImageTooltip.js +286 -0
  36. package/src/js/module/LinkDialog.js +204 -0
  37. package/src/js/module/LinkTooltip.js +242 -0
  38. package/src/js/module/Placeholder.js +44 -0
  39. package/src/js/module/ShortcutsDialog.js +141 -0
  40. package/src/js/module/Statusbar.js +238 -0
  41. package/src/js/module/TableTooltip.js +568 -0
  42. package/src/js/module/Toolbar.js +562 -0
  43. package/src/js/module/VideoDialog.js +263 -0
  44. package/src/js/module/VideoResizer.js +227 -0
  45. package/src/js/module/VideoTooltip.js +252 -0
  46. package/src/js/renderer.js +107 -0
  47. package/src/js/settings.js +134 -0
  48. package/src/styles/_variables.scss +48 -0
  49. package/src/styles/autumnnote.scss +1740 -0
  50. package/types/index.d.ts +324 -0
@@ -0,0 +1,562 @@
1
+ /**
2
+ * Toolbar.js - Builds and manages the editor toolbar UI
3
+ * Inspired by Summernote's Toolbar module — rewritten without jQuery
4
+ */
5
+
6
+ import { createElement, on } from '../core/dom.js';
7
+
8
+ export class Toolbar {
9
+ /**
10
+ * @param {import('../Context.js').Context} context
11
+ */
12
+ constructor(context) {
13
+ this.context = context;
14
+ this.options = context.options;
15
+ /** @type {HTMLElement|null} */
16
+ this.el = null;
17
+ /** @type {Array<() => void>} disposers */
18
+ this._disposers = [];
19
+ }
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // Lifecycle
23
+ // ---------------------------------------------------------------------------
24
+
25
+ initialize() {
26
+ this.el = createElement('div', { class: 'an-toolbar' });
27
+ // Detect FontAwesome once at toolbar build time to avoid re-querying the DOM
28
+ // for every button rendered.
29
+ this._faReady = this._detectFontAwesome();
30
+ this._buildButtons();
31
+ this._btnMap = new Map((this.options.toolbar || []).flat().map((b) => [b.name, b]));
32
+ return this;
33
+ }
34
+
35
+ destroy() {
36
+ this._disposers.forEach((d) => d());
37
+ this._disposers = [];
38
+ if (this.el && this.el.parentNode) {
39
+ this.el.parentNode.removeChild(this.el);
40
+ }
41
+ this.el = null;
42
+ }
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // Build
46
+ // ---------------------------------------------------------------------------
47
+
48
+ _buildButtons() {
49
+ const toolbar = this.options.toolbar || [];
50
+ toolbar.forEach((group) => {
51
+ const groupEl = createElement('div', { class: 'an-btn-group' });
52
+ group.forEach((btnDef) => {
53
+ let el;
54
+ if (btnDef.type === 'select') el = this._createSelect(btnDef);
55
+ else if (btnDef.type === 'grid') el = this._createGridPicker(btnDef);
56
+ else if (btnDef.type === 'colorpicker') el = this._createColorPicker(btnDef);
57
+ else el = this._createButton(btnDef);
58
+ groupEl.appendChild(el);
59
+ });
60
+ this.el.appendChild(groupEl);
61
+ });
62
+ }
63
+
64
+ /**
65
+ * Creates a table-grid picker button with a hoverable row/col selector popup.
66
+ * @param {import('./Buttons.js').ButtonDef} def
67
+ * @returns {HTMLDivElement}
68
+ */
69
+ _createGridPicker(def) {
70
+ const ROWS = 10;
71
+ const COLS = 10;
72
+
73
+ const wrap = createElement('div', { class: 'an-table-picker-wrap' });
74
+
75
+ const useBootstrap = !!this.options.useBootstrap;
76
+ const baseClass = useBootstrap
77
+ ? (this.options.toolbarButtonClass || 'btn btn-sm btn-light')
78
+ : 'an-btn';
79
+ const btn = createElement('button', {
80
+ type: 'button',
81
+ class: baseClass,
82
+ title: def.tooltip || '',
83
+ 'data-btn': def.name,
84
+ 'aria-label': def.tooltip || def.name,
85
+ 'aria-haspopup': 'true',
86
+ 'aria-expanded': 'false',
87
+ });
88
+
89
+ // Set icon — inline SVG (table) with optional FontAwesome fallback
90
+ if (this._faReady) {
91
+ const faPrefix = this.options.fontAwesomeClass || 'fas';
92
+ btn.innerHTML = `<i class="${faPrefix} fa-table" aria-hidden="true"></i>`;
93
+ } else {
94
+ const S = 'stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"';
95
+ btn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" ${S} style="display:block"><rect x="3" y="3" width="18" height="18" rx="1"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="3" y1="15" x2="21" y2="15"/><line x1="9" y1="3" x2="9" y2="21"/><line x1="15" y1="3" x2="15" y2="21"/></svg>`;
96
+ }
97
+
98
+ // Popup
99
+ const popup = createElement('div', {
100
+ class: 'an-table-picker-popup',
101
+ role: 'dialog',
102
+ 'aria-label': 'Select table size',
103
+ });
104
+ const grid = createElement('div', { class: 'an-table-grid' });
105
+ const label = createElement('div', { class: 'an-table-label' });
106
+ label.textContent = 'Insert Table';
107
+
108
+ const cells = [];
109
+ for (let r = 1; r <= ROWS; r++) {
110
+ for (let c = 1; c <= COLS; c++) {
111
+ const cell = createElement('div', {
112
+ class: 'an-table-cell',
113
+ 'data-row': String(r),
114
+ 'data-col': String(c),
115
+ });
116
+ cells.push(cell);
117
+ grid.appendChild(cell);
118
+ }
119
+ }
120
+
121
+ popup.appendChild(grid);
122
+ popup.appendChild(label);
123
+
124
+ let isOpen = false;
125
+
126
+ const setHighlight = (rows, cols) => {
127
+ cells.forEach((cell) => {
128
+ const r = +cell.getAttribute('data-row');
129
+ const c = +cell.getAttribute('data-col');
130
+ cell.classList.toggle('active', r <= rows && c <= cols);
131
+ });
132
+ label.textContent = (rows && cols) ? `${rows} × ${cols}` : 'Insert Table';
133
+ };
134
+
135
+ const openPopup = () => {
136
+ isOpen = true;
137
+ popup.style.display = 'block';
138
+ btn.setAttribute('aria-expanded', 'true');
139
+ };
140
+
141
+ const closePopup = () => {
142
+ isOpen = false;
143
+ popup.style.display = 'none';
144
+ btn.setAttribute('aria-expanded', 'false');
145
+ setHighlight(0, 0);
146
+ };
147
+
148
+ const d1 = on(btn, 'click', (e) => {
149
+ e.stopPropagation();
150
+ if (isOpen) closePopup(); else openPopup();
151
+ });
152
+
153
+ const d2 = on(grid, 'mouseover', (e) => {
154
+ const cell = e.target.closest('.an-table-cell');
155
+ if (!cell) return;
156
+ setHighlight(+cell.getAttribute('data-row'), +cell.getAttribute('data-col'));
157
+ });
158
+
159
+ const d3 = on(grid, 'mouseleave', () => setHighlight(0, 0));
160
+
161
+ const d4 = on(grid, 'click', (e) => {
162
+ const cell = e.target.closest('.an-table-cell');
163
+ if (!cell) return;
164
+ const rows = +cell.getAttribute('data-row');
165
+ const cols = +cell.getAttribute('data-col');
166
+ closePopup();
167
+ this.context.invoke('editor.focus');
168
+ def.action(this.context, rows, cols);
169
+ });
170
+
171
+ const d5 = on(document, 'click', () => { if (isOpen) closePopup(); });
172
+
173
+ this._disposers.push(d1, d2, d3, d4, d5);
174
+
175
+ wrap.appendChild(btn);
176
+ wrap.appendChild(popup);
177
+ return wrap;
178
+ }
179
+
180
+ /**
181
+ * Creates a split color-picker widget:
182
+ * [icon + strip | ▾] — left applies current color, right opens swatch popup.
183
+ * @param {{ name: string, type: 'colorpicker', tooltip: string, defaultColor: string, action: Function }} def
184
+ * @returns {HTMLDivElement}
185
+ */
186
+ _createColorPicker(def) {
187
+ const PRESETS = [
188
+ // Grayscale
189
+ '#000000', '#434343', '#666666', '#999999', '#b7b7b7', '#cccccc', '#efefef', '#ffffff',
190
+ // Saturated
191
+ '#ff0000', '#ff9900', '#ffff00', '#00ff00', '#00ffff', '#4a86e8', '#9900ff', '#ff00ff',
192
+ // Pastel
193
+ '#f4cccc', '#fce5cd', '#fff2cc', '#d9ead3', '#d0e0e3', '#c9daf8', '#d9d2e9', '#ead1dc',
194
+ ];
195
+
196
+ let currentColor = def.defaultColor || '#000000';
197
+
198
+ const wrap = createElement('div', { class: 'an-color-picker-wrap' });
199
+
200
+ const useBootstrap = !!this.options.useBootstrap;
201
+ const baseClass = useBootstrap ? (this.options.toolbarButtonClass || 'btn btn-sm btn-light') : 'an-btn';
202
+
203
+ // ---- Apply button (icon + color strip) ----
204
+ const applyBtn = createElement('button', {
205
+ type: 'button',
206
+ class: `${baseClass} an-color-btn`,
207
+ title: def.tooltip || '',
208
+ 'data-btn': def.name,
209
+ 'aria-label': def.tooltip || def.name,
210
+ });
211
+
212
+ const S = 'stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"';
213
+ const iconSvg = def.name === 'foreColor'
214
+ ? `<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" ${S} style="display:block"><path d="M4 20L12 4L20 20"/><line x1="7.5" y1="14" x2="16.5" y2="14"/></svg>`
215
+ : `<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" ${S} style="display:block"><path d="M3 21v-4l9-9 4 4-9 9z"/><path d="M12 8l4 4"/></svg>`;
216
+
217
+ applyBtn.innerHTML = iconSvg;
218
+ const strip = createElement('span', { class: 'an-color-strip' });
219
+ strip.style.background = currentColor;
220
+ applyBtn.appendChild(strip);
221
+
222
+ // ---- Arrow button (open popup) ----
223
+ const arrowBtn = createElement('button', {
224
+ type: 'button',
225
+ class: `${baseClass} an-color-arrow`,
226
+ title: `Choose ${def.name === 'foreColor' ? 'text' : 'highlight'} color`,
227
+ 'aria-haspopup': 'true',
228
+ 'aria-expanded': 'false',
229
+ });
230
+ arrowBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="8" height="8" viewBox="0 0 24 24" fill="currentColor" stroke="none" style="display:block"><path d="M7 10l5 5 5-5H7z"/></svg>`;
231
+
232
+ // ---- Popup ----
233
+ const popup = createElement('div', { class: 'an-color-popup' });
234
+ popup.style.display = 'none';
235
+
236
+ const swatches = createElement('div', { class: 'an-color-swatches' });
237
+ const userSwatches = Array.isArray(this.options.colorSwatches) ? this.options.colorSwatches : [];
238
+ const allColors = [...new Set([...userSwatches, ...PRESETS])];
239
+ allColors.forEach((color) => {
240
+ const sw = createElement('div', { class: 'an-color-swatch', title: color, 'data-color': color });
241
+ sw.style.background = color;
242
+ swatches.appendChild(sw);
243
+ });
244
+
245
+ const customRow = createElement('div', { class: 'an-color-custom' });
246
+ const colorInput = createElement('input', { type: 'color', value: currentColor, title: 'Custom color' });
247
+ const customLabel = createElement('span', {}, ['Custom color']);
248
+ customRow.appendChild(colorInput);
249
+ customRow.appendChild(customLabel);
250
+
251
+ popup.appendChild(swatches);
252
+ popup.appendChild(customRow);
253
+
254
+ // ---- State ----
255
+ let isOpen = false;
256
+
257
+ const openPopup = () => {
258
+ isOpen = true;
259
+ popup.style.display = 'block';
260
+ arrowBtn.setAttribute('aria-expanded', 'true');
261
+ };
262
+
263
+ const closePopup = () => {
264
+ isOpen = false;
265
+ popup.style.display = 'none';
266
+ arrowBtn.setAttribute('aria-expanded', 'false');
267
+ };
268
+
269
+ const applyColor = (color) => {
270
+ currentColor = color;
271
+ strip.style.background = color;
272
+ colorInput.value = color;
273
+ this.context.invoke('editor.focus');
274
+ def.action(this.context, color);
275
+ this.context.invoke('editor.afterCommand');
276
+ closePopup();
277
+ };
278
+
279
+ const d1 = on(applyBtn, 'click', (e) => {
280
+ e.preventDefault();
281
+ this.context.invoke('editor.focus');
282
+ def.action(this.context, currentColor);
283
+ this.context.invoke('editor.afterCommand');
284
+ });
285
+
286
+ const d2 = on(arrowBtn, 'click', (e) => {
287
+ e.stopPropagation();
288
+ if (isOpen) closePopup(); else openPopup();
289
+ });
290
+
291
+ const d3 = on(swatches, 'click', (e) => {
292
+ const sw = e.target.closest('.an-color-swatch');
293
+ if (sw) applyColor(sw.dataset.color);
294
+ });
295
+
296
+ const d4 = on(colorInput, 'change', (e) => {
297
+ applyColor(e.target.value);
298
+ });
299
+
300
+ const d5 = on(document, 'click', (e) => {
301
+ if (isOpen && !wrap.contains(e.target)) closePopup();
302
+ });
303
+
304
+ const d6 = on(popup, 'click', (e) => e.stopPropagation());
305
+
306
+ this._disposers.push(d1, d2, d3, d4, d5, d6);
307
+
308
+ wrap.appendChild(applyBtn);
309
+ wrap.appendChild(arrowBtn);
310
+ wrap.appendChild(popup);
311
+ return wrap;
312
+ }
313
+
314
+ /**
315
+ * Creates a <select> dropdown for font-family (or similar) options.
316
+ * @param {import('./Buttons.js').DropdownDef} def
317
+ * @returns {HTMLSelectElement}
318
+ */
319
+ _createSelect(def) {
320
+ const items = (def.name === 'fontFamily')
321
+ ? (this.options.fontFamilies || [])
322
+ : (def.items || []);
323
+
324
+ const cls = def.selectClass ? `an-select ${def.selectClass}` : 'an-select';
325
+ const select = createElement('select', {
326
+ class: cls,
327
+ title: def.tooltip || '',
328
+ 'data-btn': def.name,
329
+ 'aria-label': def.tooltip || def.name,
330
+ });
331
+
332
+ // Blank "placeholder" option
333
+ const placeholderText = def.placeholder || 'Font';
334
+ const placeholder = createElement('option', { value: '' }, [placeholderText]);
335
+ select.appendChild(placeholder);
336
+
337
+ items.forEach((item) => {
338
+ const value = (typeof item === 'object') ? item.value : item;
339
+ const label = (typeof item === 'object') ? item.label : item;
340
+ const opt = createElement('option', { value }, [label]);
341
+ if (def.name === 'fontFamily') opt.style.fontFamily = value;
342
+ select.appendChild(opt);
343
+ });
344
+
345
+ const disposer = on(select, 'change', (e) => {
346
+ const value = e.target.value;
347
+ if (!value) return;
348
+ this.context.invoke('editor.focus');
349
+ def.action(this.context, value);
350
+ this.context.invoke('editor.afterCommand');
351
+ });
352
+
353
+ this._disposers.push(disposer);
354
+ return select;
355
+ }
356
+
357
+ /**
358
+ * @param {import('./Buttons.js').ButtonDef} btnDef
359
+ * @returns {HTMLButtonElement}
360
+ */
361
+ _createButton(btnDef) {
362
+ // Determine classes based on whether the consumer wants Bootstrap styling
363
+ const useBootstrap = !!this.options.useBootstrap;
364
+ const baseClass = useBootstrap ? (this.options.toolbarButtonClass || 'btn btn-sm btn-light') : `an-btn`;
365
+ const extra = btnDef.className ? ` ${btnDef.className}` : '';
366
+ const classAttr = `${baseClass}${extra}`;
367
+
368
+ const btn = createElement('button', {
369
+ type: 'button',
370
+ class: classAttr,
371
+ title: btnDef.tooltip || '',
372
+ 'data-btn': btnDef.name,
373
+ 'aria-label': btnDef.tooltip || btnDef.name,
374
+ });
375
+
376
+ // Render icon: prefer FontAwesome if enabled; otherwise fall back to SVG or text.
377
+
378
+ // Heroicons-style stroke SVGs — consistent with context menu icons
379
+ const S = 'stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"';
380
+ const svgWrap = (paths) => `<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" ${S} style="display:block">${paths}</svg>`;
381
+
382
+ const svgMap = new Map([
383
+ // Format
384
+ ['bold', svgWrap('<path d="M6 4h8a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z"/><path d="M6 12h9a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z"/>')],
385
+ ['italic', svgWrap('<line x1="19" y1="4" x2="10" y2="4"/><line x1="14" y1="20" x2="5" y2="20"/><line x1="15" y1="4" x2="9" y2="20"/>')],
386
+ ['underline', svgWrap('<path d="M6 3v7a6 6 0 0 0 6 6 6 6 0 0 0 6-6V3"/><line x1="4" y1="21" x2="20" y2="21"/>')],
387
+ ['strikethrough', svgWrap('<path d="M17.3 12H6.7"/><path d="M10 6.5C10 5.1 11.1 4 12.5 4c1.4 0 2.5 1.1 2.5 2.5 0 .8-.4 1.5-1 2"/><path d="M14 17.5C14 19 12.9 20 11.5 20 10.1 20 9 18.9 9 17.5c0-.8.4-1.5 1-2"/>')],
388
+ ['superscript', svgWrap('<path d="m4 19 8-8"/><path d="m12 19-8-8"/><path d="M20 12h-4c0-1.5.44-2 1.5-2.5S20 8.33 20 7.25C20 6 19 5 17.5 5S15 6 15 7"/>')],
389
+ ['subscript', svgWrap('<path d="m4 5 8 8"/><path d="m12 5-8 8"/><path d="M20 21h-4c0-1.5.44-2 1.5-2.5S20 17.33 20 16.25C20 15 19 14 17.5 14S15 15 15 16"/>')],
390
+ // Alignment
391
+ ['align-left', svgWrap('<line x1="21" y1="6" x2="3" y2="6"/><line x1="15" y1="12" x2="3" y2="12"/><line x1="17" y1="18" x2="3" y2="18"/>')],
392
+ ['align-center', svgWrap('<line x1="21" y1="6" x2="3" y2="6"/><line x1="18" y1="12" x2="6" y2="12"/><line x1="21" y1="18" x2="3" y2="18"/>')],
393
+ ['align-right', svgWrap('<line x1="21" y1="6" x2="3" y2="6"/><line x1="21" y1="12" x2="9" y2="12"/><line x1="21" y1="18" x2="7" y2="18"/>')],
394
+ ['align-justify', svgWrap('<line x1="21" y1="6" x2="3" y2="6"/><line x1="21" y1="12" x2="3" y2="12"/><line x1="21" y1="18" x2="3" y2="18"/>')],
395
+ // Lists
396
+ ['list-ul', svgWrap('<line x1="9" y1="6" x2="20" y2="6"/><line x1="9" y1="12" x2="20" y2="12"/><line x1="9" y1="18" x2="20" y2="18"/><circle cx="4" cy="6" r="1" fill="currentColor" stroke="none"/><circle cx="4" cy="12" r="1" fill="currentColor" stroke="none"/><circle cx="4" cy="18" r="1" fill="currentColor" stroke="none"/>')],
397
+ ['list-ol', svgWrap('<line x1="10" y1="6" x2="21" y2="6"/><line x1="10" y1="12" x2="21" y2="12"/><line x1="10" y1="18" x2="21" y2="18"/><path d="M4 6h1V3"/><path d="M4 10h2l-2 2h2"/><path d="M4 16.5A1.5 1.5 0 0 1 5.5 15a1.5 1.5 0 0 1 0 3H4"/>')],
398
+ ['indent', svgWrap('<polyline points="3 8 7 12 3 16"/><line x1="21" y1="12" x2="11" y2="12"/><line x1="21" y1="6" x2="11" y2="6"/><line x1="21" y1="18" x2="11" y2="18"/>')],
399
+ ['outdent', svgWrap('<polyline points="7 8 3 12 7 16"/><line x1="21" y1="12" x2="11" y2="12"/><line x1="21" y1="6" x2="11" y2="6"/><line x1="21" y1="18" x2="11" y2="18"/>')],
400
+ // History
401
+ ['undo', svgWrap('<path d="M3 7v6h6"/><path d="M21 17a9 9 0 0 0-9-9 9 9 0 0 0-6 2.3L3 13"/>')],
402
+ ['redo', svgWrap('<path d="M21 7v6h-6"/><path d="M3 17a9 9 0 0 1 9-9 9 9 0 0 1 6 2.3L21 13"/>')],
403
+ // Insert
404
+ ['minus', svgWrap('<line x1="5" y1="12" x2="19" y2="12"/>')],
405
+ ['link', svgWrap('<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>')],
406
+ ['image', svgWrap('<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/>')],
407
+ ['video', svgWrap('<polygon points="23 7 16 12 23 17 23 7"/><rect x="1" y="5" width="15" height="14" rx="2"/>')],
408
+ ['table', svgWrap('<rect x="3" y="3" width="18" height="18" rx="1"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="3" y1="15" x2="21" y2="15"/><line x1="9" y1="3" x2="9" y2="21"/><line x1="15" y1="3" x2="15" y2="21"/>')],
409
+ ['emoji', svgWrap('<circle cx="12" cy="12" r="10"/><path d="M8.5 14.5s1.5 2.5 3.5 2.5 3.5-2.5 3.5-2.5"/><circle cx="9" cy="9" r="1.5" fill="currentColor" stroke="none"/><circle cx="15" cy="9" r="1.5" fill="currentColor" stroke="none"/>')],
410
+ ['icon', svgWrap('<circle cx="8" cy="8" r="3"/><circle cx="16" cy="8" r="3"/><rect x="5" y="13" width="6" height="6" rx="1"/><rect x="13" y="13" width="6" height="6" rx="1"/>')],
411
+ // View
412
+ ['code', svgWrap('<polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/>')],
413
+ ['expand', svgWrap('<polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/>')],
414
+ // Color pickers
415
+ ['foreColor', svgWrap('<path d="M4 20L12 4L20 20"/><line x1="7.5" y1="14" x2="16.5" y2="14"/>')],
416
+ ['backColor', svgWrap('<path d="M3 21v-4l9-9 4 4-9 9z"/><path d="M12 8l4 4"/>')],
417
+ ['keyboard', svgWrap('<rect x="2" y="6" width="20" height="12" rx="2"/><line x1="6" y1="10" x2="6" y2="10" stroke-width="2.5"/><line x1="10" y1="10" x2="10" y2="10" stroke-width="2.5"/><line x1="14" y1="10" x2="14" y2="10" stroke-width="2.5"/><line x1="18" y1="10" x2="18" y2="10" stroke-width="2.5"/><line x1="8" y1="14" x2="16" y2="14" stroke-width="2"/>')],
418
+ ['caption', svgWrap('<rect x="3" y="3" width="18" height="11" rx="2"/><line x1="6" y1="18" x2="18" y2="18"/><line x1="9" y1="21" x2="15" y2="21"/>')],
419
+ ['remove-format', svgWrap('<path d="m7 21-4.3-4.3c-1-1-1-2.5 0-3.4l9.6-9.6c1-1 2.5-1 3.4 0l5.6 5.6c1 1 1 2.5 0 3.4L13 21"/><path d="M22 21H7"/><path d="m5 11 9 9"/>')],
420
+ ['direction', svgWrap('<path d="M12 20V4"/><path d="m9 7-3 3 3 3"/><path d="M4 10h8"/><path d="m15 7 3 3-3 3"/><path d="M20 10h-8"/>')],
421
+ ['search', svgWrap('<circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>')],
422
+ ['find-replace', svgWrap('<circle cx="10" cy="10" r="6"/><line x1="18" y1="18" x2="14.35" y2="14.35"/><path d="M16 19h6"/><path d="M19 16v6"/>')],
423
+ ['inline-code', svgWrap('<path d="M8 3H7a2 2 0 0 0-2 2v5a2 2 0 0 1-2 2 2 2 0 0 1 2 2v5c0 1.1.9 2 2 2h1"/><path d="M16 3h1a2 2 0 0 1 2 2v5c0 1.1.9 2 2 2a2 2 0 0 1-2 2v5a2 2 0 0 1-2 2h-1"/>')],
424
+ ['checklist', svgWrap('<rect x="3" y="4" width="5" height="5" rx="1"/><path d="m4 6.5 1 1 2-2"/><rect x="3" y="13" width="5" height="5" rx="1"/><line x1="10" y1="6.5" x2="21" y2="6.5"/><line x1="10" y1="15.5" x2="21" y2="15.5"/>')],
425
+ ['print', svgWrap('<path d="M6 9V2h12v7"/><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><rect x="6" y="14" width="12" height="8"/>')],
426
+ ]);
427
+
428
+ const faPrefix = this.options.fontAwesomeClass || 'fas';
429
+ const faMap = new Map([
430
+ ['bold', 'fa-bold'],
431
+ ['italic', 'fa-italic'],
432
+ ['underline', 'fa-underline'],
433
+ ['strikethrough', 'fa-strikethrough'],
434
+ ['superscript', 'fa-superscript'],
435
+ ['subscript', 'fa-subscript'],
436
+ ['align-left', 'fa-align-left'],
437
+ ['align-center', 'fa-align-center'],
438
+ ['align-right', 'fa-align-right'],
439
+ ['align-justify', 'fa-align-justify'],
440
+ ['list-ul', 'fa-list-ul'],
441
+ ['list-ol', 'fa-list-ol'],
442
+ ['indent', 'fa-indent'],
443
+ ['outdent', 'fa-outdent'],
444
+ ['undo', 'fa-rotate-left'],
445
+ ['redo', 'fa-rotate-right'],
446
+ ['minus', 'fa-minus'],
447
+ ['link', 'fa-link'],
448
+ ['image', 'fa-image'],
449
+ ['code', 'fa-code'],
450
+ ['expand', 'fa-expand'],
451
+ ['emoji', 'fa-face-smile'],
452
+ ['icon', 'fa-icons'],
453
+ ['foreColor', 'fa-font'],
454
+ ['backColor', 'fa-highlighter'],
455
+ ['keyboard', 'fa-keyboard'],
456
+ ['remove-format', 'fa-remove-format'],
457
+ ['direction', 'fa-arrow-right-arrow-left'],
458
+ ['search', 'fa-magnifying-glass'],
459
+ ['find-replace', 'fa-magnifying-glass-plus'],
460
+ ['inline-code', 'fa-code'],
461
+ ['checklist', 'fa-list-check'],
462
+ ['print', 'fa-print'],
463
+ ]);
464
+
465
+ const useFaNow = this._faReady;
466
+ if (useFaNow) {
467
+ const faName = faMap.get(btnDef.icon) || faMap.get(btnDef.name) || null;
468
+ if (faName) {
469
+ btn.innerHTML = `<i class="${faPrefix} ${faName}" aria-hidden="true"></i>`;
470
+ } else if (svgMap.has(btnDef.icon)) {
471
+ btn.innerHTML = svgMap.get(btnDef.icon);
472
+ } else {
473
+ btn.textContent = btnDef.icon || btnDef.name;
474
+ }
475
+ } else {
476
+ // FontAwesome absent: use SVG fallback when available
477
+ if (svgMap.has(btnDef.icon)) {
478
+ btn.innerHTML = svgMap.get(btnDef.icon);
479
+ } else if (svgMap.has(btnDef.name)) {
480
+ btn.innerHTML = svgMap.get(btnDef.name);
481
+ } else {
482
+ btn.textContent = btnDef.icon || btnDef.name;
483
+ }
484
+ }
485
+
486
+ const disposer = on(btn, 'click', (event) => {
487
+ event.preventDefault();
488
+ // Restore focus to the editor before executing the action
489
+ this.context.invoke('editor.focus');
490
+ btnDef.action(this.context);
491
+ this.context.invoke('editor.afterCommand');
492
+ this.refresh();
493
+ });
494
+
495
+ this._disposers.push(disposer);
496
+ return btn;
497
+ }
498
+
499
+ // ---------------------------------------------------------------------------
500
+ // FontAwesome detection (run once at initialize time)
501
+ // ---------------------------------------------------------------------------
502
+
503
+ _detectFontAwesome() {
504
+ if (!this.options.useFontAwesome) return false;
505
+ if (document.querySelector('.fa, .fas, .far, .fal, .fab, .fa-solid')) return true;
506
+ const links = Array.from(document.querySelectorAll('link[rel="stylesheet"]')).map((l) => l.href || '').join(' ');
507
+ return /fontawesome|font-awesome|use\.fontawesome|all\.css/.test(links);
508
+ }
509
+
510
+ // ---------------------------------------------------------------------------
511
+ // State refresh (active / disabled states)
512
+ // ---------------------------------------------------------------------------
513
+
514
+ refresh() {
515
+ if (!this.el) return;
516
+ const btnMap = this._btnMap || new Map();
517
+
518
+ // Sync button active states
519
+ this.el.querySelectorAll('button[data-btn]').forEach((btn) => {
520
+ const def = btnMap.get(btn.getAttribute('data-btn'));
521
+ if (def && typeof def.isActive === 'function') {
522
+ btn.classList.toggle('active', !!def.isActive(this.context));
523
+ }
524
+ if (def && typeof def.isDisabled === 'function') {
525
+ btn.disabled = !!def.isDisabled(this.context);
526
+ }
527
+ });
528
+
529
+ // Sync select dropdowns (e.g. font family) with current cursor position
530
+ this.el.querySelectorAll('select[data-btn]').forEach((select) => {
531
+ const def = btnMap.get(select.getAttribute('data-btn'));
532
+ if (!def || typeof def.getValue !== 'function') return;
533
+ // queryCommandValue returns the font name, possibly quoted — strip quotes
534
+ let raw = (def.getValue(this.context) || '').replace(/["']/g, '').trim();
535
+ // Fallback: when no selection/font set, use the configured default font
536
+ if (!raw) {
537
+ raw = this.options.defaultFontFamily
538
+ || (this.options.fontFamilies && this.options.fontFamilies[0])
539
+ || '';
540
+ }
541
+ // Try to match against available options (case-insensitive)
542
+ const matched = Array.from(select.options).find(
543
+ (opt) => opt.value && opt.value.toLowerCase() === raw.toLowerCase()
544
+ );
545
+ select.value = matched ? matched.value : '';
546
+ });
547
+ }
548
+
549
+ /**
550
+ * Shows the toolbar.
551
+ */
552
+ show() {
553
+ if (this.el) this.el.style.display = '';
554
+ }
555
+
556
+ /**
557
+ * Hides the toolbar.
558
+ */
559
+ hide() {
560
+ if (this.el) this.el.style.display = 'none';
561
+ }
562
+ }