autumnnote 1.0.0 → 1.0.3
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 +6 -2
- package/dist/autumnnote.css +1 -1
- package/dist/autumnnote.es.js +2366 -1994
- package/dist/autumnnote.es.js.map +1 -1
- package/dist/autumnnote.umd.js +29 -29
- package/dist/autumnnote.umd.js.map +1 -1
- package/package.json +3 -3
- package/src/js/core/sanitise.js +43 -0
- package/src/js/editing/History.js +10 -2
- package/src/js/editing/Style.js +65 -4
- package/src/js/editing/Typing.js +243 -37
- package/src/js/index.js +1 -1
- package/src/js/index.umd.js +12 -0
- package/src/js/module/Buttons.js +15 -5
- package/src/js/module/CodeTooltip.js +18 -7
- package/src/js/module/Codeview.js +6 -1
- package/src/js/module/ContextMenu.js +13 -6
- package/src/js/module/Editor.js +70 -2
- package/src/js/module/IconDialog.js +27 -13
- package/src/js/module/ImageResizer.js +22 -3
- package/src/js/module/ImageTooltip.js +37 -10
- package/src/js/module/Placeholder.js +2 -1
- package/src/js/module/Statusbar.js +16 -2
- package/src/js/module/TableTooltip.js +119 -23
- package/src/js/module/Toolbar.js +103 -17
- package/src/js/module/VideoDialog.js +3 -3
- package/src/js/module/VideoResizer.js +22 -3
- package/src/js/module/VideoTooltip.js +73 -0
- package/src/js/renderer.js +5 -0
- package/src/js/settings.js +4 -0
- package/src/styles/autumnnote.scss +61 -31
- package/types/index.d.ts +4 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "autumnnote",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.3",
|
|
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",
|
|
@@ -13,13 +13,13 @@
|
|
|
13
13
|
],
|
|
14
14
|
"scripts": {
|
|
15
15
|
"dev": "vite",
|
|
16
|
-
"build": "vite build",
|
|
16
|
+
"build": "vite build && vite build --config vite.umd.config.js",
|
|
17
17
|
"build:demo": "vite build --config vite.demo.config.js",
|
|
18
18
|
"preview": "vite preview",
|
|
19
19
|
"prepublishOnly": "npm run build",
|
|
20
20
|
"test": "vitest run",
|
|
21
21
|
"test:watch": "vitest",
|
|
22
|
-
"lint": "eslint src
|
|
22
|
+
"lint": "eslint src"
|
|
23
23
|
},
|
|
24
24
|
"keywords": [
|
|
25
25
|
"wysiwyg",
|
package/src/js/core/sanitise.js
CHANGED
|
@@ -11,6 +11,16 @@ const PROHIBITED_TAGS = ['script', 'style', 'iframe', 'object', 'embed', 'form',
|
|
|
11
11
|
/** Attributes whose values must be sanitised as URLs. */
|
|
12
12
|
const URL_ATTRS = ['href', 'src', 'action', 'formaction'];
|
|
13
13
|
|
|
14
|
+
/** Trusted hosts for iframe embeds when allowIframes is enabled. */
|
|
15
|
+
const TRUSTED_IFRAME_HOSTS = new Set([
|
|
16
|
+
'www.youtube.com',
|
|
17
|
+
'youtube.com',
|
|
18
|
+
'm.youtube.com',
|
|
19
|
+
'www.youtube-nocookie.com',
|
|
20
|
+
'youtube-nocookie.com',
|
|
21
|
+
'player.vimeo.com',
|
|
22
|
+
]);
|
|
23
|
+
|
|
14
24
|
/**
|
|
15
25
|
* Sanitises an HTML string by removing dangerous elements and attributes.
|
|
16
26
|
* Uses DOMParser so the sanitisation follows normal browser parsing rules —
|
|
@@ -54,12 +64,45 @@ export function sanitiseHTML(html, { allowIframes = false } = {}) {
|
|
|
54
64
|
el.removeAttribute(attr.name);
|
|
55
65
|
}
|
|
56
66
|
}
|
|
67
|
+
|
|
68
|
+
// Strip iframe HTML-injection vectors and limit iframe src to trusted hosts.
|
|
69
|
+
if (el.tagName === 'IFRAME') {
|
|
70
|
+
if (attr.name === 'srcdoc') {
|
|
71
|
+
el.removeAttribute(attr.name);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (attr.name === 'src') {
|
|
75
|
+
if (!isTrustedIframeSrc(attr.value)) {
|
|
76
|
+
el.removeAttribute(attr.name);
|
|
77
|
+
}
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
57
81
|
});
|
|
58
82
|
});
|
|
59
83
|
|
|
60
84
|
return doc.body.innerHTML;
|
|
61
85
|
}
|
|
62
86
|
|
|
87
|
+
/**
|
|
88
|
+
* Returns true if iframe src points to an approved video host.
|
|
89
|
+
* Relative, protocol-relative and invalid URLs are rejected.
|
|
90
|
+
* @param {string} src
|
|
91
|
+
* @returns {boolean}
|
|
92
|
+
*/
|
|
93
|
+
function isTrustedIframeSrc(src) {
|
|
94
|
+
const trimmed = (src || '').trim();
|
|
95
|
+
if (!trimmed) return false;
|
|
96
|
+
if (trimmed.startsWith('//') || trimmed.startsWith('/')) return false;
|
|
97
|
+
try {
|
|
98
|
+
const url = new URL(trimmed);
|
|
99
|
+
if (url.protocol !== 'https:') return false;
|
|
100
|
+
return TRUSTED_IFRAME_HOSTS.has(url.hostname.toLowerCase());
|
|
101
|
+
} catch {
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
63
106
|
/**
|
|
64
107
|
* Sanitises a URL string, rejecting dangerous protocols.
|
|
65
108
|
*
|
|
@@ -56,7 +56,7 @@ export class History {
|
|
|
56
56
|
if (cur === node) return count + offset;
|
|
57
57
|
count += cur.length;
|
|
58
58
|
}
|
|
59
|
-
return
|
|
59
|
+
return 0;
|
|
60
60
|
}
|
|
61
61
|
|
|
62
62
|
/**
|
|
@@ -83,7 +83,15 @@ export class History {
|
|
|
83
83
|
}
|
|
84
84
|
count += len;
|
|
85
85
|
}
|
|
86
|
-
if (!startNode)
|
|
86
|
+
if (!startNode) {
|
|
87
|
+
// Offset exceeds content (e.g. undo to a shorter state): place at end
|
|
88
|
+
const lastWalker = document.createTreeWalker(this.editable, NodeFilter.SHOW_TEXT, null);
|
|
89
|
+
let lastNode = null;
|
|
90
|
+
while ((lastNode = lastWalker.nextNode())) { startNode = lastNode; }
|
|
91
|
+
startOff = startNode ? startNode.length : 0;
|
|
92
|
+
endNode = startNode;
|
|
93
|
+
endOff = startOff;
|
|
94
|
+
}
|
|
87
95
|
if (!endNode) { endNode = startNode; endOff = startOff; }
|
|
88
96
|
try {
|
|
89
97
|
const range = document.createRange();
|
package/src/js/editing/Style.js
CHANGED
|
@@ -36,8 +36,27 @@ export const italic = () => execCommand('italic');
|
|
|
36
36
|
|
|
37
37
|
/**
|
|
38
38
|
* Underlines / un-underlines the selection.
|
|
39
|
+
* Falls back to manual DOM manipulation when inside <code> where
|
|
40
|
+
* execCommand's state detection is unreliable.
|
|
39
41
|
*/
|
|
40
|
-
export
|
|
42
|
+
export function underline() {
|
|
43
|
+
const sel = window.getSelection();
|
|
44
|
+
if (!sel || !sel.rangeCount) return;
|
|
45
|
+
let container = sel.getRangeAt(0).commonAncestorContainer;
|
|
46
|
+
if (container.nodeType === 3) container = container.parentElement;
|
|
47
|
+
// Check if we're inside a <u> (DOM truth), to guard against unreliable queryCommandState
|
|
48
|
+
const uEl = container && container.closest && container.closest('u');
|
|
49
|
+
const nativeState = document.queryCommandState('underline');
|
|
50
|
+
if (uEl && !nativeState) {
|
|
51
|
+
// Browser doesn't recognise the underline state (e.g. inside <code>).
|
|
52
|
+
// Manually unwrap the <u> element.
|
|
53
|
+
const parent = uEl.parentNode;
|
|
54
|
+
while (uEl.firstChild) parent.insertBefore(uEl.firstChild, uEl);
|
|
55
|
+
parent.removeChild(uEl);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
execCommand('underline');
|
|
59
|
+
}
|
|
41
60
|
|
|
42
61
|
/**
|
|
43
62
|
* Strikethrough / removes strikethrough.
|
|
@@ -254,12 +273,22 @@ export function toggleInlineCode(editable) {
|
|
|
254
273
|
try {
|
|
255
274
|
const code = document.createElement('code');
|
|
256
275
|
range.surroundContents(code);
|
|
276
|
+
// Re-select wrapped content so subsequent format toggles work
|
|
277
|
+
const newRange = document.createRange();
|
|
278
|
+
newRange.selectNodeContents(code);
|
|
279
|
+
sel.removeAllRanges();
|
|
280
|
+
sel.addRange(newRange);
|
|
257
281
|
} catch {
|
|
258
282
|
// surroundContents fails across element boundaries — extract and rewrap
|
|
259
283
|
const frag = range.extractContents();
|
|
260
284
|
const code = document.createElement('code');
|
|
261
285
|
code.appendChild(frag);
|
|
262
286
|
range.insertNode(code);
|
|
287
|
+
// Re-select wrapped content
|
|
288
|
+
const newRange = document.createRange();
|
|
289
|
+
newRange.selectNodeContents(code);
|
|
290
|
+
sel.removeAllRanges();
|
|
291
|
+
sel.addRange(newRange);
|
|
263
292
|
}
|
|
264
293
|
}
|
|
265
294
|
}
|
|
@@ -306,13 +335,45 @@ export function toggleChecklist() {
|
|
|
306
335
|
ul.removeChild(li);
|
|
307
336
|
if (ul.children.length === 0) ul.remove();
|
|
308
337
|
const nr = document.createRange();
|
|
309
|
-
|
|
338
|
+
// Point into the text node (or first child) rather than the element node
|
|
339
|
+
// so the cursor is at a well-defined text position, not element-offset 0.
|
|
340
|
+
const startNode = p.firstChild || p;
|
|
341
|
+
nr.setStart(startNode, 0);
|
|
310
342
|
nr.collapse(true);
|
|
311
343
|
sel.removeAllRanges();
|
|
312
344
|
sel.addRange(nr);
|
|
313
345
|
} else {
|
|
314
|
-
|
|
315
|
-
execCommand('insertHTML'
|
|
346
|
+
// Use a temporary marker attribute so the new <li> can be found reliably
|
|
347
|
+
// even when execCommand('insertHTML') places the cursor outside the list.
|
|
348
|
+
// Chrome often moves the caret to a generated <p> after the <ul> rather
|
|
349
|
+
// than staying inside the <li>, making a selection-based lookup fail.
|
|
350
|
+
const MARKER = 'data-an-new-cli';
|
|
351
|
+
execCommand('insertHTML',
|
|
352
|
+
`<ul class="an-checklist"><li ${MARKER}><input type="checkbox" contenteditable="false"></li></ul>`);
|
|
353
|
+
const newLi = document.querySelector(`[${MARKER}]`);
|
|
354
|
+
if (newLi) {
|
|
355
|
+
newLi.removeAttribute(MARKER);
|
|
356
|
+
const cbEl = newLi.querySelector('input[type="checkbox"]');
|
|
357
|
+
if (cbEl) {
|
|
358
|
+
// Use \u200B (zero-width space) instead of an empty string.
|
|
359
|
+
// Chrome does not reliably honour a Selection pointing into an empty
|
|
360
|
+
// text node and may silently normalise it to an element-level offset,
|
|
361
|
+
// rendering the caret before the absolutely-positioned checkbox.
|
|
362
|
+
// \u200B is invisible/zero-width and is stripped by getHTML().
|
|
363
|
+
let textNode = cbEl.nextSibling;
|
|
364
|
+
if (!textNode || textNode.nodeType !== Node.TEXT_NODE) {
|
|
365
|
+
textNode = document.createTextNode('\u200B');
|
|
366
|
+
newLi.appendChild(textNode);
|
|
367
|
+
} else if (!textNode.textContent) {
|
|
368
|
+
textNode.textContent = '\u200B';
|
|
369
|
+
}
|
|
370
|
+
const nr = document.createRange();
|
|
371
|
+
nr.setStart(textNode, 0);
|
|
372
|
+
nr.collapse(true);
|
|
373
|
+
sel.removeAllRanges();
|
|
374
|
+
sel.addRange(nr);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
316
377
|
}
|
|
317
378
|
}
|
|
318
379
|
|
package/src/js/editing/Typing.js
CHANGED
|
@@ -16,6 +16,153 @@ import { currentRange } from '../core/range.js';
|
|
|
16
16
|
* @returns {boolean} true if the event was consumed
|
|
17
17
|
*/
|
|
18
18
|
export function handleKeydown(event, editable, options = {}) {
|
|
19
|
+
const isFAIcon = (n) => !!(n && n.nodeName === 'I' && /\bfa-/.test(n.className || ''));
|
|
20
|
+
const isZwsAnchor = (n) => !!(n && n.nodeType === Node.TEXT_NODE && (n.textContent === '\u200B' || n.textContent === ''));
|
|
21
|
+
const moveCaret = (setFn) => {
|
|
22
|
+
const sel = window.getSelection();
|
|
23
|
+
if (!sel) return false;
|
|
24
|
+
const nr = document.createRange();
|
|
25
|
+
setFn(nr);
|
|
26
|
+
nr.collapse(true);
|
|
27
|
+
sel.removeAllRanges();
|
|
28
|
+
sel.addRange(nr);
|
|
29
|
+
return true;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// -------------------------------------------------------------------------
|
|
33
|
+
// Backspace key — one-press deletion of a preceding FA icon (<i> element)
|
|
34
|
+
// -------------------------------------------------------------------------
|
|
35
|
+
if (isKey(event, key.BACKSPACE)) {
|
|
36
|
+
const sel = window.getSelection();
|
|
37
|
+
if (sel && sel.rangeCount > 0) {
|
|
38
|
+
const r = sel.getRangeAt(0);
|
|
39
|
+
if (r.collapsed && r.startContainer.nodeType === Node.TEXT_NODE) {
|
|
40
|
+
const textNode = r.startContainer;
|
|
41
|
+
// Case A: cursor at offset 0, preceding sibling is an FA icon
|
|
42
|
+
if (r.startOffset === 0 && isFAIcon(textNode.previousSibling)) {
|
|
43
|
+
event.preventDefault();
|
|
44
|
+
textNode.previousSibling.remove();
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Case B: cursor at offset 1 of a ZWS-only text node whose preceding
|
|
49
|
+
// sibling is an FA icon. The ZWS is the invisible caret anchor inserted
|
|
50
|
+
// by IconDialog; treat the whole Backspace as "delete icon + its anchor".
|
|
51
|
+
if (r.startOffset === 1 && textNode.textContent === '\u200B' &&
|
|
52
|
+
isFAIcon(textNode.previousSibling)) {
|
|
53
|
+
event.preventDefault();
|
|
54
|
+
textNode.previousSibling.remove();
|
|
55
|
+
textNode.remove();
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// -------------------------------------------------------------------------
|
|
64
|
+
// ArrowLeft / ArrowRight — one-press navigation across FA icon nodes
|
|
65
|
+
// -------------------------------------------------------------------------
|
|
66
|
+
if (isKey(event, key.LEFT) || isKey(event, key.RIGHT)) {
|
|
67
|
+
const sel = window.getSelection();
|
|
68
|
+
if (!sel || sel.rangeCount === 0) return false;
|
|
69
|
+
|
|
70
|
+
const r = sel.getRangeAt(0);
|
|
71
|
+
if (!r.collapsed) return false;
|
|
72
|
+
|
|
73
|
+
const sc = r.startContainer;
|
|
74
|
+
const movingLeft = isKey(event, key.LEFT);
|
|
75
|
+
|
|
76
|
+
if (sc.nodeType === Node.TEXT_NODE) {
|
|
77
|
+
const textNode = sc;
|
|
78
|
+
|
|
79
|
+
if (movingLeft &&
|
|
80
|
+
r.startOffset === 1 &&
|
|
81
|
+
textNode.textContent === '\u200B' &&
|
|
82
|
+
isFAIcon(textNode.previousSibling)) {
|
|
83
|
+
event.preventDefault();
|
|
84
|
+
return moveCaret((nr) => nr.setStartBefore(textNode.previousSibling));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (movingLeft && r.startOffset === 0 && isFAIcon(textNode.previousSibling)) {
|
|
88
|
+
event.preventDefault();
|
|
89
|
+
return moveCaret((nr) => nr.setStartBefore(textNode.previousSibling));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (movingLeft &&
|
|
93
|
+
r.startOffset === 0 &&
|
|
94
|
+
isZwsAnchor(textNode.previousSibling) &&
|
|
95
|
+
isFAIcon(textNode.previousSibling.previousSibling)) {
|
|
96
|
+
event.preventDefault();
|
|
97
|
+
return moveCaret((nr) => nr.setStartBefore(textNode.previousSibling.previousSibling));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (!movingLeft &&
|
|
101
|
+
r.startOffset === textNode.textContent.length &&
|
|
102
|
+
isFAIcon(textNode.nextSibling)) {
|
|
103
|
+
const icon = textNode.nextSibling;
|
|
104
|
+
const after = icon.nextSibling;
|
|
105
|
+
event.preventDefault();
|
|
106
|
+
if (after && after.nodeType === Node.TEXT_NODE) {
|
|
107
|
+
const offset = ((after.textContent || '').startsWith('\u200B')) ? 1 : 0;
|
|
108
|
+
return moveCaret((nr) => nr.setStart(after, Math.min(offset, after.textContent.length)));
|
|
109
|
+
}
|
|
110
|
+
return moveCaret((nr) => nr.setStartAfter(icon));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (!movingLeft &&
|
|
114
|
+
r.startOffset === textNode.textContent.length &&
|
|
115
|
+
isZwsAnchor(textNode.nextSibling) &&
|
|
116
|
+
isFAIcon(textNode.nextSibling.nextSibling)) {
|
|
117
|
+
const icon = textNode.nextSibling.nextSibling;
|
|
118
|
+
const after = icon.nextSibling;
|
|
119
|
+
event.preventDefault();
|
|
120
|
+
if (after && after.nodeType === Node.TEXT_NODE) {
|
|
121
|
+
const offset = ((after.textContent || '').startsWith('\u200B')) ? 1 : 0;
|
|
122
|
+
return moveCaret((nr) => nr.setStart(after, Math.min(offset, after.textContent.length)));
|
|
123
|
+
}
|
|
124
|
+
return moveCaret((nr) => nr.setStartAfter(icon));
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (sc.nodeType === Node.ELEMENT_NODE) {
|
|
129
|
+
const el = sc;
|
|
130
|
+
if (movingLeft && r.startOffset > 0) {
|
|
131
|
+
const prev = el.childNodes[r.startOffset - 1];
|
|
132
|
+
if (isFAIcon(prev)) {
|
|
133
|
+
event.preventDefault();
|
|
134
|
+
return moveCaret((nr) => nr.setStartBefore(prev));
|
|
135
|
+
}
|
|
136
|
+
if (isZwsAnchor(prev) && isFAIcon(prev.previousSibling)) {
|
|
137
|
+
event.preventDefault();
|
|
138
|
+
return moveCaret((nr) => nr.setStartBefore(prev.previousSibling));
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
if (!movingLeft && r.startOffset < el.childNodes.length) {
|
|
142
|
+
const next = el.childNodes[r.startOffset];
|
|
143
|
+
if (isFAIcon(next)) {
|
|
144
|
+
const after = next.nextSibling;
|
|
145
|
+
event.preventDefault();
|
|
146
|
+
if (after && after.nodeType === Node.TEXT_NODE) {
|
|
147
|
+
const offset = ((after.textContent || '').startsWith('\u200B')) ? 1 : 0;
|
|
148
|
+
return moveCaret((nr) => nr.setStart(after, Math.min(offset, after.textContent.length)));
|
|
149
|
+
}
|
|
150
|
+
return moveCaret((nr) => nr.setStartAfter(next));
|
|
151
|
+
}
|
|
152
|
+
if (isZwsAnchor(next) && isFAIcon(next.nextSibling)) {
|
|
153
|
+
const icon = next.nextSibling;
|
|
154
|
+
const after = icon.nextSibling;
|
|
155
|
+
event.preventDefault();
|
|
156
|
+
if (after && after.nodeType === Node.TEXT_NODE) {
|
|
157
|
+
const offset = ((after.textContent || '').startsWith('\u200B')) ? 1 : 0;
|
|
158
|
+
return moveCaret((nr) => nr.setStart(after, Math.min(offset, after.textContent.length)));
|
|
159
|
+
}
|
|
160
|
+
return moveCaret((nr) => nr.setStartAfter(icon));
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
19
166
|
// -------------------------------------------------------------------------
|
|
20
167
|
// Tab key — indent / outdent list items, or insert soft tab in code blocks
|
|
21
168
|
// -------------------------------------------------------------------------
|
|
@@ -36,6 +183,7 @@ export function handleKeydown(event, editable, options = {}) {
|
|
|
36
183
|
|
|
37
184
|
// In a pre/code block, insert spaces
|
|
38
185
|
if (para && para.nodeName.toUpperCase() === 'PRE') {
|
|
186
|
+
if (event.shiftKey) return false;
|
|
39
187
|
event.preventDefault();
|
|
40
188
|
execCommand('insertText', ' ');
|
|
41
189
|
return true;
|
|
@@ -43,6 +191,7 @@ export function handleKeydown(event, editable, options = {}) {
|
|
|
43
191
|
|
|
44
192
|
// Default: insert * tabSize
|
|
45
193
|
if (options.tabSize) {
|
|
194
|
+
if (event.shiftKey) return false;
|
|
46
195
|
event.preventDefault();
|
|
47
196
|
execCommand('insertText', ' '.repeat(options.tabSize));
|
|
48
197
|
return true;
|
|
@@ -65,62 +214,119 @@ export function handleKeydown(event, editable, options = {}) {
|
|
|
65
214
|
const range = currentRange(editable);
|
|
66
215
|
if (!range) return false;
|
|
67
216
|
|
|
68
|
-
//
|
|
217
|
+
// Hoist sc/el once so all guards below can reuse them.
|
|
69
218
|
const sc = range.sc;
|
|
70
219
|
const el = sc.nodeType === 3 ? sc.parentElement : sc;
|
|
220
|
+
|
|
221
|
+
// Guard: if the cursor is inside a <i> FA icon element (zero text children,
|
|
222
|
+
// rendered entirely by CSS ::before), pressing Enter would split the block
|
|
223
|
+
// and leave an orphan <i> in the new paragraph — visually an "auto-created
|
|
224
|
+
// icon". Push the cursor to just after the <i> first, then fall through so
|
|
225
|
+
// the browser fires its default Enter at a safe text boundary.
|
|
226
|
+
if (el && el.nodeName === 'I' && /\bfa-/.test(el.className || '')) {
|
|
227
|
+
const nr = document.createRange();
|
|
228
|
+
nr.setStartAfter(el);
|
|
229
|
+
nr.collapse(true);
|
|
230
|
+
const selI = window.getSelection();
|
|
231
|
+
if (selI) { selI.removeAllRanges(); selI.addRange(nr); }
|
|
232
|
+
return false; // cursor is now outside <i> — let browser default handle Enter
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Video wrapper — Enter should create a new paragraph after the wrapper,
|
|
236
|
+
// not split the wrapper's container and produce an empty video clone.
|
|
237
|
+
const videoWrapper = el && el.closest && el.closest('.an-video-wrapper');
|
|
238
|
+
if (videoWrapper) {
|
|
239
|
+
event.preventDefault();
|
|
240
|
+
const p = document.createElement('p');
|
|
241
|
+
p.innerHTML = '\u00a0';
|
|
242
|
+
videoWrapper.parentNode.insertBefore(p, videoWrapper.nextSibling);
|
|
243
|
+
const nr = document.createRange();
|
|
244
|
+
nr.setStart(p, 0);
|
|
245
|
+
nr.collapse(true);
|
|
246
|
+
const sel = window.getSelection();
|
|
247
|
+
sel.removeAllRanges();
|
|
248
|
+
sel.addRange(nr);
|
|
249
|
+
return true;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Checklist — Enter creates new item; empty item exits the list
|
|
71
253
|
const checkLi = el && el.closest && el.closest('.an-checklist li');
|
|
72
254
|
if (checkLi) {
|
|
73
255
|
event.preventDefault();
|
|
74
256
|
const ul = checkLi.closest('.an-checklist');
|
|
75
257
|
const sel = window.getSelection();
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
// Extract content from cursor to end of li into a fragment
|
|
79
|
-
const afterRange = document.createRange();
|
|
80
|
-
afterRange.setStart(nativeRange.endContainer, nativeRange.endOffset);
|
|
81
|
-
afterRange.setEnd(checkLi, checkLi.childNodes.length);
|
|
82
|
-
const afterFrag = afterRange.extractContents();
|
|
258
|
+
let nativeRange = sel.getRangeAt(0);
|
|
83
259
|
|
|
84
|
-
//
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
.
|
|
260
|
+
// Helper: get trimmed text content of a li, excluding the checkbox INPUT.
|
|
261
|
+
// Strip both \u00a0 (placeholder nbsp) and \u200B (ZWS cursor anchors).
|
|
262
|
+
const liText = (li) =>
|
|
263
|
+
Array.from(li.childNodes)
|
|
264
|
+
.filter((n) => !(n.nodeType === 1 && n.tagName === 'INPUT'))
|
|
265
|
+
.map((n) => n.textContent).join('').replace(/[\u00a0\u200B]/g, ' ').trim();
|
|
88
266
|
|
|
89
|
-
if
|
|
267
|
+
// 1. Check if the ENTIRE item is empty BEFORE any DOM mutation.
|
|
268
|
+
// (Do NOT check only the "before-cursor" part — that check incorrectly
|
|
269
|
+
// exits the list when cursor is at the start of a non-empty item.)
|
|
270
|
+
if (!liText(checkLi)) {
|
|
90
271
|
// Empty item — exit checklist, insert <p> after list
|
|
91
272
|
const p = document.createElement('p');
|
|
92
|
-
|
|
93
|
-
p.textContent = afterText || '\u00a0';
|
|
273
|
+
p.innerHTML = '\u00a0';
|
|
94
274
|
ul.parentNode.insertBefore(p, ul.nextSibling);
|
|
95
275
|
checkLi.remove();
|
|
96
276
|
if (ul.children.length === 0) ul.remove();
|
|
97
277
|
const nr = document.createRange();
|
|
98
|
-
nr.setStart(p, 0);
|
|
99
|
-
nr.collapse(true);
|
|
100
|
-
sel.removeAllRanges();
|
|
101
|
-
sel.addRange(nr);
|
|
102
|
-
} else {
|
|
103
|
-
// Create new checklist item; move after-cursor content into it
|
|
104
|
-
const newLi = document.createElement('li');
|
|
105
|
-
const cb = document.createElement('input');
|
|
106
|
-
cb.type = 'checkbox';
|
|
107
|
-
cb.setAttribute('contenteditable', 'false');
|
|
108
|
-
newLi.appendChild(cb);
|
|
109
|
-
let cursorNode;
|
|
110
|
-
if (afterFrag.textContent.length > 0) {
|
|
111
|
-
newLi.appendChild(afterFrag);
|
|
112
|
-
cursorNode = newLi.childNodes[1]; // first node after checkbox
|
|
113
|
-
} else {
|
|
114
|
-
cursorNode = document.createTextNode('\u00a0');
|
|
115
|
-
newLi.appendChild(cursorNode);
|
|
116
|
-
}
|
|
117
|
-
checkLi.insertAdjacentElement('afterend', newLi);
|
|
118
|
-
const nr = document.createRange();
|
|
119
|
-
nr.setStart(cursorNode, 0);
|
|
278
|
+
nr.setStart(p.firstChild, 0);
|
|
120
279
|
nr.collapse(true);
|
|
121
280
|
sel.removeAllRanges();
|
|
122
281
|
sel.addRange(nr);
|
|
282
|
+
return true;
|
|
123
283
|
}
|
|
284
|
+
|
|
285
|
+
// 2. If selection is not collapsed, delete the selected content first —
|
|
286
|
+
// mirrors browser-default Enter behaviour (delete selection, then split).
|
|
287
|
+
if (!nativeRange.collapsed) {
|
|
288
|
+
nativeRange.deleteContents();
|
|
289
|
+
// nativeRange is now collapsed at the deletion point; re-read it
|
|
290
|
+
nativeRange = sel.getRangeAt(0);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// 3. Extract everything from cursor to end of li into afterFrag.
|
|
294
|
+
// Use startContainer/startOffset (cursor position after potential delete),
|
|
295
|
+
// NOT endContainer/endOffset which is wrong for non-collapsed ranges.
|
|
296
|
+
const afterRange = document.createRange();
|
|
297
|
+
afterRange.setStart(nativeRange.startContainer, nativeRange.startOffset);
|
|
298
|
+
afterRange.setEnd(checkLi, checkLi.childNodes.length);
|
|
299
|
+
const afterFrag = afterRange.extractContents();
|
|
300
|
+
|
|
301
|
+
// 4. Build the new checklist item with the extracted "after" content.
|
|
302
|
+
const newLi = document.createElement('li');
|
|
303
|
+
const cb = document.createElement('input');
|
|
304
|
+
cb.type = 'checkbox';
|
|
305
|
+
cb.setAttribute('contenteditable', 'false');
|
|
306
|
+
newLi.appendChild(cb);
|
|
307
|
+
|
|
308
|
+
// Append extracted "after" content (if any) then insert the new item.
|
|
309
|
+
if (afterFrag.textContent.replace(/[\u00a0\u200B]/g, '').length > 0) {
|
|
310
|
+
newLi.appendChild(afterFrag);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Always ensure a text node exists so the cursor has a text-level
|
|
314
|
+
// anchor. Use \u200B (zero-width space) instead of an empty string:
|
|
315
|
+
// Chrome does not reliably honour a Selection in an empty text node and
|
|
316
|
+
// may normalise it to element-level, placing the caret before the
|
|
317
|
+
// absolutely-positioned checkbox. \u200B is stripped by getHTML().
|
|
318
|
+
let cursorNode = newLi.childNodes[1]; // first child after checkbox
|
|
319
|
+
if (!cursorNode || cursorNode.nodeType !== Node.TEXT_NODE) {
|
|
320
|
+
cursorNode = document.createTextNode('\u200B');
|
|
321
|
+
newLi.appendChild(cursorNode);
|
|
322
|
+
}
|
|
323
|
+
checkLi.insertAdjacentElement('afterend', newLi);
|
|
324
|
+
|
|
325
|
+
const nr = document.createRange();
|
|
326
|
+
nr.setStart(cursorNode, 0);
|
|
327
|
+
nr.collapse(true);
|
|
328
|
+
sel.removeAllRanges();
|
|
329
|
+
sel.addRange(nr);
|
|
124
330
|
return true;
|
|
125
331
|
}
|
|
126
332
|
|
package/src/js/index.js
CHANGED
|
@@ -99,7 +99,7 @@ const AutumnNote = {
|
|
|
99
99
|
registerModule(name, ModuleClass) { _customModules.set(name, ModuleClass); },
|
|
100
100
|
|
|
101
101
|
/** Library version */
|
|
102
|
-
version: '1.0.
|
|
102
|
+
version: '1.0.1',
|
|
103
103
|
};
|
|
104
104
|
|
|
105
105
|
// ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* index.umd.js — UMD entry point for AutumnNote
|
|
3
|
+
*
|
|
4
|
+
* Re-exports only the default export so the UMD global is the factory object
|
|
5
|
+
* directly, enabling the script-tag usage documented in the README:
|
|
6
|
+
*
|
|
7
|
+
* <script src="dist/autumnnote.umd.js"></script>
|
|
8
|
+
* <script>
|
|
9
|
+
* const editor = AutumnNote.create('#my-editor');
|
|
10
|
+
* </script>
|
|
11
|
+
*/
|
|
12
|
+
export { default } from './index.js';
|
package/src/js/module/Buttons.js
CHANGED
|
@@ -57,7 +57,16 @@ function btn(name, icon, tooltip, action, isActive, isDisabled) {
|
|
|
57
57
|
|
|
58
58
|
export const boldBtn = btn('bold', 'bold', 'Bold (Ctrl+B)', () => Style.bold(), () => document.queryCommandState('bold'));
|
|
59
59
|
export const italicBtn = btn('italic', 'italic', 'Italic (Ctrl+I)', () => Style.italic(), () => document.queryCommandState('italic'));
|
|
60
|
-
export const underlineBtn = btn('underline', 'underline', 'Underline (Ctrl+U)', () => Style.underline(), () =>
|
|
60
|
+
export const underlineBtn = btn('underline', 'underline', 'Underline (Ctrl+U)', () => Style.underline(), () => {
|
|
61
|
+
// queryCommandState('underline') is unreliable inside <code> elements;
|
|
62
|
+
// also check for a <u> ancestor in the DOM.
|
|
63
|
+
if (document.queryCommandState('underline')) return true;
|
|
64
|
+
const sel = window.getSelection();
|
|
65
|
+
if (!sel || !sel.rangeCount) return false;
|
|
66
|
+
let container = sel.getRangeAt(0).commonAncestorContainer;
|
|
67
|
+
if (container.nodeType === 3) container = container.parentElement;
|
|
68
|
+
return !!(container && container.closest && container.closest('u'));
|
|
69
|
+
});
|
|
61
70
|
export const strikeBtn = btn('strikethrough', 'strikethrough', 'Strikethrough', () => Style.strikethrough(), () => document.queryCommandState('strikeThrough'));
|
|
62
71
|
export const superscriptBtn = btn('superscript', 'superscript', 'Superscript', () => Style.superscript(), () => document.queryCommandState('superscript'));
|
|
63
72
|
export const subscriptBtn = btn('subscript', 'subscript', 'Subscript', () => Style.subscript(), () => document.queryCommandState('subscript'));
|
|
@@ -134,8 +143,8 @@ export const fontSizeBtn = {
|
|
|
134
143
|
if (sel && sel.rangeCount) {
|
|
135
144
|
let el = sel.getRangeAt(0).startContainer;
|
|
136
145
|
if (el.nodeType === 3) el = el.parentElement;
|
|
137
|
-
while (el && !el.style) el = el.parentElement;
|
|
138
|
-
const size = el
|
|
146
|
+
while (el && el.nodeType === 1 && !el.style.fontSize) el = el.parentElement;
|
|
147
|
+
const size = (el && el.style && el.style.fontSize) ? el.style.fontSize : '';
|
|
139
148
|
if (size) return size;
|
|
140
149
|
}
|
|
141
150
|
// Fallback: read the base font size from the editable element itself
|
|
@@ -237,7 +246,8 @@ export const lineHeightBtn = {
|
|
|
237
246
|
let el = sel.getRangeAt(0).startContainer;
|
|
238
247
|
if (el.nodeType === 3) el = el.parentElement;
|
|
239
248
|
while (el && !BLOCKS.has(el.tagName)) el = el.parentElement;
|
|
240
|
-
|
|
249
|
+
if (!el) return '';
|
|
250
|
+
return el.style.lineHeight || getComputedStyle(el).lineHeight || '';
|
|
241
251
|
} catch { return ''; }
|
|
242
252
|
},
|
|
243
253
|
};
|
|
@@ -248,7 +258,7 @@ export const lineHeightBtn = {
|
|
|
248
258
|
|
|
249
259
|
export const codeviewBtn = btn('codeview', 'code', 'HTML Code View', (ctx) => ctx.invoke('codeview.toggle'), (ctx) => ctx.invoke('codeview.isActive'));
|
|
250
260
|
export const fullscreenBtn = btn('fullscreen', 'expand', 'Fullscreen', (ctx) => ctx.invoke('fullscreen.toggle'), (ctx) => ctx.invoke('fullscreen.isActive'));
|
|
251
|
-
export const shortcutsBtn = btn('shortcuts', 'keyboard', 'Keyboard Shortcuts (Shift
|
|
261
|
+
export const shortcutsBtn = btn('shortcuts', 'keyboard', 'Keyboard Shortcuts (Ctrl+Shift+/)', (ctx) => ctx.invoke('shortcutsDialog.show'));
|
|
252
262
|
export const findBtn = btn('find', 'search', 'Find (Ctrl+F)', (ctx) => ctx.invoke('findReplace.show', 'find'));
|
|
253
263
|
export const findReplaceBtn = btn('findReplace', 'find-replace', 'Find & Replace (Ctrl+H)', (ctx) => ctx.invoke('findReplace.show', 'replace'));
|
|
254
264
|
export const inlineCodeBtn = btn('inlineCode', 'inline-code', 'Inline Code (Ctrl+`)', (ctx) => ctx.invoke('editor.inlineCode'), () => Style.isInlineCode());
|
|
@@ -221,8 +221,9 @@ export class CodeTooltip {
|
|
|
221
221
|
if (left + tipW > window.innerWidth - margin) left = window.innerWidth - tipW - margin;
|
|
222
222
|
if (left < margin) left = margin;
|
|
223
223
|
|
|
224
|
-
|
|
225
|
-
this._el.style.
|
|
224
|
+
// Tooltip uses position:fixed, so viewport coordinates are used directly.
|
|
225
|
+
this._el.style.top = `${top}px`;
|
|
226
|
+
this._el.style.left = `${left}px`;
|
|
226
227
|
}
|
|
227
228
|
|
|
228
229
|
// ---------------------------------------------------------------------------
|
|
@@ -356,15 +357,25 @@ export class CodeTooltip {
|
|
|
356
357
|
if (!this.context.options.codeHighlight || window.Prism) return;
|
|
357
358
|
const cdn = this.context.options.codeHighlightCDN
|
|
358
359
|
|| 'https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0';
|
|
360
|
+
const themeHref = `${cdn}/themes/prism-tomorrow.min.css`;
|
|
361
|
+
const scriptSrc = `${cdn}/prism.min.js`;
|
|
362
|
+
|
|
363
|
+
if (!document.querySelector(`link[href="${themeHref}"]`)) {
|
|
364
|
+
const link = document.createElement('link');
|
|
365
|
+
link.rel = 'stylesheet';
|
|
366
|
+
link.href = themeHref;
|
|
367
|
+
document.head.appendChild(link);
|
|
368
|
+
}
|
|
359
369
|
|
|
360
|
-
const
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
370
|
+
const existingScript = document.querySelector(`script[src="${scriptSrc}"]`);
|
|
371
|
+
if (existingScript) {
|
|
372
|
+
this._prismScript = window.Prism ? null : existingScript;
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
364
375
|
|
|
365
376
|
const script = document.createElement('script');
|
|
366
377
|
script.dataset.manual = ''; // prevent auto-highlight on load
|
|
367
|
-
script.src =
|
|
378
|
+
script.src = scriptSrc;
|
|
368
379
|
this._prismScript = script;
|
|
369
380
|
script.addEventListener('load', () => { this._prismScript = null; }, { once: true });
|
|
370
381
|
document.head.appendChild(script);
|
|
@@ -98,7 +98,12 @@ export class Codeview {
|
|
|
98
98
|
const INLINE_RE = /^<(a|abbr|b|bdo|br|button|cite|code|dfn|em|i|img|input|kbd|label|output|q|samp|select|small|span|strong|sub|sup|textarea|time|tt|u|var)([\s>/])/i;
|
|
99
99
|
let indent = 0;
|
|
100
100
|
return html
|
|
101
|
-
.replace(/>\s*</g,
|
|
101
|
+
.replace(/>\s*</g, (match, offset, str) => {
|
|
102
|
+
// Check what comes after the '>' — if it starts an inline tag, keep on same line
|
|
103
|
+
const remaining = str.slice(offset + 1);
|
|
104
|
+
if (INLINE_RE.test(remaining.trimStart())) return '><';
|
|
105
|
+
return '>\n<';
|
|
106
|
+
})
|
|
102
107
|
.split('\n')
|
|
103
108
|
.map((line) => {
|
|
104
109
|
const stripped = line.trim();
|