autumnnote 2.0.0 → 2.2.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 (64) hide show
  1. package/README.md +72 -5
  2. package/dist/autumnnote.cjs +20 -20
  3. package/dist/autumnnote.css +1 -1
  4. package/dist/autumnnote.es.js +661 -798
  5. package/dist/autumnnote.es.js.map +1 -1
  6. package/dist/autumnnote.min.js +20 -20
  7. package/dist/autumnnote.umd.js +20 -20
  8. package/dist/autumnnote.umd.js.map +1 -1
  9. package/dist/icon-data-V0Xqv-wX.js +255 -0
  10. package/dist/icon-data-V0Xqv-wX.js.map +1 -0
  11. package/package.json +13 -4
  12. package/types/index.d.ts +8 -0
  13. package/src/js/Context.js +0 -854
  14. package/src/js/core/detectLang.js +0 -98
  15. package/src/js/core/dom.js +0 -372
  16. package/src/js/core/env.js +0 -25
  17. package/src/js/core/key.js +0 -66
  18. package/src/js/core/lists.js +0 -121
  19. package/src/js/core/markdown.js +0 -695
  20. package/src/js/core/range.js +0 -194
  21. package/src/js/core/sanitise.js +0 -231
  22. package/src/js/editing/History.js +0 -266
  23. package/src/js/editing/Style.js +0 -812
  24. package/src/js/editing/Table.js +0 -105
  25. package/src/js/editing/Typing.js +0 -397
  26. package/src/js/index.js +0 -193
  27. package/src/js/index.umd.js +0 -17
  28. package/src/js/module/AutoSaveRestore.js +0 -125
  29. package/src/js/module/BaseDialog.js +0 -133
  30. package/src/js/module/BaseMediaTooltip.js +0 -142
  31. package/src/js/module/BaseResizer.js +0 -312
  32. package/src/js/module/BubbleToolbar.js +0 -483
  33. package/src/js/module/Buttons.js +0 -399
  34. package/src/js/module/Clipboard.js +0 -579
  35. package/src/js/module/CodeTooltip.js +0 -493
  36. package/src/js/module/Codeview.js +0 -125
  37. package/src/js/module/ContextMenu.js +0 -621
  38. package/src/js/module/Editor.js +0 -747
  39. package/src/js/module/EmojiDialog.js +0 -254
  40. package/src/js/module/FindReplace.js +0 -512
  41. package/src/js/module/Fullscreen.js +0 -80
  42. package/src/js/module/IconDialog.js +0 -618
  43. package/src/js/module/ImageCropOverlay.js +0 -586
  44. package/src/js/module/ImageDialog.js +0 -193
  45. package/src/js/module/ImageResizer.js +0 -42
  46. package/src/js/module/ImageTooltip.js +0 -285
  47. package/src/js/module/LinkDialog.js +0 -145
  48. package/src/js/module/LinkTooltip.js +0 -250
  49. package/src/js/module/MarkdownShortcuts.js +0 -250
  50. package/src/js/module/Mention.js +0 -365
  51. package/src/js/module/Placeholder.js +0 -51
  52. package/src/js/module/ShortcutsDialog.js +0 -111
  53. package/src/js/module/SlashMenu.js +0 -376
  54. package/src/js/module/Statusbar.js +0 -246
  55. package/src/js/module/TableTooltip.js +0 -1521
  56. package/src/js/module/Toolbar.js +0 -750
  57. package/src/js/module/VideoDialog.js +0 -193
  58. package/src/js/module/VideoResizer.js +0 -66
  59. package/src/js/module/VideoTooltip.js +0 -248
  60. package/src/js/module/emoji-data.js +0 -496
  61. package/src/js/renderer.js +0 -120
  62. package/src/js/settings.js +0 -214
  63. package/src/styles/_variables.scss +0 -48
  64. package/src/styles/autumnnote.scss +0 -2866
