autumnnote 1.2.1 → 1.4.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/README.md +66 -10
- package/dist/autumnnote.es.js +463 -108
- package/dist/autumnnote.es.js.map +1 -1
- package/dist/autumnnote.umd.js +433 -91
- package/dist/autumnnote.umd.js.map +1 -1
- package/package.json +1 -1
- package/src/js/Context.js +64 -0
- package/src/js/core/dom.js +6 -3
- package/src/js/core/func.js +24 -13
- package/src/js/core/markdown.js +26 -8
- package/src/js/core/sanitise.js +57 -46
- package/src/js/editing/History.js +12 -1
- package/src/js/editing/Style.js +49 -21
- package/src/js/editing/Table.js +53 -13
- package/src/js/editing/Typing.js +9 -2
- package/src/js/i18n/de.js +1 -0
- package/src/js/i18n/en.js +1 -0
- package/src/js/i18n/es.js +1 -0
- package/src/js/i18n/fr.js +1 -0
- package/src/js/i18n/ja.js +1 -0
- package/src/js/i18n/ko.js +1 -0
- package/src/js/i18n/vi.js +1 -0
- package/src/js/i18n/zh.js +1 -0
- package/src/js/index.js +42 -2
- package/src/js/module/BubbleToolbar.js +26 -7
- package/src/js/module/Buttons.js +37 -0
- package/src/js/module/Clipboard.js +2 -2
- package/src/js/module/ContextMenu.js +1 -1
- package/src/js/module/FindReplace.js +6 -5
- package/src/js/module/ImageCropOverlay.js +46 -8
- package/src/js/module/ImageResizer.js +7 -0
- package/src/js/module/Mention.js +18 -4
- package/src/js/module/Statusbar.js +2 -1
- package/src/js/module/Toolbar.js +47 -2
- package/types/index.d.ts +54 -1
package/src/js/editing/Typing.js
CHANGED
|
@@ -206,11 +206,11 @@ export function handleKeydown(event, editable, options = {}) {
|
|
|
206
206
|
return true;
|
|
207
207
|
}
|
|
208
208
|
|
|
209
|
-
// In a pre/code block, insert spaces
|
|
209
|
+
// In a pre/code block, insert spaces using configured tabSize
|
|
210
210
|
if (para && para.nodeName.toUpperCase() === 'PRE') {
|
|
211
211
|
if (event.shiftKey) return false;
|
|
212
212
|
event.preventDefault();
|
|
213
|
-
execCommand('insertText', '
|
|
213
|
+
execCommand('insertText', ' '.repeat(options.tabSize || 4));
|
|
214
214
|
return true;
|
|
215
215
|
}
|
|
216
216
|
|
|
@@ -357,6 +357,13 @@ export function handleKeydown(event, editable, options = {}) {
|
|
|
357
357
|
|
|
358
358
|
const para = closestPara(range.sc, editable);
|
|
359
359
|
|
|
360
|
+
// Enter in a pre/code block: insert a literal newline instead of a new block
|
|
361
|
+
if (para && para.nodeName.toUpperCase() === 'PRE') {
|
|
362
|
+
event.preventDefault();
|
|
363
|
+
execCommand('insertText', '\n');
|
|
364
|
+
return true;
|
|
365
|
+
}
|
|
366
|
+
|
|
360
367
|
// Pressing Enter at the end of a blockquote should exit it
|
|
361
368
|
if (para && para.nodeName.toUpperCase() === 'BLOCKQUOTE') {
|
|
362
369
|
const native = range.toNativeRange();
|
package/src/js/i18n/de.js
CHANGED
package/src/js/i18n/en.js
CHANGED
package/src/js/i18n/es.js
CHANGED
package/src/js/i18n/fr.js
CHANGED
package/src/js/i18n/ja.js
CHANGED
package/src/js/i18n/ko.js
CHANGED
package/src/js/i18n/vi.js
CHANGED
package/src/js/i18n/zh.js
CHANGED
package/src/js/index.js
CHANGED
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import '../styles/autumnnote.scss';
|
|
13
|
-
import { Context, _customModules } from './Context.js';
|
|
13
|
+
import { Context, _customModules, _globalPlugins } from './Context.js';
|
|
14
|
+
import { registerButton } from './module/Buttons.js';
|
|
14
15
|
import { defaultOptions } from './settings.js';
|
|
15
16
|
|
|
16
17
|
// Snapshot of factory defaults taken at module-load time (before any setDefaults() calls)
|
|
@@ -99,8 +100,47 @@ const AutumnNote = {
|
|
|
99
100
|
*/
|
|
100
101
|
registerModule(name, ModuleClass) { _customModules.set(name, ModuleClass); },
|
|
101
102
|
|
|
103
|
+
/**
|
|
104
|
+
* Installs a plugin globally — applied to every future editor instance.
|
|
105
|
+
* Plugin `buttons` are registered to the global button registry immediately
|
|
106
|
+
* so they are available when Toolbar initialises inside create().
|
|
107
|
+
* Plugin `install()` is called after all built-in modules have initialised.
|
|
108
|
+
* @param {object} plugin - { name, version?, buttons?, install?, uninstall? }
|
|
109
|
+
* @param {object} [options] - Forwarded to plugin.install(context, options)
|
|
110
|
+
* @returns {typeof AutumnNote}
|
|
111
|
+
*/
|
|
112
|
+
use(plugin, options = {}) {
|
|
113
|
+
if (!plugin || typeof plugin.name !== 'string') {
|
|
114
|
+
throw new TypeError('[AutumnNote] AutumnNote.use: plugin must have a string `name` property.');
|
|
115
|
+
}
|
|
116
|
+
if (_globalPlugins.has(plugin.name)) {
|
|
117
|
+
console.warn(`[AutumnNote] Plugin "${plugin.name}" already registered globally. Skipping.`);
|
|
118
|
+
return this;
|
|
119
|
+
}
|
|
120
|
+
if (Array.isArray(plugin.buttons)) {
|
|
121
|
+
plugin.buttons.forEach((b) => registerButton(b));
|
|
122
|
+
}
|
|
123
|
+
_globalPlugins.set(plugin.name, { plugin, options });
|
|
124
|
+
return this;
|
|
125
|
+
},
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Returns true if a plugin with the given name has been registered globally.
|
|
129
|
+
* @param {string} name
|
|
130
|
+
* @returns {boolean}
|
|
131
|
+
*/
|
|
132
|
+
hasPlugin(name) { return _globalPlugins.has(name); },
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Registers a single button definition in the global button registry.
|
|
136
|
+
* After create(), call ctx.invoke('toolbar.rebuild') to render new buttons.
|
|
137
|
+
* @param {object} btnDef - ButtonDef-compatible object with a `name` string
|
|
138
|
+
* @returns {typeof AutumnNote}
|
|
139
|
+
*/
|
|
140
|
+
registerButton(btnDef) { registerButton(btnDef); return this; },
|
|
141
|
+
|
|
102
142
|
/** Library version */
|
|
103
|
-
version: '1.
|
|
143
|
+
version: '1.4.0',
|
|
104
144
|
};
|
|
105
145
|
|
|
106
146
|
// ---------------------------------------------------------------------------
|
|
@@ -44,11 +44,25 @@ const _ACTIONS = {
|
|
|
44
44
|
strikethrough: (ctx) => ctx.invoke('editor.strikethrough'),
|
|
45
45
|
link: (ctx) => ctx.invoke('linkDialog.show'),
|
|
46
46
|
removeFormat: (ctx) => {
|
|
47
|
-
// editor.removeFormat does not exist — call execCommand directly
|
|
48
47
|
const editable = ctx.layoutInfo && ctx.layoutInfo.editable;
|
|
49
48
|
if (!editable) return;
|
|
50
49
|
editable.focus();
|
|
51
50
|
document.execCommand('removeFormat');
|
|
51
|
+
// Also strip inline style attributes which execCommand('removeFormat') misses
|
|
52
|
+
const sel = window.getSelection();
|
|
53
|
+
if (sel && sel.rangeCount > 0 && !sel.getRangeAt(0).collapsed) {
|
|
54
|
+
const range = sel.getRangeAt(0);
|
|
55
|
+
const ancestor = range.commonAncestorContainer;
|
|
56
|
+
const root = ancestor.nodeType === 1 ? ancestor : ancestor.parentElement;
|
|
57
|
+
if (root) {
|
|
58
|
+
const candidates = [root, ...root.querySelectorAll('[style]')];
|
|
59
|
+
for (const el of candidates) {
|
|
60
|
+
if (el.hasAttribute('style') && range.intersectsNode(el)) {
|
|
61
|
+
el.removeAttribute('style');
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
52
66
|
ctx.invoke('editor.afterCommand');
|
|
53
67
|
},
|
|
54
68
|
inlineCode: (ctx) => ctx.invoke('editor.inlineCode'),
|
|
@@ -172,6 +186,8 @@ export class BubbleToolbar {
|
|
|
172
186
|
|
|
173
187
|
document.body.appendChild(el);
|
|
174
188
|
this._el = el;
|
|
189
|
+
// Cache button references once — avoids querySelectorAll on every selectionchange
|
|
190
|
+
this._btnCache = Array.from(el.querySelectorAll('.an-bubble-btn'));
|
|
175
191
|
}
|
|
176
192
|
|
|
177
193
|
_buildColorPicker() {
|
|
@@ -291,9 +307,13 @@ export class BubbleToolbar {
|
|
|
291
307
|
editable.focus();
|
|
292
308
|
const sel = window.getSelection();
|
|
293
309
|
sel.removeAllRanges();
|
|
294
|
-
sel.addRange(this._savedRange.cloneRange());
|
|
310
|
+
try { sel.addRange(this._savedRange.cloneRange()); } catch (_) { return; }
|
|
295
311
|
|
|
296
|
-
|
|
312
|
+
// Firefox does not support 'hiliteColor'; fall back to 'backColor'
|
|
313
|
+
const cmd = type === 'hiliteColor' ? 'hiliteColor' : type;
|
|
314
|
+
if (!document.execCommand(cmd, false, color) && cmd === 'hiliteColor') {
|
|
315
|
+
document.execCommand('backColor', false, color);
|
|
316
|
+
}
|
|
297
317
|
this.context.invoke('editor.afterCommand');
|
|
298
318
|
|
|
299
319
|
// Update the color strip on the corresponding button
|
|
@@ -349,10 +369,9 @@ export class BubbleToolbar {
|
|
|
349
369
|
}
|
|
350
370
|
|
|
351
371
|
_syncActive() {
|
|
352
|
-
if (!this.
|
|
353
|
-
this.
|
|
354
|
-
const
|
|
355
|
-
const activeFn = _ACTIVE[name];
|
|
372
|
+
if (!this._btnCache) return;
|
|
373
|
+
this._btnCache.forEach((btn) => {
|
|
374
|
+
const activeFn = _ACTIVE[btn.dataset.name];
|
|
356
375
|
btn.classList.toggle('an-active', !!(activeFn && activeFn()));
|
|
357
376
|
});
|
|
358
377
|
}
|
package/src/js/module/Buttons.js
CHANGED
|
@@ -51,6 +51,43 @@ function btn(name, icon, tooltip, action, isActive, isDisabled) {
|
|
|
51
51
|
return { name, icon, tooltip, action, isActive, isDisabled };
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// Global button registry
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Global registry for custom buttons registered via AutumnNote.registerButton()
|
|
60
|
+
* or via a plugin's `buttons` array. Toolbar resolves string names from here.
|
|
61
|
+
* @type {Map<string, object>}
|
|
62
|
+
*/
|
|
63
|
+
export const _buttonRegistry = new Map();
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Registers a button definition in the global registry so it can be referenced
|
|
67
|
+
* by string name in toolbar configuration: `toolbar: [['myBtn', boldBtn]]`.
|
|
68
|
+
* @param {object} btnDef - Any ToolbarItemDef-compatible object with a `name` string.
|
|
69
|
+
*/
|
|
70
|
+
export function registerButton(btnDef) {
|
|
71
|
+
if (!btnDef || typeof btnDef.name !== 'string') {
|
|
72
|
+
console.warn('[AutumnNote] registerButton: btnDef must have a string `name` property.');
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (_buttonRegistry.has(btnDef.name)) {
|
|
76
|
+
console.warn(`[AutumnNote] registerButton: overwriting existing button "${btnDef.name}".`);
|
|
77
|
+
}
|
|
78
|
+
_buttonRegistry.set(btnDef.name, btnDef);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Looks up a button definition by name from the global registry.
|
|
83
|
+
* Returns undefined when not found.
|
|
84
|
+
* @param {string} name
|
|
85
|
+
* @returns {object|undefined}
|
|
86
|
+
*/
|
|
87
|
+
export function getButton(name) {
|
|
88
|
+
return _buttonRegistry.get(name);
|
|
89
|
+
}
|
|
90
|
+
|
|
54
91
|
// ---------------------------------------------------------------------------
|
|
55
92
|
// Style buttons
|
|
56
93
|
// ---------------------------------------------------------------------------
|
|
@@ -239,7 +239,7 @@ export class Clipboard {
|
|
|
239
239
|
const raw = clipboardData.getData('text/html');
|
|
240
240
|
// Detect source type and apply appropriate pre-cleaner
|
|
241
241
|
const isWordContent = /<[a-z]+:[a-z]/i.test(raw) || /class="Mso/i.test(raw) || /\bmso-/i.test(raw);
|
|
242
|
-
const isSocialContent =
|
|
242
|
+
const isSocialContent = /class="[^"]*\b(?:x[a-z0-9]{6,}|r-[a-z0-9]{3,})\b/.test(raw);
|
|
243
243
|
let html = raw;
|
|
244
244
|
if (isWordContent) html = this._cleanWordHtml(html);
|
|
245
245
|
else if (isSocialContent) html = this._cleanSocialHtml(html);
|
|
@@ -351,7 +351,7 @@ export class Clipboard {
|
|
|
351
351
|
*/
|
|
352
352
|
_dataUrlToBlob(dataUrl) {
|
|
353
353
|
const [header, b64] = dataUrl.split(',');
|
|
354
|
-
const mime = header.match(/:(.*?);/)[1];
|
|
354
|
+
const mime = header.match(/:(.*?);/)?.[1] ?? 'image/png';
|
|
355
355
|
const binary = atob(b64);
|
|
356
356
|
const arr = new Uint8Array(binary.length);
|
|
357
357
|
for (let i = 0; i < binary.length; i++) arr[i] = binary.charCodeAt(i);
|
|
@@ -307,7 +307,7 @@ export class ContextMenu {
|
|
|
307
307
|
cells.forEach((cell) => {
|
|
308
308
|
cell.classList.toggle('active', +cell.dataset.row <= rows && +cell.dataset.col <= cols);
|
|
309
309
|
});
|
|
310
|
-
labelEl.textContent = (rows && cols) ? `${
|
|
310
|
+
labelEl.textContent = (rows && cols) ? `${rows} × ${cols}` : (this.context.locale.contextMenu.table || 'Insert Table');
|
|
311
311
|
};
|
|
312
312
|
|
|
313
313
|
panel.appendChild(gridEl);
|
|
@@ -271,8 +271,6 @@ export class FindReplace {
|
|
|
271
271
|
const rawMatches = this._findRawMatches(query, editable);
|
|
272
272
|
if (rawMatches.length === 0) return;
|
|
273
273
|
|
|
274
|
-
this._currentIndex = 0;
|
|
275
|
-
|
|
276
274
|
// Wrap matches in reverse order so earlier text offsets stay valid when
|
|
277
275
|
// later sections of the same text node are split by surroundContents().
|
|
278
276
|
// Use push() instead of unshift() to avoid O(n²) shifting on every insert;
|
|
@@ -297,6 +295,7 @@ export class FindReplace {
|
|
|
297
295
|
|
|
298
296
|
// Drop entries where wrapping failed so the counter and navigation are accurate
|
|
299
297
|
this._matches = this._matches.filter((m) => m.mark);
|
|
298
|
+
this._currentIndex = 0;
|
|
300
299
|
if (this._matches.length === 0) return;
|
|
301
300
|
|
|
302
301
|
// Highlight the first (current) match
|
|
@@ -324,12 +323,14 @@ export class FindReplace {
|
|
|
324
323
|
}
|
|
325
324
|
const re = this._queryRegex;
|
|
326
325
|
|
|
326
|
+
// Cap results to prevent blocking the main thread on very large documents
|
|
327
|
+
const MAX_RESULTS = 500;
|
|
327
328
|
const walker = document.createTreeWalker(root, 0x4 /* NodeFilter.SHOW_TEXT */);
|
|
328
329
|
let node;
|
|
329
|
-
while ((node = walker.nextNode())) {
|
|
330
|
+
while ((node = walker.nextNode()) && results.length < MAX_RESULTS) {
|
|
330
331
|
re.lastIndex = 0;
|
|
331
332
|
let m;
|
|
332
|
-
while ((m = re.exec(node.textContent)) !== null) {
|
|
333
|
+
while ((m = re.exec(node.textContent)) !== null && results.length < MAX_RESULTS) {
|
|
333
334
|
results.push({ node, start: m.index, end: m.index + m[0].length });
|
|
334
335
|
}
|
|
335
336
|
}
|
|
@@ -447,7 +448,7 @@ export class FindReplace {
|
|
|
447
448
|
const total = this._matches.length;
|
|
448
449
|
if (total === 0) {
|
|
449
450
|
const query = this._findInput ? this._findInput.value : '';
|
|
450
|
-
this._counterEl.textContent = query ?
|
|
451
|
+
this._counterEl.textContent = query ? this.context.locale.findReplace.noResults : '';
|
|
451
452
|
} else {
|
|
452
453
|
this._counterEl.textContent = `${this._currentIndex + 1} / ${total}`;
|
|
453
454
|
}
|
|
@@ -227,6 +227,11 @@ export class ImageCropOverlay {
|
|
|
227
227
|
e.stopPropagation();
|
|
228
228
|
this._startHandleDrag(e, id);
|
|
229
229
|
}),
|
|
230
|
+
on(h, 'touchstart', (e) => {
|
|
231
|
+
e.preventDefault();
|
|
232
|
+
e.stopPropagation();
|
|
233
|
+
this._startHandleDrag({ clientX: e.touches[0].clientX, clientY: e.touches[0].clientY }, id);
|
|
234
|
+
}, { passive: false }),
|
|
230
235
|
);
|
|
231
236
|
this._handles[id] = h;
|
|
232
237
|
cropBox.appendChild(h);
|
|
@@ -235,16 +240,19 @@ export class ImageCropOverlay {
|
|
|
235
240
|
/* ---- crop move drag ---- */
|
|
236
241
|
this._disposers.push(
|
|
237
242
|
on(cropBox, 'mousedown', (e) => {
|
|
238
|
-
|
|
239
|
-
if (e.target !== cropBox && e.target !== grid
|
|
240
|
-
// Let handle mousedown handle it
|
|
241
|
-
return;
|
|
242
|
-
}
|
|
243
|
-
if (e.target.className && e.target.className.includes('an-crop-handle')) return;
|
|
243
|
+
if (e.target.classList.contains('an-crop-handle')) return;
|
|
244
|
+
if (e.target !== cropBox && e.target !== grid) return;
|
|
244
245
|
e.preventDefault();
|
|
245
246
|
e.stopPropagation();
|
|
246
247
|
this._startBoxMove(e);
|
|
247
248
|
}),
|
|
249
|
+
on(cropBox, 'touchstart', (e) => {
|
|
250
|
+
if (e.target.classList.contains('an-crop-handle')) return;
|
|
251
|
+
if (e.target !== cropBox && e.target !== grid) return;
|
|
252
|
+
e.preventDefault();
|
|
253
|
+
e.stopPropagation();
|
|
254
|
+
this._startBoxMove({ clientX: e.touches[0].clientX, clientY: e.touches[0].clientY });
|
|
255
|
+
}, { passive: false }),
|
|
248
256
|
);
|
|
249
257
|
|
|
250
258
|
/* ---- info label ---- */
|
|
@@ -434,15 +442,23 @@ export class ImageCropOverlay {
|
|
|
434
442
|
* @param {(e: MouseEvent) => void} onMove
|
|
435
443
|
*/
|
|
436
444
|
_attachDocDrag(onMove) {
|
|
445
|
+
const onTouchMove = (e) => {
|
|
446
|
+
e.preventDefault();
|
|
447
|
+
onMove({ clientX: e.touches[0].clientX, clientY: e.touches[0].clientY });
|
|
448
|
+
};
|
|
437
449
|
const cleanup = () => {
|
|
438
450
|
document.removeEventListener('mousemove', onMove);
|
|
439
451
|
document.removeEventListener('mouseup', cleanup);
|
|
452
|
+
document.removeEventListener('touchmove', onTouchMove);
|
|
453
|
+
document.removeEventListener('touchend', cleanup);
|
|
440
454
|
document.body.style.userSelect = '';
|
|
441
455
|
document.body.style.cursor = '';
|
|
442
456
|
};
|
|
443
457
|
document.body.style.userSelect = 'none';
|
|
444
458
|
document.addEventListener('mousemove', onMove);
|
|
445
459
|
document.addEventListener('mouseup', cleanup, { once: true });
|
|
460
|
+
document.addEventListener('touchmove', onTouchMove, { passive: false });
|
|
461
|
+
document.addEventListener('touchend', cleanup, { once: true });
|
|
446
462
|
}
|
|
447
463
|
|
|
448
464
|
// ---------------------------------------------------------------------------
|
|
@@ -489,8 +505,8 @@ export class ImageCropOverlay {
|
|
|
489
505
|
|
|
490
506
|
if (!canvas) {
|
|
491
507
|
// Cross-origin failure — inform user and abort
|
|
492
|
-
|
|
493
|
-
'Cannot crop this image: the image server does not allow cross-origin access
|
|
508
|
+
this._showCropError(
|
|
509
|
+
'Cannot crop this image: the image server does not allow cross-origin access. ' +
|
|
494
510
|
'Upload the image directly to use the crop tool.',
|
|
495
511
|
);
|
|
496
512
|
this._close(false);
|
|
@@ -516,6 +532,28 @@ export class ImageCropOverlay {
|
|
|
516
532
|
this.context.invoke('imageResizer.updateOverlay');
|
|
517
533
|
}
|
|
518
534
|
|
|
535
|
+
/**
|
|
536
|
+
* Show a non-blocking inline error banner appended to document.body.
|
|
537
|
+
* Auto-dismisses after 4 seconds.
|
|
538
|
+
* @param {string} msg
|
|
539
|
+
*/
|
|
540
|
+
_showCropError(msg) {
|
|
541
|
+
const banner = document.createElement('div');
|
|
542
|
+
banner.setAttribute('role', 'alert');
|
|
543
|
+
banner.style.cssText = [
|
|
544
|
+
'position:fixed', 'bottom:24px', 'left:50%', 'transform:translateX(-50%)',
|
|
545
|
+
'z-index:10200', 'max-width:420px', 'width:max-content',
|
|
546
|
+
'background:#7f1d1d', 'color:#fecaca', 'border:1px solid #b91c1c',
|
|
547
|
+
'border-radius:8px', 'padding:12px 18px',
|
|
548
|
+
'font:13px/1.5 system-ui,sans-serif',
|
|
549
|
+
'box-shadow:0 4px 16px rgba(0,0,0,.4)',
|
|
550
|
+
'pointer-events:auto',
|
|
551
|
+
].join(';');
|
|
552
|
+
banner.textContent = msg;
|
|
553
|
+
document.body.appendChild(banner);
|
|
554
|
+
setTimeout(() => { if (banner.parentNode) banner.parentNode.removeChild(banner); }, 4000);
|
|
555
|
+
}
|
|
556
|
+
|
|
519
557
|
/**
|
|
520
558
|
* Remove all overlay DOM elements and reset state.
|
|
521
559
|
* @param {boolean} _committed - reserved for future use
|
|
@@ -151,6 +151,7 @@ export class ImageResizer {
|
|
|
151
151
|
this._activeImg.classList.remove('an-image-selected');
|
|
152
152
|
}
|
|
153
153
|
this._activeImg = img;
|
|
154
|
+
this._lastOverlayPos = null; // invalidate position cache on new selection
|
|
154
155
|
img.classList.add('an-image-selected');
|
|
155
156
|
this._updateOverlayPosition();
|
|
156
157
|
this._overlay.style.display = 'block';
|
|
@@ -177,6 +178,12 @@ export class ImageResizer {
|
|
|
177
178
|
const offsetParent = this._overlay.offsetParent || this._container;
|
|
178
179
|
const containerRect = offsetParent.getBoundingClientRect();
|
|
179
180
|
const rect = this._activeImg.getBoundingClientRect();
|
|
181
|
+
|
|
182
|
+
// Skip DOM writes when position hasn't changed (common during non-scroll rAF ticks)
|
|
183
|
+
const p = this._lastOverlayPos;
|
|
184
|
+
if (p && p.l === rect.left && p.t === rect.top && p.w === rect.width && p.h === rect.height) return;
|
|
185
|
+
this._lastOverlayPos = { l: rect.left, t: rect.top, w: rect.width, h: rect.height };
|
|
186
|
+
|
|
180
187
|
const left = rect.left - containerRect.left + offsetParent.scrollLeft;
|
|
181
188
|
const top = rect.top - containerRect.top + offsetParent.scrollTop;
|
|
182
189
|
this._overlay.style.left = `${left}px`;
|
package/src/js/module/Mention.js
CHANGED
|
@@ -92,16 +92,29 @@ export class Mention {
|
|
|
92
92
|
const el = document.createElement('div');
|
|
93
93
|
el.className = 'an-mention-dropdown';
|
|
94
94
|
el.setAttribute('role', 'listbox');
|
|
95
|
+
|
|
96
|
+
// Event delegation: single listeners on the container instead of per-item
|
|
97
|
+
el.addEventListener('mousedown', (e) => e.preventDefault());
|
|
98
|
+
el.addEventListener('click', (e) => {
|
|
99
|
+
const item = e.target.closest('.an-mention-item');
|
|
100
|
+
if (item) this._select(+item.dataset.index);
|
|
101
|
+
});
|
|
102
|
+
el.addEventListener('mousemove', (e) => {
|
|
103
|
+
const item = e.target.closest('.an-mention-item');
|
|
104
|
+
if (item) this._highlightItem(+item.dataset.index);
|
|
105
|
+
});
|
|
106
|
+
|
|
95
107
|
document.body.appendChild(el);
|
|
96
108
|
this._dropdown = el;
|
|
97
109
|
}
|
|
98
110
|
|
|
99
111
|
_renderItems(items) {
|
|
100
112
|
const dd = this._dropdown;
|
|
101
|
-
dd.innerHTML = '';
|
|
102
113
|
this._items = items.slice(0, this._cfg.maxResults);
|
|
103
114
|
this._activeIndex = this._items.length > 0 ? 0 : -1;
|
|
104
115
|
|
|
116
|
+
// Build all items in a DocumentFragment — one batch DOM insertion
|
|
117
|
+
const frag = document.createDocumentFragment();
|
|
105
118
|
this._items.forEach((item, i) => {
|
|
106
119
|
const li = document.createElement('div');
|
|
107
120
|
li.className = 'an-mention-item';
|
|
@@ -117,11 +130,12 @@ export class Mention {
|
|
|
117
130
|
const label = document.createElement('span');
|
|
118
131
|
label.textContent = item.label;
|
|
119
132
|
li.appendChild(label);
|
|
120
|
-
|
|
121
|
-
li.addEventListener('click', () => this._select(i));
|
|
122
|
-
dd.appendChild(li);
|
|
133
|
+
frag.appendChild(li);
|
|
123
134
|
});
|
|
124
135
|
|
|
136
|
+
dd.innerHTML = ''; // one clear
|
|
137
|
+
dd.appendChild(frag); // one batch insert
|
|
138
|
+
|
|
125
139
|
this._highlightItem(this._activeIndex);
|
|
126
140
|
}
|
|
127
141
|
|
|
@@ -214,7 +214,8 @@ export class Statusbar {
|
|
|
214
214
|
update() {
|
|
215
215
|
if (!this._wordCountEl || !this._charCountEl) return;
|
|
216
216
|
const editable = this.context.layoutInfo.editable;
|
|
217
|
-
|
|
217
|
+
// textContent is faster than innerText (no layout flush, no CSS visibility check)
|
|
218
|
+
const text = editable.textContent || '';
|
|
218
219
|
const words = _countWords(text);
|
|
219
220
|
const chars = text.replace(/\n/g, '').length;
|
|
220
221
|
const maxWords = this.options.maxWords || 0;
|
package/src/js/module/Toolbar.js
CHANGED
|
@@ -4,6 +4,10 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
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;
|
|
7
11
|
|
|
8
12
|
// Module-level cache for FontAwesome detection.
|
|
9
13
|
// Evaluated once per page load so all Toolbar instances on the same page agree
|
|
@@ -115,6 +119,8 @@ export class Toolbar {
|
|
|
115
119
|
this._disposers = [];
|
|
116
120
|
/** @type {Array<() => void>} closers for all open color picker popups */
|
|
117
121
|
this._colorPickerClosers = [];
|
|
122
|
+
/** @type {number|null} rAF handle for debounced refresh */
|
|
123
|
+
this._refreshRaf = null;
|
|
118
124
|
}
|
|
119
125
|
|
|
120
126
|
// ---------------------------------------------------------------------------
|
|
@@ -127,11 +133,16 @@ export class Toolbar {
|
|
|
127
133
|
// for every button rendered.
|
|
128
134
|
this._faReady = this._detectFontAwesome();
|
|
129
135
|
this._buildButtons();
|
|
130
|
-
this._btnMap = new Map(
|
|
136
|
+
this._btnMap = new Map(
|
|
137
|
+
(this.options.toolbar || []).flat()
|
|
138
|
+
.map(_resolveBtn).filter(Boolean).map((b) => [b.name, b]),
|
|
139
|
+
);
|
|
131
140
|
return this;
|
|
132
141
|
}
|
|
133
142
|
|
|
134
143
|
destroy() {
|
|
144
|
+
if (this._refreshRaf) cancelAnimationFrame(this._refreshRaf);
|
|
145
|
+
this._refreshRaf = null;
|
|
135
146
|
this._disposers.forEach((d) => d());
|
|
136
147
|
this._disposers = [];
|
|
137
148
|
if (this.el && this.el.parentNode) {
|
|
@@ -151,7 +162,12 @@ export class Toolbar {
|
|
|
151
162
|
const fragment = document.createDocumentFragment();
|
|
152
163
|
toolbar.forEach((group) => {
|
|
153
164
|
const groupEl = createElement('div', { class: 'an-btn-group' });
|
|
154
|
-
group.forEach((
|
|
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
|
+
}
|
|
155
171
|
let el;
|
|
156
172
|
if (btnDef.type === 'select') el = this._createSelect(btnDef);
|
|
157
173
|
else if (btnDef.type === 'grid') el = this._createGridPicker(btnDef);
|
|
@@ -653,6 +669,16 @@ export class Toolbar {
|
|
|
653
669
|
// ---------------------------------------------------------------------------
|
|
654
670
|
|
|
655
671
|
refresh() {
|
|
672
|
+
// Debounce via rAF — multiple rapid calls (e.g. afterCommand + button click)
|
|
673
|
+
// collapse into a single update per animation frame.
|
|
674
|
+
if (this._refreshRaf) cancelAnimationFrame(this._refreshRaf);
|
|
675
|
+
this._refreshRaf = requestAnimationFrame(() => {
|
|
676
|
+
this._refreshRaf = null;
|
|
677
|
+
this._doRefresh();
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
_doRefresh() {
|
|
656
682
|
if (!this.el) return;
|
|
657
683
|
const btnMap = this._btnMap || new Map();
|
|
658
684
|
|
|
@@ -700,4 +726,23 @@ export class Toolbar {
|
|
|
700
726
|
hide() {
|
|
701
727
|
if (this.el) this.el.style.display = 'none';
|
|
702
728
|
}
|
|
729
|
+
|
|
730
|
+
/**
|
|
731
|
+
* Tears down and re-renders the toolbar in-place.
|
|
732
|
+
* Call after registering new buttons post-create via context.use(plugin)
|
|
733
|
+
* or AutumnNote.registerButton() to make them appear in the toolbar.
|
|
734
|
+
*/
|
|
735
|
+
rebuild() {
|
|
736
|
+
if (this._refreshRaf) { cancelAnimationFrame(this._refreshRaf); this._refreshRaf = null; }
|
|
737
|
+
this._disposers.forEach((d) => d());
|
|
738
|
+
this._disposers = [];
|
|
739
|
+
if (this.el) this.el.innerHTML = '';
|
|
740
|
+
this._faReady = this._detectFontAwesome();
|
|
741
|
+
this._buildButtons();
|
|
742
|
+
this._btnMap = new Map(
|
|
743
|
+
(this.options.toolbar || []).flat()
|
|
744
|
+
.map(_resolveBtn).filter(Boolean).map((b) => [b.name, b]),
|
|
745
|
+
);
|
|
746
|
+
this.refresh();
|
|
747
|
+
}
|
|
703
748
|
}
|