autumnnote 1.0.6 → 1.0.8
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 +3 -3
- package/dist/autumnnote.css +168 -4
- package/dist/autumnnote.es.js +886 -115
- package/dist/autumnnote.es.js.map +1 -1
- package/dist/autumnnote.umd.js +886 -115
- package/dist/autumnnote.umd.js.map +1 -1
- package/package.json +3 -2
- package/src/js/Context.js +6 -0
- package/src/js/core/sanitise.js +20 -2
- package/src/js/editing/Style.js +288 -24
- package/src/js/editing/Typing.js +2 -2
- package/src/js/module/Buttons.js +5 -4
- package/src/js/module/Clipboard.js +8 -0
- package/src/js/module/CodeTooltip.js +1 -0
- package/src/js/module/ContextMenu.js +146 -8
- package/src/js/module/Editor.js +73 -1
- package/src/js/module/EmojiDialog.js +11 -0
- package/src/js/module/IconDialog.js +11 -0
- package/src/js/module/ImageCropOverlay.js +2 -2
- package/src/js/module/ImageDialog.js +22 -3
- package/src/js/module/ImageResizer.js +2 -0
- package/src/js/module/ImageTooltip.js +9 -0
- package/src/js/module/LinkTooltip.js +4 -0
- package/src/js/module/Placeholder.js +7 -1
- package/src/js/module/TableTooltip.js +399 -86
- package/src/js/module/Toolbar.js +21 -3
- 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 +194 -12
|
@@ -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,59 @@ 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
|
+
// D-1: Also block dragging of iframes and .an-video-wrapper elements in
|
|
158
|
+
// edit mode — a user can inadvertently drag the iframe out of its wrapper
|
|
159
|
+
// (making it playable/removable from contenteditable protection) by holding
|
|
160
|
+
// the mouse and moving outside the wrapper before releasing.
|
|
161
|
+
on(editable, 'dragstart', (e) => {
|
|
162
|
+
if (isReadOnly()) { e.preventDefault(); return; }
|
|
163
|
+
const target = e.target;
|
|
164
|
+
if (target && (target.nodeName === 'IFRAME' ||
|
|
165
|
+
(target.closest && target.closest('.an-video-wrapper')))) {
|
|
166
|
+
e.preventDefault();
|
|
167
|
+
}
|
|
168
|
+
}),
|
|
169
|
+
on(editable, 'drop', (e) => { if (isReadOnly()) e.preventDefault(); }),
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
// B-V: Re-apply superscript / subscript after IME composition ends.
|
|
173
|
+
// Vietnamese and other IME-based inputs fire compositionstart/end around
|
|
174
|
+
// the inserted characters. During composition the browser may place the
|
|
175
|
+
// provisional text outside the current <sup>/<sub> element. When
|
|
176
|
+
// compositionend fires we detect whether the cursor escaped the sup/sub
|
|
177
|
+
// context and re-apply the command so the composed character stays inside.
|
|
178
|
+
/** @type {string|null} 'superscript' | 'subscript' | null */
|
|
179
|
+
let _compositionSupSub = null;
|
|
180
|
+
const onCompositionStart = () => {
|
|
181
|
+
const sel = window.getSelection();
|
|
182
|
+
if (!sel || !sel.rangeCount) { _compositionSupSub = null; return; }
|
|
183
|
+
let node = sel.getRangeAt(0).startContainer;
|
|
184
|
+
if (node.nodeType === Node.TEXT_NODE) node = node.parentElement;
|
|
185
|
+
if (node && node.closest) {
|
|
186
|
+
if (node.closest('sup')) _compositionSupSub = 'superscript';
|
|
187
|
+
else if (node.closest('sub')) _compositionSupSub = 'subscript';
|
|
188
|
+
else _compositionSupSub = null;
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
const onCompositionEnd = () => {
|
|
192
|
+
const tag = _compositionSupSub;
|
|
193
|
+
_compositionSupSub = null;
|
|
194
|
+
if (!tag) return;
|
|
195
|
+
const sel = window.getSelection();
|
|
196
|
+
if (!sel || !sel.rangeCount) return;
|
|
197
|
+
let node = sel.getRangeAt(0).startContainer;
|
|
198
|
+
if (node.nodeType === Node.TEXT_NODE) node = node.parentElement;
|
|
199
|
+
const inContext = node && node.closest &&
|
|
200
|
+
(tag === 'superscript' ? node.closest('sup') : node.closest('sub'));
|
|
201
|
+
if (!inContext) {
|
|
202
|
+
// The composed character escaped the sup/sub — re-apply the format.
|
|
203
|
+
document.execCommand(tag);
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
this._disposers.push(
|
|
207
|
+
on(editable, 'compositionstart', onCompositionStart),
|
|
208
|
+
on(editable, 'compositionend', onCompositionEnd),
|
|
154
209
|
);
|
|
155
210
|
}
|
|
156
211
|
|
|
@@ -256,6 +311,9 @@ export class Editor {
|
|
|
256
311
|
// ---------------------------------------------------------------------------
|
|
257
312
|
|
|
258
313
|
afterCommand() {
|
|
314
|
+
// C4: Remove figure.an-figure elements whose <img> has been deleted so
|
|
315
|
+
// orphaned figcaptions do not accumulate in the DOM.
|
|
316
|
+
this._cleanOrphanedFigures();
|
|
259
317
|
// Immediate: keep toolbar and statusbar in sync on every mutation.
|
|
260
318
|
this.context.invoke('toolbar.refresh');
|
|
261
319
|
this.context.invoke('statusbar.update');
|
|
@@ -279,6 +337,20 @@ export class Editor {
|
|
|
279
337
|
}, 400);
|
|
280
338
|
}
|
|
281
339
|
|
|
340
|
+
/**
|
|
341
|
+
* C4: Removes figure.an-figure elements that no longer contain an <img>.
|
|
342
|
+
* This happens when a user selects only the image (not the whole figure)
|
|
343
|
+
* and deletes or replaces it, leaving a dangling figcaption.
|
|
344
|
+
*/
|
|
345
|
+
_cleanOrphanedFigures() {
|
|
346
|
+
const editable = this.context.layoutInfo.editable;
|
|
347
|
+
editable.querySelectorAll('figure.an-figure').forEach((fig) => {
|
|
348
|
+
if (!fig.querySelector('img')) {
|
|
349
|
+
fig.parentNode.removeChild(fig);
|
|
350
|
+
}
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
|
|
282
354
|
// ---------------------------------------------------------------------------
|
|
283
355
|
// Focus management
|
|
284
356
|
// ---------------------------------------------------------------------------
|
|
@@ -309,7 +381,7 @@ export class Editor {
|
|
|
309
381
|
* @param {string} html - HTML string (will be sanitised)
|
|
310
382
|
*/
|
|
311
383
|
setHTML(html) {
|
|
312
|
-
this.context.layoutInfo.editable.innerHTML = sanitiseHTML(html);
|
|
384
|
+
this.context.layoutInfo.editable.innerHTML = sanitiseHTML(html, { allowIframes: true });
|
|
313
385
|
if (this._history) this._history.reset();
|
|
314
386
|
this.afterCommand();
|
|
315
387
|
}
|
|
@@ -688,7 +688,18 @@ export class EmojiDialog {
|
|
|
688
688
|
|
|
689
689
|
// 2. Insert emoji as a plain text node — no ZWS or execCommand needed.
|
|
690
690
|
// Text nodes are natively navigable so the caret lands cleanly after.
|
|
691
|
+
//
|
|
692
|
+
// E-1: When the range covers the entire content of a <td>/<th>,
|
|
693
|
+
// deleteContents() can drift the range endpoint outside the cell in some
|
|
694
|
+
// browsers. Save the cell reference first and re-anchor after deletion.
|
|
695
|
+
const _sc = range.startContainer;
|
|
696
|
+
const _tdAnchor = (_sc.nodeType === 1 ? _sc : _sc.parentElement)
|
|
697
|
+
?.closest?.('td, th');
|
|
691
698
|
range.deleteContents();
|
|
699
|
+
if (_tdAnchor && _tdAnchor.isConnected && !_tdAnchor.contains(range.startContainer)) {
|
|
700
|
+
range.setStart(_tdAnchor, 0);
|
|
701
|
+
range.collapse(true);
|
|
702
|
+
}
|
|
692
703
|
const textNode = document.createTextNode(char);
|
|
693
704
|
range.insertNode(textNode);
|
|
694
705
|
|
|
@@ -575,7 +575,18 @@ export class IconDialog {
|
|
|
575
575
|
|
|
576
576
|
// 3. Insert the <i> node directly — never via execCommand/insertHTML
|
|
577
577
|
// (execCommand leaves the caret inside the inserted element)
|
|
578
|
+
//
|
|
579
|
+
// E-1: When the range covers the entire content of a <td>/<th>,
|
|
580
|
+
// deleteContents() can drift the range endpoint outside the cell in some
|
|
581
|
+
// browsers. Save the cell reference first and re-anchor after deletion.
|
|
582
|
+
const _sc = range.startContainer;
|
|
583
|
+
const _tdAnchor = (_sc.nodeType === 1 ? _sc : _sc.parentElement)
|
|
584
|
+
?.closest?.('td, th');
|
|
578
585
|
range.deleteContents();
|
|
586
|
+
if (_tdAnchor && _tdAnchor.isConnected && !_tdAnchor.contains(range.startContainer)) {
|
|
587
|
+
range.setStart(_tdAnchor, 0);
|
|
588
|
+
range.collapse(true);
|
|
589
|
+
}
|
|
579
590
|
range.insertNode(iconEl);
|
|
580
591
|
|
|
581
592
|
// 4. Place cursor after the icon.
|
|
@@ -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
|
);
|
|
@@ -118,12 +118,15 @@ export class ImageDialog {
|
|
|
118
118
|
const fileInput = createElement('input', {
|
|
119
119
|
type: 'file',
|
|
120
120
|
class: 'an-input',
|
|
121
|
-
accept: 'image
|
|
121
|
+
accept: 'image/jpeg,image/png,image/gif,image/webp,image/svg+xml,image/avif',
|
|
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,26 @@ 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: Only allow web-displayable formats. TIFF, BMP, RAW and similar
|
|
163
|
+
// formats are not rendered by browsers — reject them with a clear message.
|
|
164
|
+
const SUPPORTED = new Set([
|
|
165
|
+
'image/jpeg', 'image/png', 'image/gif',
|
|
166
|
+
'image/webp', 'image/svg+xml', 'image/avif',
|
|
167
|
+
]);
|
|
168
|
+
if (!SUPPORTED.has(file.type)) {
|
|
169
|
+
const message = `Format "${file.type}" is not supported for display in web browsers. Please convert to JPEG, PNG, or WebP first.`;
|
|
170
|
+
if (this._fileHint) this._fileHint.textContent = message;
|
|
171
|
+
this.context.triggerEvent('imageError', { file, message });
|
|
172
|
+
this._fileInput.value = '';
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
if (this._fileHint) this._fileHint.textContent = '';
|
|
176
|
+
|
|
159
177
|
// Validate file size (max 5 MB by default)
|
|
160
178
|
const maxSize = (this.options.maxImageSize || 5) * 1024 * 1024;
|
|
161
179
|
if (file.size > maxSize) {
|
|
162
180
|
const message = `Image file is too large. Maximum allowed size is ${this.options.maxImageSize || 5} MB.`;
|
|
181
|
+
if (this._fileHint) this._fileHint.textContent = message;
|
|
163
182
|
console.warn('[AutumnNote] ImageDialog:', message);
|
|
164
183
|
this.context.triggerEvent('imageError', { file, message });
|
|
165
184
|
this._fileInput.value = '';
|
|
@@ -168,7 +187,7 @@ export class ImageDialog {
|
|
|
168
187
|
|
|
169
188
|
const reader = new FileReader();
|
|
170
189
|
reader.onload = (e) => {
|
|
171
|
-
this._urlInput.value = e.target.result;
|
|
190
|
+
this._urlInput.value = /** @type {string} */ (e.target.result);
|
|
172
191
|
};
|
|
173
192
|
reader.readAsDataURL(file);
|
|
174
193
|
}
|
|
@@ -50,6 +50,7 @@ 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
|
}),
|
|
@@ -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]')) {
|
|
@@ -227,6 +228,8 @@ export class ImageTooltip {
|
|
|
227
228
|
const target = img.closest('figure.an-figure') || img;
|
|
228
229
|
target.style.float = value;
|
|
229
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 = '';
|
|
230
233
|
target.style.marginLeft = value === 'right' ? '12px' : '';
|
|
231
234
|
target.style.marginRight = value === 'left' ? '12px' : '';
|
|
232
235
|
this.context.invoke('editor.afterCommand');
|
|
@@ -240,6 +243,9 @@ export class ImageTooltip {
|
|
|
240
243
|
const target = img.closest('figure.an-figure') || img;
|
|
241
244
|
target.style.float = '';
|
|
242
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';
|
|
243
249
|
target.style.marginLeft = 'auto';
|
|
244
250
|
target.style.marginRight = 'auto';
|
|
245
251
|
this.context.invoke('editor.afterCommand');
|
|
@@ -335,7 +341,10 @@ export class ImageTooltip {
|
|
|
335
341
|
img.style.marginLeft = '';
|
|
336
342
|
img.style.marginRight = '';
|
|
337
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.
|
|
338
346
|
figure.style.display = 'block';
|
|
347
|
+
figure.style.width = 'fit-content';
|
|
339
348
|
figure.style.marginLeft = 'auto';
|
|
340
349
|
figure.style.marginRight = 'auto';
|
|
341
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,13 @@ export class Placeholder {
|
|
|
39
39
|
_update() {
|
|
40
40
|
const editable = this.context.layoutInfo.editable;
|
|
41
41
|
const isFocused = document.activeElement === editable;
|
|
42
|
-
|
|
42
|
+
// Strip ZWS (\u200B) cursor anchors used by checklist/icon insertion in
|
|
43
|
+
// addition to regular whitespace before deciding if the editor is empty.
|
|
44
|
+
// Without this, a freshly-created checklist item or icon leaves a ZWS in
|
|
45
|
+
// the DOM that causes the placeholder to overlap real content (A-1).
|
|
46
|
+
const hasText = editable.textContent.replace(/\u200B/g, '').trim().length > 0;
|
|
47
|
+
const isEmpty = !hasText &&
|
|
48
|
+
!editable.querySelector('img, table, hr, .an-video-wrapper');
|
|
43
49
|
editable.classList.toggle('an-placeholder', isEmpty && !isFocused);
|
|
44
50
|
}
|
|
45
51
|
}
|