@@ -1,750 +0,0 @@
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
- import { getButton } from './Buttons.js';
8
-
9
- /** Resolve a toolbar item: string → registry lookup, object → pass-through. */
10
- const _resolveBtn = (item) => (typeof item === 'string') ? getButton(item) : item;
11
-
12
- // Module-level cache for FontAwesome detection.
13
- // Evaluated once per page load so all Toolbar instances on the same page agree
14
- // on whether the HOST PAGE included FA — regardless of whether IconDialog later
15
- // auto-injects its own FA <link> for the icon-picker glyph rendering.
16
- let _faPageLevelReady = null;
17
-
18
- // ---------------------------------------------------------------------------
19
- // Module-level icon lookup tables — built once, shared across all instances.
20
- // Previously these were re-created inside _createButton() on every button
21
- // render, producing O(buttons × map-size) allocations per toolbar init.
22
- // ---------------------------------------------------------------------------
23
- const _S = 'stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"';
24
- const _svgWrap = (paths) =>
25
- `<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>`;
26
-
27
- const _SVG_MAP = new Map([
28
- // Format
29
- ['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"/>')],
30
- ['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"/>')],
31
- ['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"/>')],
32
- ['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"/>')],
33
- ['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"/>')],
34
- ['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"/>')],
35
- // Alignment
36
- ['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"/>')],
37
- ['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"/>')],
38
- ['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"/>')],
39
- ['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"/>')],
40
- // Lists
41
- ['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"/>')],
42
- ['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"/>')],
43
- ['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"/>')],
44
- ['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"/>')],
45
- // History
46
- ['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"/>')],
47
- ['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"/>')],
48
- // Insert
49
- ['minus', _svgWrap('<line x1="5" y1="12" x2="19" y2="12"/>')],
50
- ['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"/>')],
51
- ['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"/>')],
52
- ['video', _svgWrap('<polygon points="23 7 16 12 23 17 23 7"/><rect x="1" y="5" width="15" height="14" rx="2"/>')],
53
- ['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"/>')],
54
- ['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"/>')],
55
- ['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"/>')],
56
- // View
57
- ['code', _svgWrap('<polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/>')],
58
- ['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"/>')],
59
- // Color pickers
60
- ['foreColor', _svgWrap('<path d="M4 20L12 4L20 20"/><line x1="7.5" y1="14" x2="16.5" y2="14"/>')],
61
- ['backColor', _svgWrap('<path d="M3 21v-4l9-9 4 4-9 9z"/><path d="M12 8l4 4"/>')],
62
- ['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"/>')],
63
- ['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"/>')],
64
- ['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"/>')],
65
- ['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"/>')],
66
- ['search', _svgWrap('<circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>')],
67
- ['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"/>')],
68
- ['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"/>')],
69
- ['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"/>')],
70
- ['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"/>')],
71
- ]);
72
-
73
- const _FA_MAP = new Map([
74
- ['bold', 'fa-bold'],
75
- ['italic', 'fa-italic'],
76
- ['underline', 'fa-underline'],
77
- ['strikethrough', 'fa-strikethrough'],
78
- ['superscript', 'fa-superscript'],
79
- ['subscript', 'fa-subscript'],
80
- ['align-left', 'fa-align-left'],
81
- ['align-center', 'fa-align-center'],
82
- ['align-right', 'fa-align-right'],
83
- ['align-justify', 'fa-align-justify'],
84
- ['list-ul', 'fa-list-ul'],
85
- ['list-ol', 'fa-list-ol'],
86
- ['indent', 'fa-indent'],
87
- ['outdent', 'fa-outdent'],
88
- ['undo', 'fa-rotate-left'],
89
- ['redo', 'fa-rotate-right'],
90
- ['minus', 'fa-minus'],
91
- ['link', 'fa-link'],
92
- ['image', 'fa-image'],
93
- ['code', 'fa-code'],
94
- ['expand', 'fa-expand'],
95
- ['emoji', 'fa-face-smile'],
96
- ['icon', 'fa-icons'],
97
- ['foreColor', 'fa-font'],
98
- ['backColor', 'fa-highlighter'],
99
- ['keyboard', 'fa-keyboard'],
100
- ['remove-format', 'fa-remove-format'],
101
- ['direction', 'fa-arrow-right-arrow-left'],
102
- ['search', 'fa-magnifying-glass'],
103
- ['find-replace', 'fa-magnifying-glass-plus'],
104
- ['inline-code', 'fa-code'],
105
- ['checklist', 'fa-list-check'],
106
- ['print', 'fa-print'],
107
- ]);
108
-
109
- export class Toolbar {
110
- /**
111
- * @param {import('../Context.js').Context} context
112
- */
113
- constructor(context) {
114
- this.context = context;
115
- this.options = context.options;
116
- /** @type {HTMLElement|null} */
117
- this.el = null;
118
- /** @type {Array<() => void>} disposers */
119
- this._disposers = [];
120
- /** @type {Array<() => void>} closers for all open color picker popups */
121
- this._colorPickerClosers = [];
122
- /** @type {number|null} rAF handle for debounced refresh */
123
- this._refreshRaf = null;
124
- }
125
-
126
- // ---------------------------------------------------------------------------
127
- // Lifecycle
128
- // ---------------------------------------------------------------------------
129
-
130
- initialize() {
131
- this.el = createElement('div', { class: 'an-toolbar' });
132
- // Detect FontAwesome once at toolbar build time to avoid re-querying the DOM
133
- // for every button rendered.
134
- this._faReady = this._detectFontAwesome();
135
- this._buildButtons();
136
- this._btnMap = new Map(
137
- (this.options.toolbar || []).flat()
138
- .map(_resolveBtn).filter(Boolean).map((b) => [b.name, b]),
139
- );
140
- return this;
141
- }
142
-
143
- destroy() {
144
- if (this._refreshRaf) cancelAnimationFrame(this._refreshRaf);
145
- this._refreshRaf = null;
146
- this._disposers.forEach((d) => d());
147
- this._disposers = [];
148
- if (this.el?.parentNode) {
149
- this.el.remove();
150
- }
151
- this.el = null;
152
- }
153
-
154
- // ---------------------------------------------------------------------------
155
- // Build
156
- // ---------------------------------------------------------------------------
157
-
158
- _buildButtons() {
159
- const toolbar = this.options.toolbar || [];
160
- // Build into a DocumentFragment so all groups are appended in a single
161
- // DOM operation, avoiding one reflow per group.
162
- const fragment = document.createDocumentFragment();
163
- toolbar.forEach((group) => {
164
- const groupEl = createElement('div', { class: 'an-btn-group' });
165
- group.forEach((item) => {
166
- const btnDef = _resolveBtn(item);
167
- if (!btnDef) {
168
- console.warn(`[AutumnNote] Toolbar: button "${item}" not found in registry. Skipped.`);
169
- return;
170
- }
171
- let el;
172
- if (btnDef.type === 'select') el = this._createSelect(btnDef);
173
- else if (btnDef.type === 'grid') el = this._createGridPicker(btnDef);
174
- else if (btnDef.type === 'colorpicker') el = this._createColorPicker(btnDef);
175
- else el = this._createButton(btnDef);
176
- groupEl.appendChild(el);
177
- });
178
- fragment.appendChild(groupEl);
179
- });
180
- this.el.appendChild(fragment);
181
- }
182
-
183
- /**
184
- * Creates a table-grid picker button with a hoverable row/col selector popup.
185
- * @param {import('./Buttons.js').ButtonDef} def
186
- * @returns {HTMLDivElement}
187
- */
188
- _createGridPicker(def) {
189
- const ROWS = 10;
190
- const COLS = 10;
191
-
192
- const wrap = createElement('div', { class: 'an-table-picker-wrap' });
193
-
194
- const useBootstrap = !!this.options.useBootstrap;
195
- const baseClass = useBootstrap
196
- ? (this.options.toolbarButtonClass || 'btn btn-sm btn-light')
197
- : 'an-btn';
198
- const btn = createElement('button', {
199
- type: 'button',
200
- class: baseClass,
201
- title: this.context.locale.toolbar[def.name] || def.tooltip || '',
202
- 'data-btn': def.name,
203
- 'aria-label': this.context.locale.toolbar[def.name] || def.tooltip || def.name,
204
- 'aria-haspopup': 'true',
205
- 'aria-expanded': 'false',
206
- });
207
-
208
- // Set icon — inline SVG (table) with optional FontAwesome fallback
209
- if (this._faReady) {
210
- const faPrefix = this.options.fontAwesomeClass || 'fas';
211
- btn.innerHTML = `<i class="${faPrefix} fa-table" aria-hidden="true"></i>`;
212
- } else {
213
- const S = 'stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"';
214
- 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>`;
215
- }
216
-
217
- // Popup
218
- const popup = createElement('div', {
219
- class: 'an-table-picker-popup',
220
- role: 'dialog',
221
- 'aria-label': 'Select table size',
222
- });
223
- const grid = createElement('div', { class: 'an-table-grid' });
224
- const label = createElement('div', { class: 'an-table-label' });
225
- label.textContent = this.context.locale.toolbar.insertTableLabel || 'Insert Table';
226
-
227
- const cells = [];
228
- for (let r = 1; r <= ROWS; r++) {
229
- for (let c = 1; c <= COLS; c++) {
230
- const cell = createElement('div', {
231
- class: 'an-table-cell',
232
- 'data-row': String(r),
233
- 'data-col': String(c),
234
- });
235
- cells.push(cell);
236
- grid.appendChild(cell);
237
- }
238
- }
239
-
240
- popup.appendChild(grid);
241
- popup.appendChild(label);
242
-
243
- let isOpen = false;
244
-
245
- const setHighlight = (rows, cols) => {
246
- cells.forEach((cell) => {
247
- const r = +cell.dataset.row;
248
- const c = +cell.dataset.col;
249
- cell.classList.toggle('active', r <= rows && c <= cols);
250
- });
251
- label.textContent = (rows && cols) ? `${rows} × ${cols}` : (this.context.locale.toolbar.insertTableLabel || 'Insert Table');
252
- };
253
-
254
- const openPopup = () => {
255
- isOpen = true;
256
- const rect = btn.getBoundingClientRect();
257
-
258
- // Measure popup dimensions while invisible so we can set the correct
259
- // position before the browser paints (matches the color-picker pattern).
260
- popup.style.visibility = 'hidden';
261
- popup.style.display = 'block';
262
- const pw = popup.offsetWidth;
263
- const ph = popup.offsetHeight;
264
-
265
- let left = rect.left;
266
- let top = rect.bottom + 4;
267
- if (left + pw > globalThis.innerWidth - 8) left = Math.max(8, globalThis.innerWidth - pw - 8);
268
- if (top + ph > globalThis.innerHeight - 8) top = rect.top - ph - 4;
269
-
270
- popup.style.left = `${left}px`;
271
- popup.style.top = `${top}px`;
272
- popup.style.visibility = '';
273
- btn.setAttribute('aria-expanded', 'true');
274
- };
275
-
276
- const closePopup = () => {
277
- isOpen = false;
278
- popup.style.display = 'none';
279
- btn.setAttribute('aria-expanded', 'false');
280
- setHighlight(0, 0);
281
- };
282
-
283
- const d1 = on(btn, 'click', (e) => {
284
- e.stopPropagation();
285
- if (isOpen) closePopup(); else openPopup();
286
- });
287
-
288
- const d2 = on(grid, 'mouseover', (e) => {
289
- const cell = /** @type {HTMLElement|null} */ (/** @type {Element} */ (e.target)?.closest('.an-table-cell'));
290
- if (!cell) return;
291
- setHighlight(+cell.dataset.row, +cell.dataset.col);
292
- });
293
-
294
- const d3 = on(grid, 'mouseleave', () => setHighlight(0, 0));
295
-
296
- const d4 = on(grid, 'click', (e) => {
297
- const cell = /** @type {HTMLElement|null} */ (/** @type {Element} */ (e.target)?.closest('.an-table-cell'));
298
- if (!cell) return;
299
- const rows = +cell.dataset.row;
300
- const cols = +cell.dataset.col;
301
- closePopup();
302
- this.context.invoke('editor.focus');
303
- def.action(this.context, rows, cols);
304
- });
305
-
306
- const d5 = on(document, 'click', () => { if (isOpen) closePopup(); });
307
-
308
- // Append popup to body so position:fixed is truly viewport-relative,
309
- // unaffected by any ancestor transform / filter (same pattern as color picker).
310
- this._disposers.push(d1, d2, d3, d4, d5, () => {
311
- if (popup.parentNode) popup.remove();
312
- });
313
-
314
- wrap.appendChild(btn);
315
- document.body.appendChild(popup);
316
- return /** @type {HTMLDivElement} */ (wrap);
317
- }
318
-
319
- /**
320
- * Creates a split color-picker widget:
321
- * [icon + strip | ▾] — left applies current color, right opens swatch popup.
322
- * @param {{ name: string, type: 'colorpicker', tooltip: string, defaultColor: string, action: Function }} def
323
- * @returns {HTMLDivElement}
324
- */
325
- _createColorPicker(def) {
326
- const PRESETS = [
327
- // Grayscale
328
- '#000000', '#434343', '#666666', '#999999', '#b7b7b7', '#cccccc', '#efefef', '#ffffff',
329
- // Saturated
330
- '#ff0000', '#ff9900', '#ffff00', '#00ff00', '#00ffff', '#4a86e8', '#9900ff', '#ff00ff',
331
- // Pastel
332
- '#f4cccc', '#fce5cd', '#fff2cc', '#d9ead3', '#d0e0e3', '#c9daf8', '#d9d2e9', '#ead1dc',
333
- ];
334
-
335
- let currentColor = def.defaultColor || '#000000';
336
-
337
- const wrap = createElement('div', { class: 'an-color-picker-wrap' });
338
-
339
- const useBootstrap = !!this.options.useBootstrap;
340
- const baseClass = useBootstrap ? (this.options.toolbarButtonClass || 'btn btn-sm btn-light') : 'an-btn';
341
-
342
- // ---- Apply button (icon + color strip) ----
343
- const applyBtn = createElement('button', {
344
- type: 'button',
345
- class: `${baseClass} an-color-btn`,
346
- title: this.context.locale.toolbar[def.name] || def.tooltip || '',
347
- 'data-btn': def.name,
348
- 'aria-label': this.context.locale.toolbar[def.name] || def.tooltip || def.name,
349
- });
350
-
351
- const S = 'stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"';
352
- const iconSvg = def.name === 'foreColor'
353
- ? `<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>`
354
- : `<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>`;
355
-
356
- applyBtn.innerHTML = iconSvg;
357
- const strip = createElement('span', { class: 'an-color-strip' });
358
- strip.style.background = currentColor;
359
- applyBtn.appendChild(strip);
360
-
361
- // ---- Arrow button (open popup) ----
362
- const arrowBtn = createElement('button', {
363
- type: 'button',
364
- class: `${baseClass} an-color-arrow`,
365
- title: def.name === 'foreColor'
366
- ? (this.context.locale.toolbar.chooseTextColor || 'Choose text color')
367
- : (this.context.locale.toolbar.chooseHighlightColor || 'Choose highlight color'),
368
- 'aria-haspopup': 'true',
369
- 'aria-expanded': 'false',
370
- });
371
- 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>`;
372
-
373
- // ---- Popup ----
374
- const popup = createElement('div', { class: 'an-color-popup' });
375
- popup.style.display = 'none';
376
-
377
- const swatches = createElement('div', { class: 'an-color-swatches' });
378
- const userSwatches = Array.isArray(this.options.colorSwatches) ? this.options.colorSwatches : [];
379
- const allColors = [...new Set([...userSwatches, ...PRESETS])];
380
- allColors.forEach((color) => {
381
- const sw = createElement('div', { class: 'an-color-swatch', title: color, 'data-color': color });
382
- sw.style.background = color;
383
- swatches.appendChild(sw);
384
- });
385
-
386
- const customRow = createElement('div', { class: 'an-color-custom' });
387
- const colorInput = /** @type {HTMLInputElement} */ (createElement('input', { type: 'color', value: currentColor, title: this.context.locale.toolbar.customColor || 'Custom color' }));
388
- const customLabel = createElement('span', {}, [this.context.locale.toolbar.customColor || 'Custom color']);
389
- customRow.appendChild(colorInput);
390
- customRow.appendChild(customLabel);
391
-
392
- popup.appendChild(swatches);
393
- popup.appendChild(customRow);
394
-
395
- // ---- State ----
396
- let isOpen = false;
397
- /** @type {Range|null} saved selection range before popup opens */
398
- let savedRange = null;
399
-
400
- const saveSelection = () => {
401
- const sel = globalThis.getSelection();
402
- savedRange = sel?.rangeCount ? sel.getRangeAt(0).cloneRange() : null;
403
- };
404
-
405
- const restoreSelection = () => {
406
- if (!savedRange) return;
407
- try {
408
- const sel = globalThis.getSelection();
409
- if (!sel) return;
410
- sel.removeAllRanges();
411
- sel.addRange(savedRange);
412
- } catch (_) {
413
- void _; // range may be stale if DOM changed while popup was open
414
- }
415
- };
416
-
417
- const openPopup = () => {
418
- // Close any other open color picker before opening this one
419
- this._colorPickerClosers.forEach((fn) => { if (fn !== closePopup) fn(); });
420
- saveSelection();
421
- isOpen = true;
422
- // Use fixed positioning so the popup escapes any overflow-clipping ancestor
423
- // (notably toolbar scroll mode, where overflow-x:auto coerces overflow-y)
424
- const rect = arrowBtn.getBoundingClientRect();
425
- const popupMinW = 184;
426
- let left = rect.left;
427
- if (left + popupMinW > globalThis.innerWidth) left = rect.right - popupMinW;
428
- popup.style.top = `${rect.bottom + 4}px`;
429
- popup.style.left = `${Math.max(4, left)}px`;
430
- popup.style.display = 'block';
431
- arrowBtn.setAttribute('aria-expanded', 'true');
432
- };
433
-
434
- const closePopup = () => {
435
- isOpen = false;
436
- popup.style.display = 'none';
437
- popup.style.top = '';
438
- popup.style.left = '';
439
- arrowBtn.setAttribute('aria-expanded', 'false');
440
- };
441
-
442
- const applyColor = (color) => {
443
- currentColor = color;
444
- strip.style.background = color;
445
- colorInput.value = color;
446
- restoreSelection();
447
- def.action(this.context, color);
448
- this.context.invoke('editor.afterCommand');
449
- closePopup();
450
- };
451
-
452
- const d1 = on(applyBtn, 'click', (e) => {
453
- e.preventDefault();
454
- restoreSelection();
455
- def.action(this.context, currentColor);
456
- this.context.invoke('editor.afterCommand');
457
- });
458
-
459
- const d2 = on(arrowBtn, 'mousedown', (e) => {
460
- // Prevent editor blur so selection is preserved when the popup opens
461
- e.preventDefault();
462
- });
463
-
464
- const d2b = on(arrowBtn, 'click', (e) => {
465
- e.stopPropagation();
466
- if (isOpen) closePopup(); else openPopup();
467
- });
468
-
469
- const d3 = on(swatches, 'mousedown', (e) => {
470
- // Prevent blur before the click handler fires
471
- e.preventDefault();
472
- });
473
-
474
- const d3b = on(swatches, 'click', (e) => {
475
- const sw = /** @type {Element} */ (e.target)?.closest('.an-color-swatch');
476
- if (sw) applyColor(/** @type {HTMLElement} */ (sw).dataset.color);
477
- });
478
-
479
- const d4 = on(colorInput, 'change', (e) => {
480
- applyColor(/** @type {HTMLInputElement} */ (e.target).value);
481
- });
482
-
483
- const d5 = on(document, 'click', (e) => {
484
- // popup is in document.body, not inside wrap — check both
485
- if (isOpen && !wrap.contains(/** @type {Node} */ (e.target)) && !popup.contains(/** @type {Node} */ (e.target))) closePopup();
486
- });
487
-
488
- const d6 = on(popup, 'click', (e) => e.stopPropagation());
489
-
490
- // Close the popup when the viewport scrolls or resizes so the fixed-position
491
- // popup doesn't drift away from the button it belongs to.
492
- const onScrollResize = () => { if (isOpen) closePopup(); };
493
- document.addEventListener('scroll', onScrollResize, { passive: true, capture: true });
494
- globalThis.addEventListener('resize', onScrollResize, { passive: true });
495
-
496
- this._disposers.push(d1, d2, d2b, d3, d3b, d4, d5, d6,
497
- () => document.removeEventListener('scroll', onScrollResize, { capture: true }),
498
- () => globalThis.removeEventListener('resize', onScrollResize),
499
- // Remove popup from body on editor destroy
500
- () => { if (popup.parentNode) popup.remove(); },
501
- );
502
-
503
- // Register this popup's closer so other color pickers can close it
504
- this._colorPickerClosers.push(closePopup);
505
- this._disposers.push(() => {
506
- const idx = this._colorPickerClosers.indexOf(closePopup);
507
- if (idx !== -1) this._colorPickerClosers.splice(idx, 1);
508
- });
509
-
510
- // Append popup to document.body so it escapes all overflow-clipping and
511
- // contain:layout ancestors (contain:layout makes the container a fixed-pos
512
- // containing block per the CSS Contain spec, breaking viewport coordinates).
513
- wrap.appendChild(applyBtn);
514
- wrap.appendChild(arrowBtn);
515
- document.body.appendChild(popup);
516
- return /** @type {HTMLDivElement} */ (wrap);
517
- }
518
-
519
- /**
520
- * Creates a <select> dropdown for font-family (or similar) options.
521
- * @param {import('./Buttons.js').DropdownDef} def
522
- * @returns {HTMLSelectElement}
523
- */
524
- _createSelect(def) {
525
- const items = (def.name === 'fontFamily')
526
- ? (this.options.fontFamilies || [])
527
- : (def.items || []);
528
-
529
- const cls = def.selectClass ? `an-select ${def.selectClass}` : 'an-select';
530
- const select = createElement('select', {
531
- class: cls,
532
- title: this.context.locale.toolbar[def.name] || def.tooltip || '',
533
- 'data-btn': def.name,
534
- 'aria-label': this.context.locale.toolbar[def.name] || def.tooltip || def.name,
535
- });
536
-
537
- // Blank "placeholder" option (non-selectable header)
538
- const placeholderText = this.context.locale.toolbar[def.name + 'Placeholder'] || def.placeholder || 'Font';
539
- const placeholder = createElement('option', { value: '', disabled: '', hidden: '' }, [placeholderText]);
540
- select.appendChild(placeholder);
541
-
542
- items.forEach((item) => {
543
- const value = (typeof item === 'object') ? item.value : item;
544
- let label;
545
- if (typeof item !== 'object') {
546
- label = item;
547
- } else if (def.name === 'paragraphStyle') {
548
- label = this.context.locale.toolbar.paragraphItems?.[item.value] || item.label;
549
- } else {
550
- label = item.label;
551
- }
552
- const isHeader = (typeof item === 'object') && !!item.disabled;
553
- const attrs = { value };
554
- if (isHeader) attrs.disabled = '';
555
- const opt = createElement('option', attrs, [label]);
556
- // Only apply fontFamily face preview on real (non-header) entries
557
- if (def.name === 'fontFamily' && !isHeader) opt.style.fontFamily = value;
558
- select.appendChild(opt);
559
- });
560
-
561
- // Save the editor selection when the user starts interacting with the
562
- // dropdown (mousedown fires before the editor loses focus). When the
563
- // change handler runs, focus has moved to the <select>; we restore the
564
- // saved range so execCommand / fontSize() act on the intended text.
565
- /** @type {Range|null} */
566
- let _savedRange = null;
567
- const dMousedown = on(select, 'mousedown', () => {
568
- const sel = globalThis.getSelection();
569
- _savedRange = sel?.rangeCount ? sel.getRangeAt(0).cloneRange() : null;
570
- });
571
-
572
- const disposer = on(select, 'change', (e) => {
573
- const value = /** @type {HTMLSelectElement} */ (e.target).value;
574
- const selectedOpt = /** @type {HTMLSelectElement} */ (e.target).options[/** @type {HTMLSelectElement} */ (e.target).selectedIndex];
575
- if (!value || selectedOpt.disabled) return;
576
- this.context.invoke('editor.focus');
577
- // Restore selection saved on mousedown so the action targets the correct text.
578
- if (_savedRange) {
579
- try {
580
- const sel = globalThis.getSelection();
581
- if (sel) { sel.removeAllRanges(); sel.addRange(_savedRange); }
582
- } catch (_) { void _; /* range may be stale if DOM changed */ }
583
- }
584
- def.action(this.context, value);
585
- this.context.invoke('editor.afterCommand');
586
- });
587
-
588
- this._disposers.push(dMousedown, disposer);
589
- return /** @type {HTMLSelectElement} */ (select);
590
- }
591
-
592
- /**
593
- * @param {import('./Buttons.js').ButtonDef} btnDef
594
- * @returns {HTMLButtonElement}
595
- */
596
- _createButton(btnDef) {
597
- // Determine classes based on whether the consumer wants Bootstrap styling
598
- const useBootstrap = !!this.options.useBootstrap;
599
- const baseClass = useBootstrap ? (this.options.toolbarButtonClass || 'btn btn-sm btn-light') : `an-btn`;
600
- const extra = btnDef.className ? ` ${btnDef.className}` : '';
601
- const classAttr = `${baseClass}${extra}`;
602
-
603
- const btn = createElement('button', {
604
- type: 'button',
605
- class: classAttr,
606
- title: this.context.locale.toolbar[btnDef.name] || btnDef.tooltip || '',
607
- 'data-btn': btnDef.name,
608
- 'aria-label': this.context.locale.toolbar[btnDef.name] || btnDef.tooltip || btnDef.name,
609
- });
610
-
611
- // Render icon: prefer FontAwesome if enabled; otherwise fall back to SVG or text.
612
- const faPrefix = this.options.fontAwesomeClass || 'fas';
613
- const useFaNow = this._faReady;
614
- if (useFaNow) {
615
- const faName = _FA_MAP.get(btnDef.icon) || _FA_MAP.get(btnDef.name) || null;
616
- if (faName) {
617
- btn.innerHTML = `<i class="${faPrefix} ${faName}" aria-hidden="true"></i>`;
618
- } else if (_SVG_MAP.has(btnDef.icon)) {
619
- btn.innerHTML = _SVG_MAP.get(btnDef.icon);
620
- } else {
621
- btn.textContent = btnDef.icon || btnDef.name;
622
- }
623
- } else if (_SVG_MAP.has(btnDef.icon)) {
624
- // FontAwesome absent: use SVG fallback when available
625
- btn.innerHTML = _SVG_MAP.get(btnDef.icon);
626
- } else if (_SVG_MAP.has(btnDef.name)) {
627
- btn.innerHTML = _SVG_MAP.get(btnDef.name);
628
- } else {
629
- btn.textContent = btnDef.icon || btnDef.name;
630
- }
631
-
632
- const disposer = on(btn, 'click', (event) => {
633
- event.preventDefault();
634
- // Restore focus to the editor before executing the action
635
- this.context.invoke('editor.focus');
636
- btnDef.action(this.context);
637
- this.context.invoke('editor.afterCommand');
638
- this.refresh();
639
- });
640
-
641
- this._disposers.push(disposer);
642
- return /** @type {HTMLButtonElement} */ (btn);
643
- }
644
-
645
- // ---------------------------------------------------------------------------
646
- // FontAwesome detection (run once at initialize time)
647
- // ---------------------------------------------------------------------------
648
-
649
- _detectFontAwesome() {
650
- if (!this.options.useFontAwesome) return false;
651
- // Return cached result when available. This ensures that a later-initialised
652
- // toolbar sees the same detection state as the first one — even if IconDialog
653
- // has since injected its own FA <link> into <head> for the icon-picker UI.
654
- if (_faPageLevelReady !== null) return _faPageLevelReady;
655
- if (document.querySelector('.fa, .fas, .far, .fal, .fab, .fa-solid')) {
656
- _faPageLevelReady = true;
657
- return true;
658
- }
659
- // Exclude the editor-self-injected link (id='an-fontawesome-css') so it doesn't
660
- // count as "the host page loaded FA" for toolbar icon rendering purposes.
661
- const links = Array.from(document.querySelectorAll('link[rel="stylesheet"]'))
662
- .filter((l) => l.id !== 'an-fontawesome-css')
663
- .map((l) => /** @type {HTMLLinkElement} */ (l).href || '').join(' ');
664
- _faPageLevelReady = /fontawesome|font-awesome|use\.fontawesome|all\.css/.test(links);
665
- return _faPageLevelReady;
666
- }
667
-
668
- // ---------------------------------------------------------------------------
669
- // State refresh (active / disabled states)
670
- // ---------------------------------------------------------------------------
671
-
672
- refresh() {
673
- // Debounce via rAF — multiple rapid calls (e.g. afterCommand + button click)
674
- // collapse into a single update per animation frame.
675
- if (this._refreshRaf) cancelAnimationFrame(this._refreshRaf);
676
- this._refreshRaf = requestAnimationFrame(() => {
677
- this._refreshRaf = null;
678
- this._doRefresh();
679
- });
680
- }
681
-
682
- _doRefresh() {
683
- if (!this.el) return;
684
- const btnMap = this._btnMap || new Map();
685
-
686
- // Sync button active states
687
- this.el.querySelectorAll('button[data-btn]').forEach((btn) => {
688
- const def = btnMap.get(/** @type {HTMLElement} */ (btn).dataset.btn);
689
- if (def && typeof def.isActive === 'function') {
690
- btn.classList.toggle('active', !!def.isActive(this.context));
691
- }
692
- if (def && typeof def.isDisabled === 'function') {
693
- /** @type {HTMLButtonElement} */ (btn).disabled = !!def.isDisabled(this.context);
694
- }
695
- });
696
-
697
- // Sync select dropdowns (e.g. font family) with current cursor position
698
- this.el.querySelectorAll('select[data-btn]').forEach((select) => {
699
- const def = btnMap.get(/** @type {HTMLElement} */ (select).dataset.btn);
700
- if (!def || typeof def.getValue !== 'function') return;
701
- // queryCommandValue returns the font name, possibly quoted — strip quotes
702
- let raw = (def.getValue(this.context) || '').replace(/["']/g, '').trim();
703
- // Fallback: when no selection/font set, use the configured default font
704
- if (!raw) {
705
- raw = this.options.defaultFontFamily
706
- || this.options.fontFamilies?.[0]
707
- || '';
708
- }
709
- // Try to match against available options (case-insensitive)
710
- const sel = /** @type {HTMLSelectElement} */ (select);
711
- const matched = Array.from(sel.options).find(
712
- (opt) => opt.value?.toLowerCase() === raw.toLowerCase()
713
- );
714
- sel.value = matched ? matched.value : '';
715
- });
716
- }
717
-
718
- /**
719
- * Shows the toolbar.
720
- */
721
- show() {
722
- if (this.el) this.el.style.display = '';
723
- }
724
-
725
- /**
726
- * Hides the toolbar.
727
- */
728
- hide() {
729
- if (this.el) this.el.style.display = 'none';
730
- }
731
-
732
- /**
733
- * Tears down and re-renders the toolbar in-place.
734
- * Call after registering new buttons post-create via context.use(plugin)
735
- * or AutumnNote.registerButton() to make them appear in the toolbar.
736
- */
737
- rebuild() {
738
- if (this._refreshRaf) { cancelAnimationFrame(this._refreshRaf); this._refreshRaf = null; }
739
- this._disposers.forEach((d) => d());
740
- this._disposers = [];
741
- if (this.el) this.el.innerHTML = '';
742
- this._faReady = this._detectFontAwesome();
743
- this._buildButtons();
744
- this._btnMap = new Map(
745
- (this.options.toolbar || []).flat()
746
- .map(_resolveBtn).filter(Boolean).map((b) => [b.name, b]),
747
- );
748
- this.refresh();
749
- }
750
- }