autumnnote 1.0.6 → 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 +1 -1
- package/dist/autumnnote.css +129 -1
- package/dist/autumnnote.es.js +401 -36
- package/dist/autumnnote.es.js.map +1 -1
- package/dist/autumnnote.umd.js +401 -36
- 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 +120 -7
- 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 +61 -0
- package/src/js/module/ImageCropOverlay.js +2 -2
- package/src/js/module/ImageDialog.js +17 -2
- 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 +2 -1
- package/src/js/module/TableTooltip.js +6 -0
- 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 +146 -9
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "autumnnote",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.7",
|
|
4
4
|
"description": "A modern, lightweight WYSIWYG editor — built with vanilla JavaScript, no jQuery required.",
|
|
5
5
|
"main": "dist/autumnnote.umd.js",
|
|
6
6
|
"module": "dist/autumnnote.es.js",
|
|
@@ -45,9 +45,10 @@
|
|
|
45
45
|
"license": "MIT",
|
|
46
46
|
"devDependencies": {
|
|
47
47
|
"@vitest/browser": "^4.1.2",
|
|
48
|
+
"@vitest/coverage-v8": "^4.1.2",
|
|
48
49
|
"cross-env": "^10.1.0",
|
|
49
50
|
"eslint": "^10.2.0",
|
|
50
|
-
"jsdom": "^
|
|
51
|
+
"jsdom": "^25.0.1",
|
|
51
52
|
"rollup-plugin-visualizer": "^7.0.1",
|
|
52
53
|
"sass": "^1.99.0",
|
|
53
54
|
"typescript": "^6.0.2",
|
package/src/js/Context.js
CHANGED
|
@@ -444,9 +444,15 @@ export class Context {
|
|
|
444
444
|
if (disabled) {
|
|
445
445
|
editable.setAttribute('contenteditable', 'false');
|
|
446
446
|
this.layoutInfo.container.classList.add('an-disabled');
|
|
447
|
+
editable.querySelectorAll('ul.an-checklist input[type="checkbox"]').forEach((cb) => {
|
|
448
|
+
cb.setAttribute('disabled', '');
|
|
449
|
+
});
|
|
447
450
|
} else {
|
|
448
451
|
editable.setAttribute('contenteditable', 'true');
|
|
449
452
|
this.layoutInfo.container.classList.remove('an-disabled');
|
|
453
|
+
editable.querySelectorAll('ul.an-checklist input[type="checkbox"]').forEach((cb) => {
|
|
454
|
+
cb.removeAttribute('disabled');
|
|
455
|
+
});
|
|
450
456
|
}
|
|
451
457
|
}
|
|
452
458
|
|
package/src/js/core/sanitise.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
/** Tags that are unconditionally removed from editor content. */
|
|
9
|
-
const PROHIBITED_TAGS = ['script', 'style', 'iframe', 'object', 'embed', 'form', '
|
|
9
|
+
const PROHIBITED_TAGS = ['script', 'style', 'iframe', 'object', 'embed', 'form', 'button'];
|
|
10
10
|
|
|
11
11
|
/** Attributes whose values must be sanitised as URLs. */
|
|
12
12
|
const URL_ATTRS = ['href', 'src', 'action', 'formaction'];
|
|
@@ -26,7 +26,8 @@ const TRUSTED_IFRAME_HOSTS = new Set([
|
|
|
26
26
|
* Uses DOMParser so the sanitisation follows normal browser parsing rules —
|
|
27
27
|
* no regex shortcuts that can be bypassed by encoding tricks.
|
|
28
28
|
*
|
|
29
|
-
* - Strips PROHIBITED_TAGS (script, style, iframe, object, embed, form,
|
|
29
|
+
* - Strips PROHIBITED_TAGS (script, style, iframe, object, embed, form, button)
|
|
30
|
+
* - Allows input[type="checkbox"] only inside ul.an-checklist li; removes all other <input>
|
|
30
31
|
* - Removes all on* event-handler attributes
|
|
31
32
|
* - Rejects javascript: and vbscript: URLs in URL attributes
|
|
32
33
|
* - Rejects data: URIs everywhere except img[src] (base64 uploads)
|
|
@@ -81,6 +82,23 @@ export function sanitiseHTML(html, { allowIframes = false } = {}) {
|
|
|
81
82
|
});
|
|
82
83
|
});
|
|
83
84
|
|
|
85
|
+
// Allow only input[type="checkbox"] inside ul.an-checklist li; strip everything else.
|
|
86
|
+
// This preserves checklist state while blocking arbitrary <input> injection.
|
|
87
|
+
doc.querySelectorAll('input').forEach((el) => {
|
|
88
|
+
const inChecklist = el.closest('ul.an-checklist') !== null &&
|
|
89
|
+
el.closest('li') !== null;
|
|
90
|
+
if (!inChecklist || el.getAttribute('type') !== 'checkbox') {
|
|
91
|
+
el.remove();
|
|
92
|
+
} else {
|
|
93
|
+
// Harden: keep only safe attributes on checklist checkboxes
|
|
94
|
+
Array.from(el.attributes).forEach((attr) => {
|
|
95
|
+
if (!['type', 'checked', 'contenteditable'].includes(attr.name)) {
|
|
96
|
+
el.removeAttribute(attr.name);
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
84
102
|
return doc.body.innerHTML;
|
|
85
103
|
}
|
|
86
104
|
|
package/src/js/editing/Style.js
CHANGED
|
@@ -98,15 +98,54 @@ export const fontName = (name) => execCommand('fontName', name);
|
|
|
98
98
|
* @param {HTMLElement} [editable] - scoping element to avoid touching nodes outside this editor
|
|
99
99
|
*/
|
|
100
100
|
export function fontSize(size, editable = document) {
|
|
101
|
+
const sel = window.getSelection();
|
|
102
|
+
const wasCollapsed = !sel || !sel.rangeCount || sel.getRangeAt(0).collapsed;
|
|
103
|
+
|
|
101
104
|
execCommand('fontSize', '7'); // placeholder
|
|
102
105
|
// Replace font elements with spans, scoped to the active editable
|
|
103
|
-
|
|
106
|
+
const scope = editable instanceof HTMLElement ? editable : document;
|
|
107
|
+
const newSpans = [];
|
|
108
|
+
scope.querySelectorAll('font[size="7"]').forEach((el) => {
|
|
104
109
|
const span = document.createElement('span');
|
|
105
110
|
span.style.fontSize = size;
|
|
106
111
|
el.parentNode.insertBefore(span, el);
|
|
107
112
|
while (el.firstChild) span.appendChild(el.firstChild);
|
|
108
113
|
el.parentNode.removeChild(el);
|
|
114
|
+
newSpans.push(span);
|
|
109
115
|
});
|
|
116
|
+
|
|
117
|
+
// Restore the selection inside the new span(s) so:
|
|
118
|
+
// 1. The toolbar getValue() correctly reflects the new font size.
|
|
119
|
+
// 2. For a collapsed (caret) selection, subsequent typing inherits the
|
|
120
|
+
// chosen size rather than the browser's stale execCommand state (which
|
|
121
|
+
// would produce size 7 = 48 px instead of the requested value).
|
|
122
|
+
if (sel && newSpans.length > 0) {
|
|
123
|
+
const first = newSpans[0];
|
|
124
|
+
const last = newSpans[newSpans.length - 1];
|
|
125
|
+
try {
|
|
126
|
+
if (wasCollapsed) {
|
|
127
|
+
// Ensure the span has a text anchor so the cursor can live inside it.
|
|
128
|
+
if (!first.firstChild) {
|
|
129
|
+
first.appendChild(document.createTextNode('\u200B'));
|
|
130
|
+
}
|
|
131
|
+
const nr = document.createRange();
|
|
132
|
+
const anchor = first.firstChild;
|
|
133
|
+
nr.setStart(anchor, anchor.textContent.length);
|
|
134
|
+
nr.collapse(true);
|
|
135
|
+
sel.removeAllRanges();
|
|
136
|
+
sel.addRange(nr);
|
|
137
|
+
} else {
|
|
138
|
+
// Re-select all replaced content so toolbar refresh reads the new size.
|
|
139
|
+
const nr = document.createRange();
|
|
140
|
+
const startNode = first.firstChild || first;
|
|
141
|
+
const endNode = last.lastChild || last;
|
|
142
|
+
nr.setStart(startNode, 0);
|
|
143
|
+
nr.setEnd(endNode, endNode.nodeType === Node.TEXT_NODE ? endNode.textContent.length : endNode.childNodes.length);
|
|
144
|
+
sel.removeAllRanges();
|
|
145
|
+
sel.addRange(nr);
|
|
146
|
+
}
|
|
147
|
+
} catch (_) { /* ignore range errors on unusual DOM structures */ }
|
|
148
|
+
}
|
|
110
149
|
}
|
|
111
150
|
|
|
112
151
|
// ---------------------------------------------------------------------------
|
|
@@ -263,11 +302,36 @@ export function toggleInlineCode(editable) {
|
|
|
263
302
|
if (container.nodeType === 3) container = container.parentElement;
|
|
264
303
|
const codeEl = container && container.closest ? container.closest('code') : null;
|
|
265
304
|
if (codeEl && !codeEl.closest('pre')) {
|
|
266
|
-
// Unwrap
|
|
305
|
+
// Unwrap — save range endpoints relative to surrounding text so we can
|
|
306
|
+
// restore the selection after normalize() merges adjacent text nodes.
|
|
267
307
|
const parent = codeEl.parentNode;
|
|
308
|
+
// Note the sibling before the code element so we can re-anchor later.
|
|
309
|
+
const prevSibling = codeEl.previousSibling;
|
|
310
|
+
const movedChildren = Array.from(codeEl.childNodes);
|
|
268
311
|
while (codeEl.firstChild) parent.insertBefore(codeEl.firstChild, codeEl);
|
|
269
312
|
parent.removeChild(codeEl);
|
|
270
|
-
|
|
313
|
+
// Normalize only the immediate parent to merge adjacent text nodes without
|
|
314
|
+
// invalidating distant selection anchors (full editable.normalize() can
|
|
315
|
+
// cause selection offsets to shift, making subsequent format toggles miss).
|
|
316
|
+
if (parent && parent.normalize) parent.normalize();
|
|
317
|
+
// Restore selection to the text that was inside the unwrapped <code>.
|
|
318
|
+
if (movedChildren.length > 0) {
|
|
319
|
+
try {
|
|
320
|
+
// After normalize, find the merged text node that contains the content.
|
|
321
|
+
const firstMoved = movedChildren[0];
|
|
322
|
+
const lastMoved = movedChildren[movedChildren.length - 1];
|
|
323
|
+
const nr = document.createRange();
|
|
324
|
+
// Use the (possibly merged) live node if still in the DOM.
|
|
325
|
+
const anchorNode = (firstMoved.parentNode === parent) ? firstMoved : (prevSibling ? prevSibling.nextSibling : parent.firstChild);
|
|
326
|
+
if (anchorNode) {
|
|
327
|
+
nr.setStart(anchorNode, 0);
|
|
328
|
+
const endAnchor = (lastMoved.parentNode === parent) ? lastMoved : anchorNode;
|
|
329
|
+
nr.setEnd(endAnchor, endAnchor.nodeType === Node.TEXT_NODE ? endAnchor.textContent.length : endAnchor.childNodes.length);
|
|
330
|
+
sel.removeAllRanges();
|
|
331
|
+
sel.addRange(nr);
|
|
332
|
+
}
|
|
333
|
+
} catch (_) { /* ignore */ }
|
|
334
|
+
}
|
|
271
335
|
} else {
|
|
272
336
|
if (range.collapsed) return;
|
|
273
337
|
try {
|
|
@@ -296,14 +360,17 @@ export function toggleInlineCode(editable) {
|
|
|
296
360
|
/**
|
|
297
361
|
* Returns true when the cursor / selection is inside an inline <code>
|
|
298
362
|
* (not nested in a <pre>).
|
|
363
|
+
* Uses startContainer for reliable cross-browser detection regardless of
|
|
364
|
+
* whether the selection is collapsed or a range (commonAncestorContainer
|
|
365
|
+
* can behave inconsistently for range selections on some browsers).
|
|
299
366
|
* @returns {boolean}
|
|
300
367
|
*/
|
|
301
368
|
export function isInlineCode() {
|
|
302
369
|
const sel = window.getSelection();
|
|
303
370
|
if (!sel || !sel.rangeCount) return false;
|
|
304
|
-
let
|
|
305
|
-
if (
|
|
306
|
-
const code =
|
|
371
|
+
let sc = sel.getRangeAt(0).startContainer;
|
|
372
|
+
if (sc.nodeType === 3) sc = sc.parentElement;
|
|
373
|
+
const code = sc && sc.closest ? sc.closest('code') : null;
|
|
307
374
|
return !!(code && !code.closest('pre'));
|
|
308
375
|
}
|
|
309
376
|
|
|
@@ -357,7 +424,53 @@ export function toggleChecklist() {
|
|
|
357
424
|
}
|
|
358
425
|
}
|
|
359
426
|
|
|
360
|
-
// Otherwise: insert new checklist from selected text
|
|
427
|
+
// Otherwise: insert new checklist from selected text (or current block when collapsed).
|
|
428
|
+
const isCollapsed = range.collapsed;
|
|
429
|
+
if (isCollapsed) {
|
|
430
|
+
// Find the nearest block-level ancestor (p, div, li, h1-h6, blockquote, etc.)
|
|
431
|
+
// and convert it into a single checklist item.
|
|
432
|
+
const BLOCK_TAGS = new Set(['P', 'DIV', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'BLOCKQUOTE', 'LI']);
|
|
433
|
+
let block = container;
|
|
434
|
+
while (block && block.parentNode && !BLOCK_TAGS.has(block.tagName)) {
|
|
435
|
+
block = block.parentNode;
|
|
436
|
+
}
|
|
437
|
+
// Fallback: if no block element found (e.g. cursor directly in editable root), use the
|
|
438
|
+
// insertion approach with a zero-width-space item so the cursor ends up inside.
|
|
439
|
+
const itemText = (block && BLOCK_TAGS.has(block.tagName))
|
|
440
|
+
? Array.from(block.childNodes)
|
|
441
|
+
.map((n) => n.textContent)
|
|
442
|
+
.join('')
|
|
443
|
+
.replace(/\u00a0/g, ' ')
|
|
444
|
+
: '';
|
|
445
|
+
|
|
446
|
+
const ul = document.createElement('ul');
|
|
447
|
+
ul.className = 'an-checklist';
|
|
448
|
+
const li = document.createElement('li');
|
|
449
|
+
const checkbox = document.createElement('input');
|
|
450
|
+
checkbox.type = 'checkbox';
|
|
451
|
+
checkbox.contentEditable = 'false';
|
|
452
|
+
li.appendChild(checkbox);
|
|
453
|
+
li.appendChild(document.createTextNode(itemText || '\u200B'));
|
|
454
|
+
ul.appendChild(li);
|
|
455
|
+
|
|
456
|
+
if (block && BLOCK_TAGS.has(block.tagName)) {
|
|
457
|
+
block.parentNode.replaceChild(ul, block);
|
|
458
|
+
} else {
|
|
459
|
+
document.execCommand('insertHTML', false, ul.outerHTML);
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// Move caret to the text node inside the new <li>
|
|
464
|
+
const textNode = li.lastChild;
|
|
465
|
+
const nr = document.createRange();
|
|
466
|
+
const offset = textNode.nodeType === Node.TEXT_NODE ? textNode.textContent.length : 0;
|
|
467
|
+
nr.setStart(textNode, offset);
|
|
468
|
+
nr.collapse(true);
|
|
469
|
+
sel.removeAllRanges();
|
|
470
|
+
sel.addRange(nr);
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
|
|
361
474
|
const text = sel.toString();
|
|
362
475
|
const lines = text.split(/\r?\n/).filter((l) => l.trim().length > 0);
|
|
363
476
|
if (lines.length === 0) return;
|
package/src/js/module/Buttons.js
CHANGED
|
@@ -59,13 +59,14 @@ export const boldBtn = btn('bold', 'bold', 'Bold (Ctrl+B)', () => Style.bold(),
|
|
|
59
59
|
export const italicBtn = btn('italic', 'italic', 'Italic (Ctrl+I)', () => Style.italic(), () => document.queryCommandState('italic'));
|
|
60
60
|
export const underlineBtn = btn('underline', 'underline', 'Underline (Ctrl+U)', () => Style.underline(), () => {
|
|
61
61
|
// queryCommandState('underline') is unreliable inside <code> elements;
|
|
62
|
-
// also check for a <u> ancestor in the DOM
|
|
62
|
+
// also check for a <u> ancestor in the DOM using startContainer for
|
|
63
|
+
// consistent behaviour across both collapsed and range selections.
|
|
63
64
|
if (document.queryCommandState('underline')) return true;
|
|
64
65
|
const sel = window.getSelection();
|
|
65
66
|
if (!sel || !sel.rangeCount) return false;
|
|
66
|
-
let
|
|
67
|
-
if (
|
|
68
|
-
return !!(
|
|
67
|
+
let sc = sel.getRangeAt(0).startContainer;
|
|
68
|
+
if (sc.nodeType === 3) sc = sc.parentElement;
|
|
69
|
+
return !!(sc && sc.closest && sc.closest('u'));
|
|
69
70
|
});
|
|
70
71
|
export const strikeBtn = btn('strikethrough', 'strikethrough', 'Strikethrough', () => Style.strikethrough(), () => document.queryCommandState('strikeThrough'));
|
|
71
72
|
export const superscriptBtn = btn('superscript', 'superscript', 'Superscript', () => Style.superscript(), () => document.queryCommandState('superscript'));
|
|
@@ -299,9 +299,17 @@ export class Clipboard {
|
|
|
299
299
|
return;
|
|
300
300
|
}
|
|
301
301
|
|
|
302
|
+
// C2: Reject image formats that browsers cannot decode/display.
|
|
303
|
+
const UNSUPPORTED = ['image/tiff', 'image/x-tiff', 'image/bmp', 'image/x-bmp', 'image/x-ms-bmp'];
|
|
302
304
|
const maxBytes = (this.options.maxImageSize || 5) * 1024 * 1024;
|
|
303
305
|
files.forEach((file) => {
|
|
304
306
|
if (!file || !file.type.startsWith('image/')) return;
|
|
307
|
+
if (UNSUPPORTED.includes(file.type)) {
|
|
308
|
+
const message = `Image format "${file.type}" is not supported for display in web browsers. Please convert to PNG, JPEG, or WebP first.`;
|
|
309
|
+
this.context.triggerEvent('imageError', { file, message });
|
|
310
|
+
console.warn('[AutumnNote]', message);
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
305
313
|
if (file.size > maxBytes) {
|
|
306
314
|
const message = `Image "${file.name}" exceeds the ${this.options.maxImageSize || 5} MB size limit.`;
|
|
307
315
|
this.context.triggerEvent('imageError', { file, message });
|
|
@@ -39,6 +39,7 @@ export class CodeTooltip {
|
|
|
39
39
|
|
|
40
40
|
this._disposers.push(
|
|
41
41
|
on(editable, 'mouseover', (e) => {
|
|
42
|
+
if (this.context.layoutInfo.container.classList.contains('an-disabled')) return;
|
|
42
43
|
const pre = e.target.closest('pre');
|
|
43
44
|
if (pre && editable.contains(pre)) {
|
|
44
45
|
this._scheduleShow(pre);
|
|
@@ -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
|
// ---------------------------------------------------------------------------
|
|
@@ -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
|
);
|