autumnnote 1.2.1 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/autumnnote.es.js +337 -104
- package/dist/autumnnote.es.js.map +1 -1
- package/dist/autumnnote.umd.js +308 -88
- package/dist/autumnnote.umd.js.map +1 -1
- package/package.json +1 -1
- package/src/js/core/dom.js +6 -3
- package/src/js/core/func.js +24 -13
- package/src/js/core/markdown.js +26 -8
- package/src/js/core/sanitise.js +57 -46
- package/src/js/editing/History.js +12 -1
- package/src/js/editing/Style.js +49 -21
- package/src/js/editing/Table.js +53 -13
- package/src/js/editing/Typing.js +9 -2
- package/src/js/i18n/de.js +1 -0
- package/src/js/i18n/en.js +1 -0
- package/src/js/i18n/es.js +1 -0
- package/src/js/i18n/fr.js +1 -0
- package/src/js/i18n/ja.js +1 -0
- package/src/js/i18n/ko.js +1 -0
- package/src/js/i18n/vi.js +1 -0
- package/src/js/i18n/zh.js +1 -0
- package/src/js/module/BubbleToolbar.js +26 -7
- package/src/js/module/Clipboard.js +2 -2
- package/src/js/module/ContextMenu.js +1 -1
- package/src/js/module/FindReplace.js +6 -5
- package/src/js/module/ImageCropOverlay.js +46 -8
- package/src/js/module/ImageResizer.js +7 -0
- package/src/js/module/Mention.js +18 -4
- package/src/js/module/Statusbar.js +2 -1
- package/src/js/module/Toolbar.js +14 -0
package/package.json
CHANGED
package/src/js/core/dom.js
CHANGED
|
@@ -204,13 +204,16 @@ export function nodeValue(node) {
|
|
|
204
204
|
}
|
|
205
205
|
|
|
206
206
|
/**
|
|
207
|
-
*
|
|
208
|
-
*
|
|
209
|
-
*
|
|
207
|
+
* Determine whether a DOM node contains no visible content.
|
|
208
|
+
*
|
|
209
|
+
* Text nodes are considered empty when their `nodeValue` is empty. Void elements (e.g., `img`, `br`, `input`) are considered non-empty. An element with exactly one `<br>` child is treated as empty. For other elements, emptiness means trimmed `textContent` is empty and there are no descendant `img`, `video`, `hr`, or `table` elements.
|
|
210
|
+
* @param {Node} node - Node to inspect for visible content.
|
|
211
|
+
* @returns {boolean} `true` if the node has no visible content, `false` otherwise.
|
|
210
212
|
*/
|
|
211
213
|
export function isEmpty(node) {
|
|
212
214
|
if (isText(node)) return !node.nodeValue;
|
|
213
215
|
if (isVoid(node)) return false;
|
|
216
|
+
if (node.childNodes.length === 1 && node.firstChild?.nodeName === 'BR') return true;
|
|
214
217
|
return !node.textContent.trim() && !node.querySelector('img, video, hr, table');
|
|
215
218
|
}
|
|
216
219
|
|
package/src/js/core/func.js
CHANGED
|
@@ -29,19 +29,30 @@ export function debounce(fn, delay) {
|
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
/**
|
|
32
|
-
*
|
|
33
|
-
* @param {Function} fn
|
|
34
|
-
* @param {number} limit - milliseconds
|
|
35
|
-
* @returns {Function}
|
|
32
|
+
* Create a wrapper that limits how often `fn` can be invoked while ensuring the last call in a burst is executed.
|
|
33
|
+
* @param {Function} fn - Function to be throttled.
|
|
34
|
+
* @param {number} limit - Time window in milliseconds during which at most one call is allowed.
|
|
35
|
+
* @returns {Function} A wrapper function that invokes `fn` at most once per `limit` milliseconds; calls preserve `this` and original arguments and schedule a trailing invocation for the final call in a burst.
|
|
36
36
|
*/
|
|
37
37
|
export function throttle(fn, limit) {
|
|
38
|
-
let lastCall =
|
|
38
|
+
let lastCall = -Infinity;
|
|
39
|
+
let trailingTimer = null;
|
|
39
40
|
return function (...args) {
|
|
40
|
-
const now =
|
|
41
|
-
|
|
41
|
+
const now = performance.now();
|
|
42
|
+
const elapsed = now - lastCall;
|
|
43
|
+
if (elapsed >= limit) {
|
|
42
44
|
lastCall = now;
|
|
45
|
+
clearTimeout(trailingTimer);
|
|
46
|
+
trailingTimer = null;
|
|
43
47
|
return fn.apply(this, args);
|
|
44
48
|
}
|
|
49
|
+
// Ensure the final event in a burst is not dropped
|
|
50
|
+
clearTimeout(trailingTimer);
|
|
51
|
+
trailingTimer = setTimeout(() => {
|
|
52
|
+
lastCall = performance.now();
|
|
53
|
+
trailingTimer = null;
|
|
54
|
+
fn.apply(this, args);
|
|
55
|
+
}, limit - elapsed);
|
|
45
56
|
};
|
|
46
57
|
}
|
|
47
58
|
|
|
@@ -143,11 +154,11 @@ export function isPlainObject(val) {
|
|
|
143
154
|
export function rect2bnd(rect) {
|
|
144
155
|
if (!rect) return null;
|
|
145
156
|
return {
|
|
146
|
-
top:
|
|
147
|
-
left:
|
|
148
|
-
width:
|
|
149
|
-
height:
|
|
150
|
-
bottom:
|
|
151
|
-
right:
|
|
157
|
+
top: rect.top,
|
|
158
|
+
left: rect.left,
|
|
159
|
+
width: rect.width,
|
|
160
|
+
height: rect.height,
|
|
161
|
+
bottom: rect.bottom,
|
|
162
|
+
right: rect.right,
|
|
152
163
|
};
|
|
153
164
|
}
|
package/src/js/core/markdown.js
CHANGED
|
@@ -20,14 +20,25 @@ export function htmlToMarkdown(html) {
|
|
|
20
20
|
return _domToMd(doc.body).replace(/\n{3,}/g, '\n\n').trim();
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
/**
|
|
24
|
+
* Convert a DOM node subtree into Markdown.
|
|
25
|
+
*
|
|
26
|
+
* Recursively produces a Markdown string representing the given DOM node and its descendants,
|
|
27
|
+
* handling common HTML constructs such as paragraphs, headings, lists (with nested indentation),
|
|
28
|
+
* blockquotes, fenced and inline code, links, images, tables, horizontal rules, and basic inline emphasis.
|
|
29
|
+
*
|
|
30
|
+
* @param {Node} node - The DOM node to convert.
|
|
31
|
+
* @param {number} [depth=0] - Current nesting depth used to indent nested list items.
|
|
32
|
+
* @returns {string} The Markdown representation of the node subtree.
|
|
33
|
+
*/
|
|
34
|
+
function _domToMd(node, depth = 0) {
|
|
24
35
|
if (node.nodeType === 3) {
|
|
25
36
|
return node.textContent.replace(/\s+/g, ' ');
|
|
26
37
|
}
|
|
27
38
|
if (node.nodeType !== 1) return '';
|
|
28
39
|
|
|
29
40
|
const tag = node.nodeName.toLowerCase();
|
|
30
|
-
const inner = () => Array.from(node.childNodes).map(_domToMd).join('');
|
|
41
|
+
const inner = () => Array.from(node.childNodes).map(n => _domToMd(n, depth)).join('');
|
|
31
42
|
|
|
32
43
|
switch (tag) {
|
|
33
44
|
case 'p':
|
|
@@ -76,12 +87,16 @@ function _domToMd(node) {
|
|
|
76
87
|
case 'ul': {
|
|
77
88
|
const items = Array.from(node.querySelectorAll(':scope > li'));
|
|
78
89
|
if (!items.length) return inner();
|
|
79
|
-
|
|
90
|
+
const indent = ' '.repeat(depth);
|
|
91
|
+
const lines = items.map((li) => `${indent}- ${_domToMd(li, depth + 1).trim()}`).join('\n');
|
|
92
|
+
return depth === 0 ? `\n\n${lines}\n\n` : `\n${lines}`;
|
|
80
93
|
}
|
|
81
94
|
case 'ol': {
|
|
82
95
|
const items = Array.from(node.querySelectorAll(':scope > li'));
|
|
83
96
|
if (!items.length) return inner();
|
|
84
|
-
|
|
97
|
+
const indent = ' '.repeat(depth);
|
|
98
|
+
const lines = items.map((li, i) => `${indent}${i + 1}. ${_domToMd(li, depth + 1).trim()}`).join('\n');
|
|
99
|
+
return depth === 0 ? `\n\n${lines}\n\n` : `\n${lines}`;
|
|
85
100
|
}
|
|
86
101
|
case 'li': return inner();
|
|
87
102
|
case 'hr': return '\n\n---\n\n';
|
|
@@ -106,12 +121,15 @@ function _domToMd(node) {
|
|
|
106
121
|
}
|
|
107
122
|
|
|
108
123
|
/**
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
124
|
+
* Detects whether a string likely contains Markdown syntax.
|
|
125
|
+
*
|
|
126
|
+
* Checks for common Markdown constructs such as ATX headings, unordered or
|
|
127
|
+
* ordered list items, blockquotes, fenced code blocks, and bold emphasis.
|
|
128
|
+
* @param {string} text - Input text to inspect for Markdown patterns.
|
|
129
|
+
* @returns {boolean} `true` if any Markdown-like pattern is present, `false` otherwise.
|
|
112
130
|
*/
|
|
113
131
|
export function isMarkdown(text) {
|
|
114
|
-
return /^#{1,6} \S|^\s*[-*+] \S|^\s*\d+\. \S|^> \S
|
|
132
|
+
return /^#{1,6} \S|^\s*[-*+] \S|^\s*\d+\. \S|^> \S|^```|^\*{2}.+?\*{2}/m.test(text);
|
|
115
133
|
}
|
|
116
134
|
|
|
117
135
|
/**
|
package/src/js/core/sanitise.js
CHANGED
|
@@ -6,7 +6,10 @@
|
|
|
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', 'base'];
|
|
10
|
+
|
|
11
|
+
/** Tags whose element wrapper is stripped but content (child nodes) is preserved. */
|
|
12
|
+
const UNWRAP_TAGS = new Set(['button']);
|
|
10
13
|
|
|
11
14
|
/** Attributes whose values must be sanitised as URLs. */
|
|
12
15
|
const URL_ATTRS = ['href', 'src', 'action', 'formaction'];
|
|
@@ -22,82 +25,90 @@ const TRUSTED_IFRAME_HOSTS = new Set([
|
|
|
22
25
|
]);
|
|
23
26
|
|
|
24
27
|
/**
|
|
25
|
-
*
|
|
26
|
-
* Uses DOMParser so the sanitisation follows normal browser parsing rules —
|
|
27
|
-
* no regex shortcuts that can be bypassed by encoding tricks.
|
|
28
|
+
* Produce a sanitized HTML string with dangerous elements and attributes removed.
|
|
28
29
|
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
* - Rejects javascript: and vbscript: URLs in URL attributes
|
|
33
|
-
* - Rejects data: URIs everywhere except img[src] (base64 uploads)
|
|
30
|
+
* Removes disallowed tags and wrappers, strips event-handler attributes, rejects
|
|
31
|
+
* `javascript:`/`vbscript:` URLs and most `data:` URIs, restricts iframe `src`
|
|
32
|
+
* to trusted hosts when enabled, and permits only checklist checkboxes as inputs.
|
|
34
33
|
*
|
|
35
|
-
* @param {string} html
|
|
36
|
-
* @
|
|
34
|
+
* @param {string} html - HTML fragment to sanitize.
|
|
35
|
+
* @param {Object} [options]
|
|
36
|
+
* @param {boolean} [options.allowIframes=false] - If true, `iframe` elements are not removed but their `src` is restricted to trusted hosts and `srcdoc` is removed.
|
|
37
|
+
* @returns {string} The sanitized HTML fragment.
|
|
37
38
|
*/
|
|
38
39
|
export function sanitiseHTML(html, { allowIframes = false } = {}) {
|
|
39
40
|
const doc = new DOMParser().parseFromString(`<body>${html || ''}</body>`, 'text/html');
|
|
40
41
|
|
|
41
|
-
//
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
42
|
+
// Single querySelectorAll pass — collect all elements once to avoid
|
|
43
|
+
// repeated full-tree traversals for each category of check.
|
|
44
|
+
const allElements = Array.from(doc.querySelectorAll('*'));
|
|
45
|
+
|
|
46
|
+
// Build the prohibited tag set for fast O(1) lookup
|
|
47
|
+
const prohibited = new Set(
|
|
48
|
+
allowIframes ? PROHIBITED_TAGS.filter((t) => t !== 'iframe') : PROHIBITED_TAGS,
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
for (const el of allElements) {
|
|
52
|
+
const tag = el.tagName.toLowerCase();
|
|
53
|
+
|
|
54
|
+
// Unwrap elements whose wrapper is unsafe but whose content should be kept
|
|
55
|
+
if (UNWRAP_TAGS.has(tag)) {
|
|
56
|
+
el.replaceWith(...el.childNodes);
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Remove outright dangerous elements
|
|
61
|
+
if (prohibited.has(tag)) {
|
|
62
|
+
el.remove();
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
46
65
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
Array.from(el.attributes).forEach((attr) => {
|
|
66
|
+
// Strip dangerous attributes
|
|
67
|
+
for (const attr of Array.from(el.attributes)) {
|
|
50
68
|
// Remove all event handlers (onclick, onload, onerror, …)
|
|
51
69
|
if (attr.name.startsWith('on')) {
|
|
52
70
|
el.removeAttribute(attr.name);
|
|
53
|
-
|
|
71
|
+
continue;
|
|
54
72
|
}
|
|
55
73
|
// Sanitise URL attributes
|
|
56
74
|
if (URL_ATTRS.includes(attr.name)) {
|
|
57
75
|
const val = attr.value.trim();
|
|
58
|
-
// Block javascript: and vbscript: protocols
|
|
59
76
|
if (/^(javascript|vbscript):/i.test(val)) {
|
|
60
77
|
el.removeAttribute(attr.name);
|
|
61
|
-
|
|
78
|
+
continue;
|
|
62
79
|
}
|
|
63
80
|
// Allow data: URIs only on img[src] (base64 image uploads); block elsewhere
|
|
64
81
|
if (/^data:/i.test(val) && !(attr.name === 'src' && el.tagName === 'IMG')) {
|
|
65
82
|
el.removeAttribute(attr.name);
|
|
66
83
|
}
|
|
67
84
|
}
|
|
68
|
-
|
|
69
|
-
// Strip iframe HTML-injection vectors and limit iframe src to trusted hosts.
|
|
85
|
+
// Strip iframe HTML-injection vectors; limit src to trusted hosts
|
|
70
86
|
if (el.tagName === 'IFRAME') {
|
|
71
87
|
if (attr.name === 'srcdoc') {
|
|
72
88
|
el.removeAttribute(attr.name);
|
|
73
|
-
|
|
89
|
+
continue;
|
|
74
90
|
}
|
|
75
|
-
if (attr.name === 'src') {
|
|
76
|
-
|
|
77
|
-
el.removeAttribute(attr.name);
|
|
78
|
-
}
|
|
79
|
-
return;
|
|
91
|
+
if (attr.name === 'src' && !isTrustedIframeSrc(attr.value)) {
|
|
92
|
+
el.removeAttribute(attr.name);
|
|
80
93
|
}
|
|
81
94
|
}
|
|
82
|
-
}
|
|
83
|
-
});
|
|
95
|
+
}
|
|
84
96
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
el.removeAttribute(attr.name);
|
|
97
|
+
// Allow only input[type="checkbox"] inside ul.an-checklist li
|
|
98
|
+
if (tag === 'input') {
|
|
99
|
+
const inChecklist = el.closest('ul.an-checklist') !== null &&
|
|
100
|
+
el.closest('li') !== null;
|
|
101
|
+
if (!inChecklist || el.getAttribute('type') !== 'checkbox') {
|
|
102
|
+
el.remove();
|
|
103
|
+
} else {
|
|
104
|
+
for (const attr of Array.from(el.attributes)) {
|
|
105
|
+
if (!['type', 'checked', 'contenteditable'].includes(attr.name)) {
|
|
106
|
+
el.removeAttribute(attr.name);
|
|
107
|
+
}
|
|
97
108
|
}
|
|
98
|
-
}
|
|
109
|
+
}
|
|
99
110
|
}
|
|
100
|
-
}
|
|
111
|
+
}
|
|
101
112
|
|
|
102
113
|
return doc.body.innerHTML;
|
|
103
114
|
}
|
|
@@ -100,7 +100,16 @@ export class History {
|
|
|
100
100
|
const sel = window.getSelection();
|
|
101
101
|
sel.removeAllRanges();
|
|
102
102
|
sel.addRange(range);
|
|
103
|
-
} catch (_) {
|
|
103
|
+
} catch (_) {
|
|
104
|
+
// Detached node — fall back to placing cursor at start of editable
|
|
105
|
+
try {
|
|
106
|
+
const fb = document.createRange();
|
|
107
|
+
fb.setStart(this.editable, 0);
|
|
108
|
+
fb.collapse(true);
|
|
109
|
+
const s = window.getSelection();
|
|
110
|
+
if (s) { s.removeAllRanges(); s.addRange(fb); }
|
|
111
|
+
} catch (_2) { /* fully give up */ }
|
|
112
|
+
}
|
|
104
113
|
}
|
|
105
114
|
|
|
106
115
|
_savePoint() {
|
|
@@ -138,6 +147,8 @@ export class History {
|
|
|
138
147
|
* @returns {{ html: string, images: Object<string,string> }}
|
|
139
148
|
*/
|
|
140
149
|
_tokenizeImages(html) {
|
|
150
|
+
// Fast-path: skip regex entirely when there are no data URIs (common case)
|
|
151
|
+
if (!html.includes('data:')) return { html, images: {} };
|
|
141
152
|
const images = {};
|
|
142
153
|
let index = 0;
|
|
143
154
|
const tokenized = html.replace(/data:[^;]+;base64,[^"' >]*/g, (match) => {
|
package/src/js/editing/Style.js
CHANGED
|
@@ -236,9 +236,15 @@ export function outdent() {
|
|
|
236
236
|
}
|
|
237
237
|
|
|
238
238
|
/**
|
|
239
|
-
*
|
|
240
|
-
*
|
|
241
|
-
*
|
|
239
|
+
* Convert a checklist <li> into a paragraph and move any following items into a new checklist.
|
|
240
|
+
*
|
|
241
|
+
* Preserves inline markup from the converted item, strips zero-width space anchors,
|
|
242
|
+
* and replaces empty content with a non‑breaking space. If there are list items
|
|
243
|
+
* after the converted item they are moved into a new <ul class="an-checklist">
|
|
244
|
+
* inserted immediately after the original list. The original <li> is removed and
|
|
245
|
+
* the original list is removed if it becomes empty. Attempts to place the caret
|
|
246
|
+
* at the start of the newly created <p>.
|
|
247
|
+
* @param {HTMLElement} checkLi - The checklist `<li>` element to convert to a `<p>`.
|
|
242
248
|
*/
|
|
243
249
|
function _checklistItemToP(checkLi) {
|
|
244
250
|
const checkUl = checkLi.closest('.an-checklist');
|
|
@@ -248,12 +254,18 @@ function _checklistItemToP(checkLi) {
|
|
|
248
254
|
const liIndex = allLis.indexOf(checkLi);
|
|
249
255
|
const afterLis = allLis.slice(liIndex + 1);
|
|
250
256
|
|
|
251
|
-
// Build <p> from the item's
|
|
257
|
+
// Build <p> preserving inline formatting (bold/italic/links) from the item's content
|
|
252
258
|
const p = document.createElement('p');
|
|
253
|
-
const
|
|
254
|
-
|
|
255
|
-
.
|
|
256
|
-
|
|
259
|
+
for (const child of checkLi.childNodes) {
|
|
260
|
+
if (child.nodeType === 1 && child.tagName === 'INPUT') continue;
|
|
261
|
+
p.appendChild(child.cloneNode(true));
|
|
262
|
+
}
|
|
263
|
+
// Strip ZWS anchors left over from checklist markup
|
|
264
|
+
p.innerHTML = p.innerHTML.replace(/\u200B/g, '');
|
|
265
|
+
if (!p.hasChildNodes() || !p.textContent.trim()) {
|
|
266
|
+
p.innerHTML = '';
|
|
267
|
+
p.appendChild(document.createTextNode('\u00a0'));
|
|
268
|
+
}
|
|
257
269
|
|
|
258
270
|
// Move items after the current li into a new checklist
|
|
259
271
|
if (afterLis.length > 0) {
|
|
@@ -296,9 +308,12 @@ export const insertOrderedList = () => execCommand('insertOrderedList');
|
|
|
296
308
|
// ---------------------------------------------------------------------------
|
|
297
309
|
|
|
298
310
|
/**
|
|
299
|
-
*
|
|
300
|
-
*
|
|
301
|
-
*
|
|
311
|
+
* Set the line-height on every block-level element that intersects the current selection.
|
|
312
|
+
*
|
|
313
|
+
* If the selection is collapsed, the nearest enclosing block element receives the style.
|
|
314
|
+
* For a non-collapsed selection, all unique block ancestors of text nodes that intersect the range are updated;
|
|
315
|
+
* if none are found, the nearest block ancestor of the range's common ancestor is updated.
|
|
316
|
+
* @param {string} value - Line-height value to apply; typically a unitless multiplier (for example, "1.5").
|
|
302
317
|
*/
|
|
303
318
|
export function lineHeight(value) {
|
|
304
319
|
const sel = window.getSelection();
|
|
@@ -324,13 +339,15 @@ export function lineHeight(value) {
|
|
|
324
339
|
|
|
325
340
|
// For a range selection, collect all unique block ancestors of text nodes
|
|
326
341
|
const blocks = new Set();
|
|
327
|
-
const iter = document.
|
|
342
|
+
const iter = document.createTreeWalker(
|
|
343
|
+
range.commonAncestorContainer,
|
|
344
|
+
NodeFilter.SHOW_TEXT,
|
|
345
|
+
{ acceptNode: (node) => range.intersectsNode(node) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP },
|
|
346
|
+
);
|
|
328
347
|
let textNode;
|
|
329
348
|
while ((textNode = iter.nextNode())) {
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
if (block) blocks.add(block);
|
|
333
|
-
}
|
|
349
|
+
const block = nearestBlock(textNode);
|
|
350
|
+
if (block) blocks.add(block);
|
|
334
351
|
}
|
|
335
352
|
if (blocks.size === 0) {
|
|
336
353
|
const block = nearestBlock(range.commonAncestorContainer);
|
|
@@ -472,9 +489,18 @@ export function isInlineCode() {
|
|
|
472
489
|
// ---------------------------------------------------------------------------
|
|
473
490
|
|
|
474
491
|
/**
|
|
475
|
-
*
|
|
476
|
-
*
|
|
477
|
-
*
|
|
492
|
+
* Toggle a checklist at the current selection or caret.
|
|
493
|
+
*
|
|
494
|
+
* When the selection is inside an existing checklist `<ul class="an-checklist">`,
|
|
495
|
+
* converts the selected `<li>` items back into `<p>` paragraphs and places the caret
|
|
496
|
+
* at the start of the first converted paragraph. Otherwise creates a checklist:
|
|
497
|
+
* - If the selection is collapsed, converts the nearest block-level ancestor (or inserts
|
|
498
|
+
* a single checklist item at the editable root) into a checklist with one item containing
|
|
499
|
+
* that block's text and places the caret inside the new item.
|
|
500
|
+
* - If the selection is a range, converts each intersecting block element into one checklist
|
|
501
|
+
* item (preserving textual content) and places the caret at the end of the last item.
|
|
502
|
+
*
|
|
503
|
+
* Empty or whitespace-only selections do not create a checklist.
|
|
478
504
|
*/
|
|
479
505
|
export function toggleChecklist() {
|
|
480
506
|
const sel = window.getSelection();
|
|
@@ -549,8 +575,10 @@ export function toggleChecklist() {
|
|
|
549
575
|
if (block && BLOCK_TAGS.has(block.tagName)) {
|
|
550
576
|
block.parentNode.replaceChild(ul, block);
|
|
551
577
|
} else {
|
|
552
|
-
|
|
553
|
-
|
|
578
|
+
// Cursor directly in editable root — insert via Range API
|
|
579
|
+
const nativeRange = sel.getRangeAt(0);
|
|
580
|
+
nativeRange.deleteContents();
|
|
581
|
+
nativeRange.insertNode(ul);
|
|
554
582
|
}
|
|
555
583
|
|
|
556
584
|
// Move caret to the text node inside the new <li>
|
package/src/js/editing/Table.js
CHANGED
|
@@ -4,18 +4,18 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { createElement } from '../core/dom.js';
|
|
7
|
-
import { execCommand } from './Style.js';
|
|
8
7
|
|
|
9
8
|
// ---------------------------------------------------------------------------
|
|
10
9
|
// Table creation
|
|
11
10
|
// ---------------------------------------------------------------------------
|
|
12
11
|
|
|
13
12
|
/**
|
|
14
|
-
*
|
|
15
|
-
* @param {number} cols
|
|
16
|
-
* @param {number} rows
|
|
17
|
-
* @param {{ headerRow?: boolean }} [opts]
|
|
18
|
-
* @
|
|
13
|
+
* Build an HTML table with the given number of columns and rows, optionally including a header row.
|
|
14
|
+
* @param {number} cols - Number of columns in each row.
|
|
15
|
+
* @param {number} rows - Total number of rows to create (including header when `headerRow` is true).
|
|
16
|
+
* @param {{ headerRow?: boolean }} [opts] - Options object.
|
|
17
|
+
* @param {boolean} [opts.headerRow=false] - When true and `rows > 0`, creates a header row (`<thead>`) plus body rows for the remainder.
|
|
18
|
+
* @returns {HTMLTableElement} The constructed `<table>` element with a `<tbody>` and optional `<thead>`; each cell contains a `<br>` placeholder.
|
|
19
19
|
*/
|
|
20
20
|
export function createTable(cols, rows, opts = {}) {
|
|
21
21
|
const { headerRow = false } = opts;
|
|
@@ -25,7 +25,7 @@ export function createTable(cols, rows, opts = {}) {
|
|
|
25
25
|
const thead = createElement('thead');
|
|
26
26
|
const tr = createElement('tr');
|
|
27
27
|
for (let c = 0; c < cols; c++) {
|
|
28
|
-
const th = createElement('th', {}, ['
|
|
28
|
+
const th = createElement('th', {}, [document.createElement('br')]);
|
|
29
29
|
tr.appendChild(th);
|
|
30
30
|
}
|
|
31
31
|
thead.appendChild(tr);
|
|
@@ -39,7 +39,7 @@ export function createTable(cols, rows, opts = {}) {
|
|
|
39
39
|
for (let r = 0; r < bodyRows; r++) {
|
|
40
40
|
const tr = createElement('tr');
|
|
41
41
|
for (let c = 0; c < cols; c++) {
|
|
42
|
-
const td = createElement('td', {}, ['
|
|
42
|
+
const td = createElement('td', {}, [document.createElement('br')]);
|
|
43
43
|
tr.appendChild(td);
|
|
44
44
|
}
|
|
45
45
|
tbody.appendChild(tr);
|
|
@@ -48,12 +48,52 @@ export function createTable(cols, rows, opts = {}) {
|
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
/**
|
|
51
|
-
*
|
|
52
|
-
* @param {number} cols
|
|
53
|
-
* @param {number} rows
|
|
54
|
-
* @param {{ headerRow?: boolean }} [opts]
|
|
51
|
+
* Insert a table at the current selection and place the caret into its first cell.
|
|
52
|
+
* @param {number} cols - Number of columns for the new table.
|
|
53
|
+
* @param {number} rows - Number of rows for the new table.
|
|
54
|
+
* @param {{ headerRow?: boolean }} [opts] - Options for table creation.
|
|
55
|
+
* @param {boolean} [opts.headerRow=false] - If true, include a header row as the first row.
|
|
55
56
|
*/
|
|
56
57
|
export function insertTable(cols, rows, opts = {}) {
|
|
58
|
+
if (cols <= 0 || rows <= 0) return;
|
|
57
59
|
const table = createTable(cols, rows, opts);
|
|
58
|
-
|
|
60
|
+
|
|
61
|
+
const sel = window.getSelection();
|
|
62
|
+
if (!sel || sel.rangeCount === 0) return;
|
|
63
|
+
const range = sel.getRangeAt(0);
|
|
64
|
+
range.deleteContents();
|
|
65
|
+
|
|
66
|
+
// Walk up to find the nearest block-level ancestor to insert after
|
|
67
|
+
const BLOCK = new Set(['P', 'DIV', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'BLOCKQUOTE', 'LI', 'PRE']);
|
|
68
|
+
let anchor = range.startContainer;
|
|
69
|
+
if (anchor.nodeType === 3) anchor = anchor.parentElement;
|
|
70
|
+
while (anchor && !BLOCK.has(anchor.tagName?.toUpperCase()) && anchor.parentElement) {
|
|
71
|
+
anchor = anchor.parentElement;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (anchor && BLOCK.has(anchor.tagName?.toUpperCase()) && anchor.parentNode) {
|
|
75
|
+
anchor.after(table);
|
|
76
|
+
// Ensure there's a paragraph after the table for cursor landing
|
|
77
|
+
if (!table.nextElementSibling) {
|
|
78
|
+
const p = document.createElement('p');
|
|
79
|
+
p.appendChild(document.createElement('br'));
|
|
80
|
+
table.after(p);
|
|
81
|
+
}
|
|
82
|
+
// Remove the anchor block if it was empty (common case: cursor in blank paragraph)
|
|
83
|
+
if (!anchor.textContent.trim() && !anchor.querySelector('img, video, table')) {
|
|
84
|
+
anchor.remove();
|
|
85
|
+
}
|
|
86
|
+
} else {
|
|
87
|
+
range.insertNode(table);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Place cursor in the first cell
|
|
91
|
+
const firstCell = table.querySelector('td, th');
|
|
92
|
+
if (firstCell) {
|
|
93
|
+
const nr = document.createRange();
|
|
94
|
+
nr.setStart(firstCell, 0);
|
|
95
|
+
nr.collapse(true);
|
|
96
|
+
sel.removeAllRanges();
|
|
97
|
+
sel.addRange(nr);
|
|
98
|
+
}
|
|
59
99
|
}
|
package/src/js/editing/Typing.js
CHANGED
|
@@ -206,11 +206,11 @@ export function handleKeydown(event, editable, options = {}) {
|
|
|
206
206
|
return true;
|
|
207
207
|
}
|
|
208
208
|
|
|
209
|
-
// In a pre/code block, insert spaces
|
|
209
|
+
// In a pre/code block, insert spaces using configured tabSize
|
|
210
210
|
if (para && para.nodeName.toUpperCase() === 'PRE') {
|
|
211
211
|
if (event.shiftKey) return false;
|
|
212
212
|
event.preventDefault();
|
|
213
|
-
execCommand('insertText', '
|
|
213
|
+
execCommand('insertText', ' '.repeat(options.tabSize || 4));
|
|
214
214
|
return true;
|
|
215
215
|
}
|
|
216
216
|
|
|
@@ -357,6 +357,13 @@ export function handleKeydown(event, editable, options = {}) {
|
|
|
357
357
|
|
|
358
358
|
const para = closestPara(range.sc, editable);
|
|
359
359
|
|
|
360
|
+
// Enter in a pre/code block: insert a literal newline instead of a new block
|
|
361
|
+
if (para && para.nodeName.toUpperCase() === 'PRE') {
|
|
362
|
+
event.preventDefault();
|
|
363
|
+
execCommand('insertText', '\n');
|
|
364
|
+
return true;
|
|
365
|
+
}
|
|
366
|
+
|
|
360
367
|
// Pressing Enter at the end of a blockquote should exit it
|
|
361
368
|
if (para && para.nodeName.toUpperCase() === 'BLOCKQUOTE') {
|
|
362
369
|
const native = range.toNativeRange();
|
package/src/js/i18n/de.js
CHANGED
package/src/js/i18n/en.js
CHANGED
package/src/js/i18n/es.js
CHANGED
package/src/js/i18n/fr.js
CHANGED
package/src/js/i18n/ja.js
CHANGED
package/src/js/i18n/ko.js
CHANGED
package/src/js/i18n/vi.js
CHANGED