autumnnote 1.0.5 → 1.0.7
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 +2 -2
- package/dist/autumnnote.css +1769 -1
- package/dist/autumnnote.es.js +11885 -6463
- package/dist/autumnnote.es.js.map +1 -1
- package/dist/autumnnote.umd.js +11579 -84
- package/dist/autumnnote.umd.js.map +1 -1
- package/package.json +16 -8
- package/src/js/Context.js +8 -6
- package/src/js/core/sanitise.js +20 -2
- package/src/js/editing/Style.js +120 -7
- package/src/js/editing/Typing.js +28 -3
- package/src/js/module/Buttons.js +5 -4
- package/src/js/module/Clipboard.js +25 -20
- package/src/js/module/CodeTooltip.js +10 -6
- package/src/js/module/ContextMenu.js +146 -8
- package/src/js/module/Editor.js +61 -0
- package/src/js/module/FindReplace.js +21 -7
- package/src/js/module/ImageCropOverlay.js +2 -2
- package/src/js/module/ImageDialog.js +17 -2
- package/src/js/module/ImageResizer.js +3 -1
- package/src/js/module/ImageTooltip.js +12 -1
- package/src/js/module/LinkTooltip.js +4 -0
- package/src/js/module/Placeholder.js +2 -1
- package/src/js/module/TableTooltip.js +62 -25
- package/src/js/module/Toolbar.js +129 -100
- package/src/js/module/VideoDialog.js +5 -5
- package/src/js/module/VideoResizer.js +11 -0
- package/src/js/module/VideoTooltip.js +1 -0
- package/src/js/renderer.js +3 -0
- package/src/styles/autumnnote.scss +146 -9
|
@@ -30,12 +30,31 @@ const ICONS = {
|
|
|
30
30
|
removeFormat:`<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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"/></svg>`,
|
|
31
31
|
// Table
|
|
32
32
|
table: `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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>`,
|
|
33
|
+
// Color
|
|
34
|
+
textColor: `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 20L12 4L20 20"/><line x1="7.5" y1="14" x2="16.5" y2="14"/></svg>`,
|
|
35
|
+
highlightColor: `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21v-4l9-9 4 4-9 9z"/><path d="M12 8l4 4"/><line x1="3" y1="21" x2="21" y2="21"/></svg>`,
|
|
36
|
+
noColor: `<svg xmlns="http://www.w3.org/2000/svg" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="4" y1="4" x2="20" y2="20"/><line x1="20" y1="4" x2="4" y2="20"/></svg>`,
|
|
37
|
+
back: `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>`,
|
|
33
38
|
};
|
|
34
39
|
|
|
40
|
+
const COLOR_PRESETS = [
|
|
41
|
+
// Grayscale
|
|
42
|
+
'#000000', '#434343', '#666666', '#999999', '#b7b7b7', '#cccccc', '#efefef', '#ffffff',
|
|
43
|
+
// Saturated
|
|
44
|
+
'#ff0000', '#ff9900', '#ffff00', '#00ff00', '#00ffff', '#4a86e8', '#9900ff', '#ff00ff',
|
|
45
|
+
// Pastel
|
|
46
|
+
'#f4cccc', '#fce5cd', '#fff2cc', '#d9ead3', '#d0e0e3', '#c9daf8', '#d9d2e9', '#ead1dc',
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
function makeColorSubItems(colorType) {
|
|
50
|
+
const label = colorType === 'foreColor' ? 'Text Color' : 'Highlight Color';
|
|
51
|
+
return () => [
|
|
52
|
+
{ back: true, label, navigate: () => defaultItems },
|
|
53
|
+
{ colorPalette: true, colorType },
|
|
54
|
+
];
|
|
55
|
+
}
|
|
56
|
+
|
|
35
57
|
const defaultItems = [
|
|
36
|
-
{ name: 'undo', label: 'Undo', icon: ICONS.undo, action: (ctx) => ctx.invoke('editor.undo') },
|
|
37
|
-
{ name: 'redo', label: 'Redo', icon: ICONS.redo, action: (ctx) => ctx.invoke('editor.redo') },
|
|
38
|
-
{ separator: true },
|
|
39
58
|
{ name: 'cut', label: 'Cut', icon: ICONS.cut, action: () => document.execCommand('cut') },
|
|
40
59
|
{ name: 'copy', label: 'Copy', icon: ICONS.copy, action: () => document.execCommand('copy') },
|
|
41
60
|
{ name: 'paste', label: 'Paste', icon: ICONS.paste, action: (ctx) => {
|
|
@@ -76,6 +95,9 @@ const defaultItems = [
|
|
|
76
95
|
{ name: 'italic', label: 'Italic', icon: ICONS.italic, action: (ctx) => ctx.invoke('editor.italic') },
|
|
77
96
|
{ name: 'underline', label: 'Underline', icon: ICONS.underline, action: (ctx) => ctx.invoke('editor.underline') },
|
|
78
97
|
{ separator: true },
|
|
98
|
+
{ name: 'textColor', label: 'Text Color', icon: ICONS.textColor, colorStrip: 'foreColor', navigate: makeColorSubItems('foreColor') },
|
|
99
|
+
{ name: 'highlightColor', label: 'Highlight Color', icon: ICONS.highlightColor, colorStrip: 'hiliteColor', navigate: makeColorSubItems('hiliteColor') },
|
|
100
|
+
{ separator: true },
|
|
79
101
|
{ name: 'copyFormat', label: 'Copy Format', icon: ICONS.copyFormat, action: (ctx) => ctx.invoke('contextMenu.copyFormat') },
|
|
80
102
|
{ name: 'pasteFormat', label: 'Paste Format', icon: ICONS.pasteFormat, action: (ctx) => ctx.invoke('contextMenu.pasteFormat'), disabled: (ctx) => !ctx.invoke('contextMenu.hasCopiedFormat') },
|
|
81
103
|
{ name: 'removeFormat', label: 'Remove Format', icon: ICONS.removeFormat, action: (ctx) => ctx.invoke('contextMenu.removeFormat') },
|
|
@@ -152,8 +174,10 @@ export class ContextMenu {
|
|
|
152
174
|
backBtn.appendChild(createElement('span', { class: 'an-context-label' }, [it.label || 'Back']));
|
|
153
175
|
const off = on(backBtn, 'click', (e) => {
|
|
154
176
|
e.stopPropagation();
|
|
177
|
+
const curLeft = parseFloat(this.el.style.left);
|
|
178
|
+
const curTop = parseFloat(this.el.style.top);
|
|
155
179
|
this._renderItems(it.navigate());
|
|
156
|
-
this._reposition();
|
|
180
|
+
this._reposition(curLeft, curTop);
|
|
157
181
|
});
|
|
158
182
|
this._menuDisposers.push(off);
|
|
159
183
|
this.el.appendChild(backBtn);
|
|
@@ -164,9 +188,21 @@ export class ContextMenu {
|
|
|
164
188
|
if (it.navigate) {
|
|
165
189
|
const btn = createElement('button', { type: 'button', class: 'an-context-item an-context-submenu', 'data-name': it.name || '' });
|
|
166
190
|
if (it.icon) {
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
191
|
+
if (it.colorStrip) {
|
|
192
|
+
// Toolbar-style icon: SVG stacked above a colored strip
|
|
193
|
+
const iconWrap = createElement('span', { class: 'an-context-icon an-context-icon--color', 'aria-hidden': 'true' });
|
|
194
|
+
const svgSpan = createElement('span', { class: 'an-context-icon-svg' });
|
|
195
|
+
svgSpan.innerHTML = it.icon;
|
|
196
|
+
const strip = createElement('span', { class: 'an-context-color-strip' });
|
|
197
|
+
strip.style.background = this._getSelectionColor(it.colorStrip);
|
|
198
|
+
iconWrap.appendChild(svgSpan);
|
|
199
|
+
iconWrap.appendChild(strip);
|
|
200
|
+
btn.appendChild(iconWrap);
|
|
201
|
+
} else {
|
|
202
|
+
const iconSpan = createElement('span', { class: 'an-context-icon', 'aria-hidden': 'true' });
|
|
203
|
+
iconSpan.innerHTML = it.icon;
|
|
204
|
+
btn.appendChild(iconSpan);
|
|
205
|
+
}
|
|
170
206
|
}
|
|
171
207
|
btn.appendChild(createElement('span', { class: 'an-context-label' }, [it.label || it.name]));
|
|
172
208
|
const chevron = createElement('span', { class: 'an-context-chevron', 'aria-hidden': 'true' });
|
|
@@ -174,14 +210,62 @@ export class ContextMenu {
|
|
|
174
210
|
btn.appendChild(chevron);
|
|
175
211
|
const off = on(btn, 'click', (e) => {
|
|
176
212
|
e.stopPropagation();
|
|
213
|
+
const curLeft = parseFloat(this.el.style.left);
|
|
214
|
+
const curTop = parseFloat(this.el.style.top);
|
|
177
215
|
this._renderItems(it.navigate());
|
|
178
|
-
this._reposition();
|
|
216
|
+
this._reposition(curLeft, curTop);
|
|
179
217
|
});
|
|
180
218
|
this._menuDisposers.push(off);
|
|
181
219
|
this.el.appendChild(btn);
|
|
182
220
|
return;
|
|
183
221
|
}
|
|
184
222
|
|
|
223
|
+
// Color palette item — renders inline color swatches
|
|
224
|
+
if (it.colorPalette) {
|
|
225
|
+
const palette = createElement('div', { class: 'an-context-color-palette' });
|
|
226
|
+
COLOR_PRESETS.forEach((color) => {
|
|
227
|
+
const sw = createElement('div', {
|
|
228
|
+
class: 'an-context-color-swatch',
|
|
229
|
+
title: color,
|
|
230
|
+
role: 'button',
|
|
231
|
+
'aria-label': color,
|
|
232
|
+
});
|
|
233
|
+
sw.style.background = color;
|
|
234
|
+
const offSw = on(sw, 'click', (e) => { e.stopPropagation(); this._applyColor(it.colorType, color); });
|
|
235
|
+
this._menuDisposers.push(offSw);
|
|
236
|
+
palette.appendChild(sw);
|
|
237
|
+
});
|
|
238
|
+
if (it.colorType === 'hiliteColor') {
|
|
239
|
+
const noColor = createElement('div', {
|
|
240
|
+
class: 'an-context-color-swatch an-context-color-none',
|
|
241
|
+
title: 'No highlight',
|
|
242
|
+
role: 'button',
|
|
243
|
+
'aria-label': 'No highlight',
|
|
244
|
+
});
|
|
245
|
+
noColor.innerHTML = ICONS.noColor;
|
|
246
|
+
const offNo = on(noColor, 'click', (e) => { e.stopPropagation(); this._applyColor('hiliteColor', 'transparent'); });
|
|
247
|
+
this._menuDisposers.push(offNo);
|
|
248
|
+
palette.appendChild(noColor);
|
|
249
|
+
}
|
|
250
|
+
this.el.appendChild(palette);
|
|
251
|
+
|
|
252
|
+
// Custom color row
|
|
253
|
+
const customRow = createElement('div', { class: 'an-context-color-custom' });
|
|
254
|
+
const colorInput = createElement('input', {
|
|
255
|
+
type: 'color',
|
|
256
|
+
value: it.colorType === 'foreColor' ? '#000000' : '#ffff00',
|
|
257
|
+
title: 'Custom color',
|
|
258
|
+
'aria-label': 'Custom color',
|
|
259
|
+
});
|
|
260
|
+
const customLabel = createElement('span', {}, ['Custom…']);
|
|
261
|
+
const offCustom = on(colorInput, 'change', () => this._applyColor(it.colorType, colorInput.value));
|
|
262
|
+
this._menuDisposers.push(offCustom);
|
|
263
|
+
customRow.appendChild(colorInput);
|
|
264
|
+
customRow.appendChild(customLabel);
|
|
265
|
+
this.el.appendChild(customRow);
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
|
|
185
269
|
// Table grid picker item — expands an inline grid panel when clicked
|
|
186
270
|
if (it.tableGrid) {
|
|
187
271
|
const GRID_ROWS = 8, GRID_COLS = 8;
|
|
@@ -289,6 +373,7 @@ export class ContextMenu {
|
|
|
289
373
|
const editable = this.context.layoutInfo && this.context.layoutInfo.editable;
|
|
290
374
|
if (!editable) return;
|
|
291
375
|
if (!editable.contains(event.target)) return;
|
|
376
|
+
if (this.context.layoutInfo.container.classList.contains('an-disabled')) return;
|
|
292
377
|
event.preventDefault();
|
|
293
378
|
this._lastX = event.clientX;
|
|
294
379
|
this._lastY = event.clientY;
|
|
@@ -318,7 +403,29 @@ export class ContextMenu {
|
|
|
318
403
|
let left = rx;
|
|
319
404
|
let top = ry;
|
|
320
405
|
if (left + rect.width > window.innerWidth) left = window.innerWidth - rect.width - 8;
|
|
406
|
+
if (left < 8) left = 8;
|
|
321
407
|
if (top + rect.height > window.innerHeight) top = window.innerHeight - rect.height - 8;
|
|
408
|
+
if (top < 8) top = 8;
|
|
409
|
+
|
|
410
|
+
// Avoid covering the saved selection range
|
|
411
|
+
if (this._savedRange) {
|
|
412
|
+
try {
|
|
413
|
+
const sel = this._savedRange.getBoundingClientRect();
|
|
414
|
+
if (sel.width > 0 || sel.height > 0) {
|
|
415
|
+
const overlaps = top < sel.bottom && (top + rect.height) > sel.top &&
|
|
416
|
+
left < sel.right && (left + rect.width) > sel.left;
|
|
417
|
+
if (overlaps) {
|
|
418
|
+
const belowTop = sel.bottom + 6;
|
|
419
|
+
if (belowTop + rect.height <= window.innerHeight - 8) {
|
|
420
|
+
top = belowTop;
|
|
421
|
+
} else {
|
|
422
|
+
top = Math.max(8, sel.top - rect.height - 6);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
} catch (_) { /* stale range — ignore */ }
|
|
427
|
+
}
|
|
428
|
+
|
|
322
429
|
this.el.style.left = `${left}px`;
|
|
323
430
|
this.el.style.top = `${top}px`;
|
|
324
431
|
}
|
|
@@ -333,6 +440,37 @@ export class ContextMenu {
|
|
|
333
440
|
// Format operations (Copy Format / Paste Format / Remove Format)
|
|
334
441
|
// ---------------------------------------------------------------------------
|
|
335
442
|
|
|
443
|
+
/** Read the current selection's text or highlight color for the strip.
|
|
444
|
+
* @param {'foreColor'|'hiliteColor'} type
|
|
445
|
+
* @returns {string} CSS color string
|
|
446
|
+
*/
|
|
447
|
+
_getSelectionColor(type) {
|
|
448
|
+
const range = this._savedRange;
|
|
449
|
+
if (!range) return type === 'foreColor' ? '#000000' : 'transparent';
|
|
450
|
+
let node = range.startContainer;
|
|
451
|
+
if (node.nodeType === Node.TEXT_NODE) node = node.parentElement;
|
|
452
|
+
if (!node) return type === 'foreColor' ? '#000000' : 'transparent';
|
|
453
|
+
const cs = window.getComputedStyle(node);
|
|
454
|
+
if (type === 'foreColor') {
|
|
455
|
+
return cs.color || '#000000';
|
|
456
|
+
}
|
|
457
|
+
const bg = cs.backgroundColor;
|
|
458
|
+
return (!bg || bg === 'rgba(0, 0, 0, 0)' || bg === 'transparent') ? 'transparent' : bg;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/** Restore selection, apply a color command, then hide the menu. */
|
|
462
|
+
_applyColor(type, color) {
|
|
463
|
+
const editable = this.context.layoutInfo && this.context.layoutInfo.editable;
|
|
464
|
+
if (!editable || !this._savedRange) return;
|
|
465
|
+
editable.focus();
|
|
466
|
+
const sel = window.getSelection();
|
|
467
|
+
sel.removeAllRanges();
|
|
468
|
+
sel.addRange(this._savedRange.cloneRange());
|
|
469
|
+
document.execCommand(type, false, color);
|
|
470
|
+
this.context.invoke('editor.afterCommand');
|
|
471
|
+
this.hide();
|
|
472
|
+
}
|
|
473
|
+
|
|
336
474
|
/** Returns true if a format has been copied — used to disable Paste Format. */
|
|
337
475
|
hasCopiedFormat() { return !!this._copiedFormat; }
|
|
338
476
|
|
package/src/js/module/Editor.js
CHANGED
|
@@ -143,6 +143,8 @@ export class Editor {
|
|
|
143
143
|
sel.addRange(nr);
|
|
144
144
|
};
|
|
145
145
|
|
|
146
|
+
const isReadOnly = () => this.context.layoutInfo.container.classList.contains('an-disabled');
|
|
147
|
+
|
|
146
148
|
this._disposers.push(
|
|
147
149
|
on(editable, 'keydown', onKeydown),
|
|
148
150
|
on(editable, 'beforeinput', onBeforeInput),
|
|
@@ -151,6 +153,48 @@ export class Editor {
|
|
|
151
153
|
on(editable, 'click', onCheckboxClick),
|
|
152
154
|
on(editable, 'mouseup', fixChecklistCursor),
|
|
153
155
|
on(editable, 'keyup', fixChecklistCursor),
|
|
156
|
+
// Block drag-out and external drops in read-only mode
|
|
157
|
+
on(editable, 'dragstart', (e) => { if (isReadOnly()) e.preventDefault(); }),
|
|
158
|
+
on(editable, 'drop', (e) => { if (isReadOnly()) e.preventDefault(); }),
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
// B-V: Re-apply superscript / subscript after IME composition ends.
|
|
162
|
+
// Vietnamese and other IME-based inputs fire compositionstart/end around
|
|
163
|
+
// the inserted characters. During composition the browser may place the
|
|
164
|
+
// provisional text outside the current <sup>/<sub> element. When
|
|
165
|
+
// compositionend fires we detect whether the cursor escaped the sup/sub
|
|
166
|
+
// context and re-apply the command so the composed character stays inside.
|
|
167
|
+
/** @type {string|null} 'superscript' | 'subscript' | null */
|
|
168
|
+
let _compositionSupSub = null;
|
|
169
|
+
const onCompositionStart = () => {
|
|
170
|
+
const sel = window.getSelection();
|
|
171
|
+
if (!sel || !sel.rangeCount) { _compositionSupSub = null; return; }
|
|
172
|
+
let node = sel.getRangeAt(0).startContainer;
|
|
173
|
+
if (node.nodeType === Node.TEXT_NODE) node = node.parentElement;
|
|
174
|
+
if (node && node.closest) {
|
|
175
|
+
if (node.closest('sup')) _compositionSupSub = 'superscript';
|
|
176
|
+
else if (node.closest('sub')) _compositionSupSub = 'subscript';
|
|
177
|
+
else _compositionSupSub = null;
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
const onCompositionEnd = () => {
|
|
181
|
+
const tag = _compositionSupSub;
|
|
182
|
+
_compositionSupSub = null;
|
|
183
|
+
if (!tag) return;
|
|
184
|
+
const sel = window.getSelection();
|
|
185
|
+
if (!sel || !sel.rangeCount) return;
|
|
186
|
+
let node = sel.getRangeAt(0).startContainer;
|
|
187
|
+
if (node.nodeType === Node.TEXT_NODE) node = node.parentElement;
|
|
188
|
+
const inContext = node && node.closest &&
|
|
189
|
+
(tag === 'superscript' ? node.closest('sup') : node.closest('sub'));
|
|
190
|
+
if (!inContext) {
|
|
191
|
+
// The composed character escaped the sup/sub — re-apply the format.
|
|
192
|
+
document.execCommand(tag);
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
this._disposers.push(
|
|
196
|
+
on(editable, 'compositionstart', onCompositionStart),
|
|
197
|
+
on(editable, 'compositionend', onCompositionEnd),
|
|
154
198
|
);
|
|
155
199
|
}
|
|
156
200
|
|
|
@@ -256,6 +300,9 @@ export class Editor {
|
|
|
256
300
|
// ---------------------------------------------------------------------------
|
|
257
301
|
|
|
258
302
|
afterCommand() {
|
|
303
|
+
// C4: Remove figure.an-figure elements whose <img> has been deleted so
|
|
304
|
+
// orphaned figcaptions do not accumulate in the DOM.
|
|
305
|
+
this._cleanOrphanedFigures();
|
|
259
306
|
// Immediate: keep toolbar and statusbar in sync on every mutation.
|
|
260
307
|
this.context.invoke('toolbar.refresh');
|
|
261
308
|
this.context.invoke('statusbar.update');
|
|
@@ -279,6 +326,20 @@ export class Editor {
|
|
|
279
326
|
}, 400);
|
|
280
327
|
}
|
|
281
328
|
|
|
329
|
+
/**
|
|
330
|
+
* C4: Removes figure.an-figure elements that no longer contain an <img>.
|
|
331
|
+
* This happens when a user selects only the image (not the whole figure)
|
|
332
|
+
* and deletes or replaces it, leaving a dangling figcaption.
|
|
333
|
+
*/
|
|
334
|
+
_cleanOrphanedFigures() {
|
|
335
|
+
const editable = this.context.layoutInfo.editable;
|
|
336
|
+
editable.querySelectorAll('figure.an-figure').forEach((fig) => {
|
|
337
|
+
if (!fig.querySelector('img')) {
|
|
338
|
+
fig.parentNode.removeChild(fig);
|
|
339
|
+
}
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
|
|
282
343
|
// ---------------------------------------------------------------------------
|
|
283
344
|
// Focus management
|
|
284
345
|
// ---------------------------------------------------------------------------
|
|
@@ -34,6 +34,11 @@ export class FindReplace {
|
|
|
34
34
|
/** @type {'find'|'replace'} */
|
|
35
35
|
this._mode = 'find';
|
|
36
36
|
|
|
37
|
+
/** Cached compiled regex — reused when query and case-sensitivity are unchanged */
|
|
38
|
+
this._queryRegex = null;
|
|
39
|
+
this._lastQuery = null;
|
|
40
|
+
this._lastCaseSensitive = null;
|
|
41
|
+
|
|
37
42
|
this._disposers = [];
|
|
38
43
|
this._removeTrap = null;
|
|
39
44
|
this._focusTimer = null;
|
|
@@ -267,7 +272,10 @@ export class FindReplace {
|
|
|
267
272
|
|
|
268
273
|
this._currentIndex = 0;
|
|
269
274
|
|
|
270
|
-
// Wrap in reverse order so earlier
|
|
275
|
+
// Wrap matches in reverse order so earlier text offsets stay valid when
|
|
276
|
+
// later sections of the same text node are split by surroundContents().
|
|
277
|
+
// Use push() instead of unshift() to avoid O(n²) shifting on every insert;
|
|
278
|
+
// reverse() at the end restores forward document order in O(n).
|
|
271
279
|
for (let i = rawMatches.length - 1; i >= 0; i--) {
|
|
272
280
|
const { node, start, end } = rawMatches[i];
|
|
273
281
|
try {
|
|
@@ -277,13 +285,14 @@ export class FindReplace {
|
|
|
277
285
|
const mark = document.createElement('mark');
|
|
278
286
|
mark.className = 'an-highlight';
|
|
279
287
|
range.surroundContents(mark);
|
|
280
|
-
this._matches.
|
|
288
|
+
this._matches.push({ mark });
|
|
281
289
|
} catch (_) {
|
|
282
290
|
// surroundContents fails when the range crosses element boundaries.
|
|
283
291
|
// This can happen with <br> inside matched text — skip safely.
|
|
284
|
-
this._matches.
|
|
292
|
+
this._matches.push({ mark: null });
|
|
285
293
|
}
|
|
286
294
|
}
|
|
295
|
+
this._matches.reverse(); // O(n) — restore forward document order
|
|
287
296
|
|
|
288
297
|
// Drop entries where wrapping failed so the counter and navigation are accurate
|
|
289
298
|
this._matches = this._matches.filter((m) => m.mark);
|
|
@@ -304,10 +313,15 @@ export class FindReplace {
|
|
|
304
313
|
*/
|
|
305
314
|
_findRawMatches(query, root) {
|
|
306
315
|
const results = [];
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
316
|
+
// Reuse compiled regex when query and case-sensitivity haven't changed
|
|
317
|
+
if (this._lastQuery !== query || this._lastCaseSensitive !== this._caseSensitive) {
|
|
318
|
+
const flags = this._caseSensitive ? 'g' : 'gi';
|
|
319
|
+
const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
320
|
+
this._queryRegex = new RegExp(escaped, flags);
|
|
321
|
+
this._lastQuery = query;
|
|
322
|
+
this._lastCaseSensitive = this._caseSensitive;
|
|
323
|
+
}
|
|
324
|
+
const re = this._queryRegex;
|
|
311
325
|
|
|
312
326
|
const walker = document.createTreeWalker(root, 0x4 /* NodeFilter.SHOW_TEXT */);
|
|
313
327
|
let node;
|
|
@@ -66,7 +66,7 @@ function drawCropToCanvas(img, naturalRect, renderW, renderH) {
|
|
|
66
66
|
if (
|
|
67
67
|
img.src.startsWith('data:') ||
|
|
68
68
|
img.src.startsWith('blob:') ||
|
|
69
|
-
img.src.startsWith(location.origin)
|
|
69
|
+
img.src.startsWith(window.location.origin)
|
|
70
70
|
) {
|
|
71
71
|
tryDraw(img);
|
|
72
72
|
return;
|
|
@@ -489,7 +489,7 @@ export class ImageCropOverlay {
|
|
|
489
489
|
|
|
490
490
|
if (!canvas) {
|
|
491
491
|
// Cross-origin failure — inform user and abort
|
|
492
|
-
alert(
|
|
492
|
+
window.alert(
|
|
493
493
|
'Cannot crop this image: the image server does not allow cross-origin access.\n' +
|
|
494
494
|
'Upload the image directly to use the crop tool.',
|
|
495
495
|
);
|
|
@@ -121,9 +121,12 @@ export class ImageDialog {
|
|
|
121
121
|
accept: 'image/*',
|
|
122
122
|
});
|
|
123
123
|
this._fileInput = fileInput;
|
|
124
|
+
// Hint line shown below the file input for format errors
|
|
125
|
+
const fileHint = createElement('p', { class: 'an-dialog-hint' });
|
|
126
|
+
this._fileHint = fileHint;
|
|
124
127
|
const d = on(fileInput, 'change', () => this._onFileChange());
|
|
125
128
|
this._disposers.push(d);
|
|
126
|
-
box.append(fileLabel, fileInput);
|
|
129
|
+
box.append(fileLabel, fileInput, fileHint);
|
|
127
130
|
}
|
|
128
131
|
|
|
129
132
|
// Buttons
|
|
@@ -156,10 +159,22 @@ export class ImageDialog {
|
|
|
156
159
|
const file = this._fileInput && this._fileInput.files && this._fileInput.files[0];
|
|
157
160
|
if (!file || !file.type.startsWith('image/')) return;
|
|
158
161
|
|
|
162
|
+
// C2: Reject image formats browsers cannot display (TIFF, BMP, etc.).
|
|
163
|
+
const UNSUPPORTED = ['image/tiff', 'image/x-tiff', 'image/bmp', 'image/x-bmp', 'image/x-ms-bmp'];
|
|
164
|
+
if (UNSUPPORTED.includes(file.type)) {
|
|
165
|
+
const message = `Format "${file.type}" is not supported for display in web browsers. Please convert to PNG, JPEG, or WebP first.`;
|
|
166
|
+
if (this._fileHint) this._fileHint.textContent = message;
|
|
167
|
+
this.context.triggerEvent('imageError', { file, message });
|
|
168
|
+
this._fileInput.value = '';
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
if (this._fileHint) this._fileHint.textContent = '';
|
|
172
|
+
|
|
159
173
|
// Validate file size (max 5 MB by default)
|
|
160
174
|
const maxSize = (this.options.maxImageSize || 5) * 1024 * 1024;
|
|
161
175
|
if (file.size > maxSize) {
|
|
162
176
|
const message = `Image file is too large. Maximum allowed size is ${this.options.maxImageSize || 5} MB.`;
|
|
177
|
+
if (this._fileHint) this._fileHint.textContent = message;
|
|
163
178
|
console.warn('[AutumnNote] ImageDialog:', message);
|
|
164
179
|
this.context.triggerEvent('imageError', { file, message });
|
|
165
180
|
this._fileInput.value = '';
|
|
@@ -168,7 +183,7 @@ export class ImageDialog {
|
|
|
168
183
|
|
|
169
184
|
const reader = new FileReader();
|
|
170
185
|
reader.onload = (e) => {
|
|
171
|
-
this._urlInput.value = e.target.result;
|
|
186
|
+
this._urlInput.value = /** @type {string} */ (e.target.result);
|
|
172
187
|
};
|
|
173
188
|
reader.readAsDataURL(file);
|
|
174
189
|
}
|
|
@@ -50,12 +50,13 @@ export class ImageResizer {
|
|
|
50
50
|
on(editable, 'click', (e) => this._onEditorClick(e)),
|
|
51
51
|
// Also select on right-click so the highlight shows before the context menu
|
|
52
52
|
on(editable, 'contextmenu', (e) => {
|
|
53
|
+
if (this.context.layoutInfo.container.classList.contains('an-disabled')) return;
|
|
53
54
|
const img = e.target.closest('img');
|
|
54
55
|
if (img) this._select(img);
|
|
55
56
|
}),
|
|
56
57
|
on(document, 'click', (e) => this._onDocClick(e)),
|
|
57
58
|
on(window, 'scroll', () => this._updateOverlayPosition(), { passive: true }),
|
|
58
|
-
on(window, 'resize', onWindowResize),
|
|
59
|
+
on(window, 'resize', onWindowResize, { passive: true }),
|
|
59
60
|
on(editable, 'scroll', () => this._updateOverlayPosition(), { passive: true }),
|
|
60
61
|
);
|
|
61
62
|
|
|
@@ -127,6 +128,7 @@ export class ImageResizer {
|
|
|
127
128
|
}
|
|
128
129
|
|
|
129
130
|
_onEditorClick(e) {
|
|
131
|
+
if (this.context.layoutInfo.container.classList.contains('an-disabled')) return;
|
|
130
132
|
const img = e.target.closest('img');
|
|
131
133
|
if (img) {
|
|
132
134
|
// Prevent browser from deselecting the current text selection / opening native resize
|
|
@@ -38,6 +38,7 @@ export class ImageTooltip {
|
|
|
38
38
|
|
|
39
39
|
this._disposers.push(
|
|
40
40
|
on(editable, 'mouseover', (e) => {
|
|
41
|
+
if (this.context.layoutInfo.container.classList.contains('an-disabled')) return;
|
|
41
42
|
const img = e.target.closest('img');
|
|
42
43
|
// Skip when the image is inside a link — LinkTooltip takes priority there
|
|
43
44
|
if (img && editable.contains(img) && !img.closest('a[href]')) {
|
|
@@ -173,7 +174,9 @@ export class ImageTooltip {
|
|
|
173
174
|
_scheduleHide() {
|
|
174
175
|
clearTimeout(this._showTimer);
|
|
175
176
|
this._showTimer = null;
|
|
176
|
-
|
|
177
|
+
// Always reset the hide timer so rapid mouseout→mouseover sequences
|
|
178
|
+
// don't leave a stale timer that hides the tooltip prematurely.
|
|
179
|
+
clearTimeout(this._hideTimer);
|
|
177
180
|
this._hideTimer = setTimeout(() => this._hide(), HIDE_DELAY);
|
|
178
181
|
}
|
|
179
182
|
|
|
@@ -225,6 +228,8 @@ export class ImageTooltip {
|
|
|
225
228
|
const target = img.closest('figure.an-figure') || img;
|
|
226
229
|
target.style.float = value;
|
|
227
230
|
target.style.display = '';
|
|
231
|
+
// Clear explicit width set for centering so the figure is free to float
|
|
232
|
+
if (target !== img) target.style.width = '';
|
|
228
233
|
target.style.marginLeft = value === 'right' ? '12px' : '';
|
|
229
234
|
target.style.marginRight = value === 'left' ? '12px' : '';
|
|
230
235
|
this.context.invoke('editor.afterCommand');
|
|
@@ -238,6 +243,9 @@ export class ImageTooltip {
|
|
|
238
243
|
const target = img.closest('figure.an-figure') || img;
|
|
239
244
|
target.style.float = '';
|
|
240
245
|
target.style.display = 'block';
|
|
246
|
+
// C1/C3: use fit-content so the block figure shrinks to the image width;
|
|
247
|
+
// without an explicit width, margin:auto has no effect on a 100%-wide block.
|
|
248
|
+
if (target !== img) target.style.width = 'fit-content';
|
|
241
249
|
target.style.marginLeft = 'auto';
|
|
242
250
|
target.style.marginRight = 'auto';
|
|
243
251
|
this.context.invoke('editor.afterCommand');
|
|
@@ -333,7 +341,10 @@ export class ImageTooltip {
|
|
|
333
341
|
img.style.marginLeft = '';
|
|
334
342
|
img.style.marginRight = '';
|
|
335
343
|
} else if (img.style.display === 'block' && img.style.marginLeft === 'auto') {
|
|
344
|
+
// C1/C3: use fit-content so the centered block figure tightly wraps the
|
|
345
|
+
// image, making margin:auto actually centre it regardless of image width.
|
|
336
346
|
figure.style.display = 'block';
|
|
347
|
+
figure.style.width = 'fit-content';
|
|
337
348
|
figure.style.marginLeft = 'auto';
|
|
338
349
|
figure.style.marginRight = 'auto';
|
|
339
350
|
img.style.display = '';
|
|
@@ -125,9 +125,13 @@ export class LinkTooltip {
|
|
|
125
125
|
}
|
|
126
126
|
|
|
127
127
|
_show(anchor) {
|
|
128
|
+
const isReadOnly = this.context.layoutInfo.container.classList.contains('an-disabled');
|
|
128
129
|
const url = anchor.getAttribute('href') || '';
|
|
129
130
|
this._urlLabel.textContent = this._truncateUrl(url);
|
|
130
131
|
this._urlLabel.title = url;
|
|
132
|
+
// In read-only mode keep only open + copy; hide edit and unlink
|
|
133
|
+
this._editBtn.style.display = isReadOnly ? 'none' : '';
|
|
134
|
+
this._unlinkBtn.style.display = isReadOnly ? 'none' : '';
|
|
131
135
|
this._el.style.display = 'flex';
|
|
132
136
|
this._positionNear(anchor);
|
|
133
137
|
}
|
|
@@ -39,7 +39,8 @@ export class Placeholder {
|
|
|
39
39
|
_update() {
|
|
40
40
|
const editable = this.context.layoutInfo.editable;
|
|
41
41
|
const isFocused = document.activeElement === editable;
|
|
42
|
-
const isEmpty = !editable.textContent.trim() &&
|
|
42
|
+
const isEmpty = !editable.textContent.trim() &&
|
|
43
|
+
!editable.querySelector('img, table, hr, .an-video-wrapper');
|
|
43
44
|
editable.classList.toggle('an-placeholder', isEmpty && !isFocused);
|
|
44
45
|
}
|
|
45
46
|
}
|