autumnnote 1.2.0 → 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.
Binary file
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "autumnnote",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
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",
@@ -204,13 +204,16 @@ export function nodeValue(node) {
204
204
  }
205
205
 
206
206
  /**
207
- * Returns true if a node is empty (no visible content).
208
- * @param {Node} node
209
- * @returns {boolean}
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
 
@@ -29,19 +29,30 @@ export function debounce(fn, delay) {
29
29
  }
30
30
 
31
31
  /**
32
- * Throttle a function call.
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 = 0;
38
+ let lastCall = -Infinity;
39
+ let trailingTimer = null;
39
40
  return function (...args) {
40
- const now = Date.now();
41
- if (now - lastCall >= limit) {
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: Math.round(rect.top),
147
- left: Math.round(rect.left),
148
- width: Math.round(rect.width),
149
- height: Math.round(rect.height),
150
- bottom: Math.round(rect.bottom),
151
- right: Math.round(rect.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
  }
@@ -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
- function _domToMd(node) {
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
- return `\n\n${items.map((li) => `- ${_domToMd(li).trim()}`).join('\n')}\n\n`;
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
- return `\n\n${items.map((li, i) => `${i + 1}. ${_domToMd(li).trim()}`).join('\n')}\n\n`;
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
- * Returns true if the text contains recognisable Markdown patterns.
110
- * @param {string} text
111
- * @returns {boolean}
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|\*{2}.+?\*{2}|^```/m.test(text);
132
+ return /^#{1,6} \S|^\s*[-*+] \S|^\s*\d+\. \S|^> \S|^```|^\*{2}.+?\*{2}/m.test(text);
115
133
  }
116
134
 
117
135
  /**
@@ -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', 'button'];
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
- * Sanitises an HTML string by removing dangerous elements and attributes.
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
- * - 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>
31
- * - Removes all on* event-handler attributes
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
- * @returns {string}
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
- // Remove outright dangerous elements (optionally preserve iframes for video embeds)
42
- const tags = allowIframes ? PROHIBITED_TAGS.filter((t) => t !== 'iframe') : PROHIBITED_TAGS;
43
- tags.forEach((tag) => {
44
- doc.querySelectorAll(tag).forEach((el) => el.remove());
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
- // Strip dangerous attributes from remaining elements
48
- doc.querySelectorAll('*').forEach((el) => {
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
- return;
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
- return;
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
- return;
89
+ continue;
74
90
  }
75
- if (attr.name === 'src') {
76
- if (!isTrustedIframeSrc(attr.value)) {
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
- // 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
+ // 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 (_) { /* detached node — ignore */ }
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) => {
@@ -236,9 +236,15 @@ export function outdent() {
236
236
  }
237
237
 
238
238
  /**
239
- * G.5 helper: splits a checklist at checkLi, converts it to a <p>,
240
- * and keeps items before/after as separate checklists.
241
- * @param {HTMLElement} checkLi
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 text (skip the checkbox INPUT)
257
+ // Build <p> preserving inline formatting (bold/italic/links) from the item's content
252
258
  const p = document.createElement('p');
253
- const text = Array.from(checkLi.childNodes)
254
- .filter(n => !(n.nodeType === 1 && n.tagName === 'INPUT'))
255
- .map(n => n.textContent).join('').replace(/\u200B/g, '').trim();
256
- p.textContent = text || '\u00a0';
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
- * Applies a line-height value to every block-level element that intersects
300
- * the current selection.
301
- * @param {string} value - unitless multiplier, e.g. '1.5'
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.createNodeIterator(range.commonAncestorContainer, NodeFilter.SHOW_TEXT, null);
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
- if (range.intersectsNode(textNode)) {
331
- const block = nearestBlock(textNode);
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
- * Toggles a task-list at the cursor.
476
- * If inside a checklist <li>, converts it (and any other selected items) back to <p> elements.
477
- * Otherwise inserts a new <ul class="an-checklist"> with one item per selected line.
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
- document.execCommand('insertHTML', false, ul.outerHTML);
553
- return;
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>
@@ -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
- * Creates a table element with the specified dimensions.
15
- * @param {number} cols
16
- * @param {number} rows
17
- * @param {{ headerRow?: boolean }} [opts]
18
- * @returns {HTMLTableElement}
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', {}, ['\u00a0']);
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', {}, ['\u00a0']); // &nbsp;
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
- * Inserts a table at the current cursor position.
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
- execCommand('insertHTML', table.outerHTML);
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
  }
@@ -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
@@ -156,6 +156,7 @@ export const de = {
156
156
  replacePlaceholder: 'Ersetzen durch…',
157
157
  replaceAriaLabel: 'Ersetzen durch',
158
158
  replaceBtn: 'Ersetzen',
159
+ noResults: 'Keine Ergebnisse',
159
160
  replaceAllBtn: 'Alle ersetzen',
160
161
  close: '×',
161
162
  },
package/src/js/i18n/en.js CHANGED
@@ -159,6 +159,7 @@ export const en = {
159
159
  replaceAriaLabel: 'Replace with',
160
160
  replaceBtn: 'Replace',
161
161
  replaceAllBtn: 'Replace All',
162
+ noResults: 'No results',
162
163
  close: '\u00d7',
163
164
  },
164
165
 
package/src/js/i18n/es.js CHANGED
@@ -156,6 +156,7 @@ export const es = {
156
156
  replacePlaceholder: 'Reemplazar con…',
157
157
  replaceAriaLabel: 'Reemplazar con',
158
158
  replaceBtn: 'Reemplazar',
159
+ noResults: 'Sin resultados',
159
160
  replaceAllBtn: 'Reemplazar todo',
160
161
  close: '×',
161
162
  },
package/src/js/i18n/fr.js CHANGED
@@ -157,6 +157,7 @@ export const fr = {
157
157
  replacePlaceholder: 'Remplacer par\u2026',
158
158
  replaceAriaLabel: 'Remplacer par',
159
159
  replaceBtn: 'Remplacer',
160
+ noResults: 'Aucun résultat',
160
161
  replaceAllBtn: 'Tout remplacer',
161
162
  close: '\u00d7',
162
163
  },
package/src/js/i18n/ja.js CHANGED
@@ -157,6 +157,7 @@ export const ja = {
157
157
  replacePlaceholder: '置換後\u2026',
158
158
  replaceAriaLabel: '置換後のテキスト',
159
159
  replaceBtn: '置換',
160
+ noResults: '結果なし',
160
161
  replaceAllBtn: 'すべて置換',
161
162
  close: '\u00d7',
162
163
  },
package/src/js/i18n/ko.js CHANGED
@@ -156,6 +156,7 @@ export const ko = {
156
156
  replacePlaceholder: '바꿀 내용…',
157
157
  replaceAriaLabel: '바꿀 내용',
158
158
  replaceBtn: '바꾸기',
159
+ noResults: '결과 없음',
159
160
  replaceAllBtn: '모두 바꾸기',
160
161
  close: '×',
161
162
  },
package/src/js/i18n/vi.js CHANGED
@@ -158,6 +158,7 @@ export const vi = {
158
158
  replaceAriaLabel: 'Thay thế bằng',
159
159
  replaceBtn: 'Thay thế',
160
160
  replaceAllBtn: 'Thay thế tất cả',
161
+ noResults: 'Không có kết quả',
161
162
  close: '\u00d7',
162
163
  },
163
164
 
package/src/js/i18n/zh.js CHANGED
@@ -157,6 +157,7 @@ export const zh = {
157
157
  replacePlaceholder: '替换为\u2026',
158
158
  replaceAriaLabel: '替换为',
159
159
  replaceBtn: '替换',
160
+ noResults: '没有结果',
160
161
  replaceAllBtn: '全部替换',
161
162
  close: '\u00d7',
162
163
  },