autumnnote 1.2.0 → 1.3.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 +1 -1
- package/dist/autumnnote.es.js +343 -108
- package/dist/autumnnote.es.js.map +1 -1
- package/dist/autumnnote.umd.js +314 -92
- package/dist/autumnnote.umd.js.map +1 -1
- package/dist/image/banner.png +0 -0
- package/dist/image/favicon.ico +0 -0
- package/package.json +1 -1
- 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/module/BubbleToolbar.js +26 -7
- 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 +28 -7
|
@@ -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
|
}
|
|
@@ -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
|
@@ -115,6 +115,8 @@ export class Toolbar {
|
|
|
115
115
|
this._disposers = [];
|
|
116
116
|
/** @type {Array<() => void>} closers for all open color picker popups */
|
|
117
117
|
this._colorPickerClosers = [];
|
|
118
|
+
/** @type {number|null} rAF handle for debounced refresh */
|
|
119
|
+
this._refreshRaf = null;
|
|
118
120
|
}
|
|
119
121
|
|
|
120
122
|
// ---------------------------------------------------------------------------
|
|
@@ -132,6 +134,8 @@ export class Toolbar {
|
|
|
132
134
|
}
|
|
133
135
|
|
|
134
136
|
destroy() {
|
|
137
|
+
if (this._refreshRaf) cancelAnimationFrame(this._refreshRaf);
|
|
138
|
+
this._refreshRaf = null;
|
|
135
139
|
this._disposers.forEach((d) => d());
|
|
136
140
|
this._disposers = [];
|
|
137
141
|
if (this.el && this.el.parentNode) {
|
|
@@ -237,26 +241,29 @@ export class Toolbar {
|
|
|
237
241
|
|
|
238
242
|
const openPopup = () => {
|
|
239
243
|
isOpen = true;
|
|
240
|
-
// Use fixed positioning to escape overflow-clipping ancestors
|
|
241
|
-
// (toolbar scroll mode and mobile overflow-x:auto both clip position:absolute)
|
|
242
244
|
const rect = btn.getBoundingClientRect();
|
|
245
|
+
|
|
246
|
+
// Measure popup dimensions while invisible so we can set the correct
|
|
247
|
+
// position before the browser paints (matches the color-picker pattern).
|
|
248
|
+
popup.style.visibility = 'hidden';
|
|
243
249
|
popup.style.display = 'block';
|
|
244
250
|
const pw = popup.offsetWidth;
|
|
245
251
|
const ph = popup.offsetHeight;
|
|
252
|
+
|
|
246
253
|
let left = rect.left;
|
|
247
254
|
let top = rect.bottom + 4;
|
|
248
255
|
if (left + pw > window.innerWidth - 8) left = Math.max(8, window.innerWidth - pw - 8);
|
|
249
|
-
if (top
|
|
256
|
+
if (top + ph > window.innerHeight - 8) top = rect.top - ph - 4;
|
|
257
|
+
|
|
250
258
|
popup.style.left = `${left}px`;
|
|
251
259
|
popup.style.top = `${top}px`;
|
|
260
|
+
popup.style.visibility = '';
|
|
252
261
|
btn.setAttribute('aria-expanded', 'true');
|
|
253
262
|
};
|
|
254
263
|
|
|
255
264
|
const closePopup = () => {
|
|
256
265
|
isOpen = false;
|
|
257
266
|
popup.style.display = 'none';
|
|
258
|
-
popup.style.top = '';
|
|
259
|
-
popup.style.left = '';
|
|
260
267
|
btn.setAttribute('aria-expanded', 'false');
|
|
261
268
|
setHighlight(0, 0);
|
|
262
269
|
};
|
|
@@ -286,10 +293,14 @@ export class Toolbar {
|
|
|
286
293
|
|
|
287
294
|
const d5 = on(document, 'click', () => { if (isOpen) closePopup(); });
|
|
288
295
|
|
|
289
|
-
|
|
296
|
+
// Append popup to body so position:fixed is truly viewport-relative,
|
|
297
|
+
// unaffected by any ancestor transform / filter (same pattern as color picker).
|
|
298
|
+
this._disposers.push(d1, d2, d3, d4, d5, () => {
|
|
299
|
+
if (popup.parentNode) popup.parentNode.removeChild(popup);
|
|
300
|
+
});
|
|
290
301
|
|
|
291
302
|
wrap.appendChild(btn);
|
|
292
|
-
|
|
303
|
+
document.body.appendChild(popup);
|
|
293
304
|
return wrap;
|
|
294
305
|
}
|
|
295
306
|
|
|
@@ -646,6 +657,16 @@ export class Toolbar {
|
|
|
646
657
|
// ---------------------------------------------------------------------------
|
|
647
658
|
|
|
648
659
|
refresh() {
|
|
660
|
+
// Debounce via rAF — multiple rapid calls (e.g. afterCommand + button click)
|
|
661
|
+
// collapse into a single update per animation frame.
|
|
662
|
+
if (this._refreshRaf) cancelAnimationFrame(this._refreshRaf);
|
|
663
|
+
this._refreshRaf = requestAnimationFrame(() => {
|
|
664
|
+
this._refreshRaf = null;
|
|
665
|
+
this._doRefresh();
|
|
666
|
+
});
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
_doRefresh() {
|
|
649
670
|
if (!this.el) return;
|
|
650
671
|
const btnMap = this._btnMap || new Map();
|
|
651
672
|
|