suneditor 3.3.0 → 3.3.2

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.
Files changed (44) hide show
  1. package/README.md +18 -1
  2. package/dist/suneditor.min.css +2 -2
  3. package/dist/suneditor.min.js +1 -1
  4. package/package.json +3 -2
  5. package/src/assets/suneditor.css +5117 -5105
  6. package/src/core/config/eventManager.js +24 -1
  7. package/src/core/editor.js +6 -1
  8. package/src/core/event/effects/keydown.registry.js +14 -9
  9. package/src/core/event/effects/ruleHelpers.js +119 -1
  10. package/src/core/event/eventOrchestrator.js +15 -6
  11. package/src/core/event/rules/keydown.rule.backspace.js +10 -47
  12. package/src/core/event/rules/keydown.rule.delete.js +28 -66
  13. package/src/core/logic/dom/format.js +2 -2
  14. package/src/core/logic/dom/html.js +85 -5
  15. package/src/core/logic/dom/nodeTransform.js +5 -3
  16. package/src/core/logic/panel/blockHandle.js +61 -24
  17. package/src/core/logic/panel/blockResolver.js +41 -4
  18. package/src/core/logic/shell/pluginManager.js +15 -2
  19. package/src/core/logic/shell/ui.js +38 -28
  20. package/src/core/schema/options.js +20 -3
  21. package/src/core/section/constructor.js +12 -8
  22. package/src/events.js +1 -1
  23. package/src/helper/dom/domQuery.js +10 -4
  24. package/src/helper/googleDocs.js +40 -0
  25. package/src/helper/index.js +3 -0
  26. package/src/modules/contract/Figure.js +5 -2
  27. package/src/modules/ui/CommandMenu.js +47 -5
  28. package/src/modules/ui/SelectMenu.js +97 -36
  29. package/src/plugins/dropdown/table/index.js +25 -8
  30. package/src/plugins/dropdown/table/shared/table.constants.js +2 -0
  31. package/src/plugins/field/slashCommand.js +59 -12
  32. package/types/core/event/effects/ruleHelpers.d.ts +56 -0
  33. package/types/core/logic/panel/blockHandle.d.ts +3 -16
  34. package/types/core/logic/shell/ui.d.ts +14 -5
  35. package/types/core/schema/options.d.ts +31 -5
  36. package/types/events.d.ts +2 -2
  37. package/types/helper/dom/domQuery.d.ts +4 -2
  38. package/types/helper/googleDocs.d.ts +19 -0
  39. package/types/helper/index.d.ts +5 -0
  40. package/types/modules/ui/CommandMenu.d.ts +11 -0
  41. package/types/modules/ui/SelectMenu.d.ts +13 -7
  42. package/types/plugins/dropdown/table/index.d.ts +11 -0
  43. package/types/plugins/dropdown/table/shared/table.constants.d.ts +1 -0
  44. package/types/plugins/field/slashCommand.d.ts +43 -2
@@ -20,6 +20,9 @@ class EventManager {
20
20
  /** @type {Array<*>} */
21
21
  #events = [];
22
22
 
23
+ /** @type {?Array<SunEditor.Event.GlobalInfo>} */
24
+ #globalEvents = [];
25
+
23
26
  /**
24
27
  * @constructor
25
28
  * @param {import('./contextProvider').default} contextProvider
@@ -165,11 +168,16 @@ class EventManager {
165
168
  this.#frameContext.get('_ww').addEventListener(type, listener, useCapture);
166
169
  }
167
170
  _w.addEventListener(type, listener, useCapture);
168
- return {
171
+
172
+ const info = {
169
173
  type,
170
174
  listener,
171
175
  useCapture,
172
176
  };
177
+
178
+ this.#globalEvents?.push(info);
179
+
180
+ return info;
173
181
  }
174
182
 
175
183
  /**
@@ -193,6 +201,12 @@ class EventManager {
193
201
  }
194
202
  _w.removeEventListener(type, listener, useCapture);
195
203
 
204
+ const i =
205
+ this.#globalEvents?.findIndex(
206
+ (e) => e.type === type && e.listener === listener && e.useCapture === useCapture,
207
+ ) ?? -1;
208
+ if (i > -1) this.#globalEvents.splice(i, 1);
209
+
196
210
  return null;
197
211
  }
198
212
 
@@ -218,6 +232,15 @@ class EventManager {
218
232
  this.#events = null;
219
233
 
220
234
  this.#geckoActiveEvent &&= this.removeGlobalEvent(this.#geckoActiveEvent);
235
+
236
+ const frameWindow = this.#frameOptions.get('iframe') ? this.#frameContext.get('_ww') : null;
237
+ for (let i = 0, len = this.#globalEvents.length, e; i < len; i++) {
238
+ e = this.#globalEvents[i];
239
+ frameWindow?.removeEventListener(e.type, e.listener, e.useCapture);
240
+ _w.removeEventListener(e.type, e.listener, e.useCapture);
241
+ }
242
+
243
+ this.#globalEvents = null;
221
244
  }
222
245
  }
223
246
 
@@ -232,7 +232,12 @@ class Editor {
232
232
  }
233
233
  if (e.get('documentType').usePage) {
234
234
  e.set('documentType_use_page', true);
235
- e.get('documentTypePageMirror').innerHTML = e.get('wysiwyg').innerHTML;
235
+ const mirror = e.get('documentTypePageMirror');
236
+ const frag = mirror.ownerDocument.createDocumentFragment();
237
+ for (let n = e.get('wysiwyg').firstChild; n; n = n.nextSibling) {
238
+ frag.appendChild(n.cloneNode(true));
239
+ }
240
+ mirror.replaceChildren(frag);
236
241
  }
237
242
  }
238
243
  }
@@ -1,4 +1,5 @@
1
1
  import { dom, unicode } from '../../../helper';
2
+ import { getNestedListTarget } from './ruleHelpers';
2
3
 
3
4
  /**
4
5
  * @typedef {Object} EffectContext_keydown
@@ -79,12 +80,16 @@ export default {
79
80
  /** @action backspaceComponentSelect */
80
81
  'backspace.component.select': ({ ports }, { selectionNode, range, fileComponentInfo }) => {
81
82
  let currentZWS = null;
82
- if (dom.check.isBreak(selectionNode)) dom.utils.removeItem(selectionNode);
83
- else if (dom.check.isBreak((currentZWS = range.startContainer.childNodes?.[range.startOffset])))
83
+
84
+ if (dom.check.isBreak(selectionNode)) {
85
+ dom.utils.removeItem(selectionNode);
86
+ } else if (dom.check.isBreak((currentZWS = range.startContainer.childNodes?.[range.startOffset]))) {
84
87
  dom.utils.removeItem(currentZWS);
88
+ }
85
89
 
86
- if (ports.component.select(fileComponentInfo.target, fileComponentInfo.pluginName) === false)
90
+ if (ports.component.select(fileComponentInfo.target, fileComponentInfo.pluginName) === false) {
87
91
  ports.focusManager.blur();
92
+ }
88
93
  },
89
94
 
90
95
  /** @action backspaceComponentRemove */
@@ -103,6 +108,9 @@ export default {
103
108
  rangeEl.parentNode.insertBefore(con, rangeEl.parentNode.firstChild);
104
109
  }
105
110
  const offset = con.nodeType === 3 ? con.textContent.length : 1;
111
+
112
+ stripTrailingBreaks(formatEl);
113
+
106
114
  const children = formatEl.childNodes;
107
115
  let after = con;
108
116
  let child = children[0];
@@ -213,12 +221,9 @@ export default {
213
221
  'delete.list.removeNested': ({ ports, ctx }, { range, formatEl, rangeEl }) => {
214
222
  if (range.startContainer !== range.endContainer) ports.html.remove();
215
223
 
216
- const next = /** @type {HTMLElement} */ (
217
- dom.utils.arrayFind(formatEl.children, dom.check.isList) ||
218
- formatEl.nextElementSibling ||
219
- rangeEl.parentElement.nextElementSibling
220
- );
221
- if (next && (dom.check.isList(next) || dom.utils.arrayFind(next.children, dom.check.isList))) {
224
+ // Same decision the rule gated on — asked once, here and there, so the two can't drift apart.
225
+ const next = getNestedListTarget(formatEl, rangeEl);
226
+ if (next) {
222
227
  ctx.e.preventDefault();
223
228
 
224
229
  let con, children;
@@ -188,4 +188,122 @@ function isRtlBidiMismatch(range, formatEl, detectedEdge, doc) {
188
188
  return detectedEdge === 'front' ? caretRect.left <= contentRect.left + 2 : caretRect.left >= contentRect.right - 2;
189
189
  }
190
190
 
191
- export { hardDelete, cleanRemovedTags, isUneditableNode, setDefaultLine, isRtlBidiMismatch };
191
+ /**
192
+ * @description Whether the caret sits on a bare `<br>` that stands at the front/end edge of its `line`.
193
+ * @param {Range} range - The current range
194
+ * @param {Node} selectionNode - Current selection node
195
+ * @param {'front'|'end'} edge - Edge to test: `front` for Backspace, `end` for Delete
196
+ * @returns {boolean} `true` if the caret is on an edge `<br>`
197
+ */
198
+ function isEdgeBreakCaret(range, selectionNode, edge) {
199
+ if (!range.collapsed || !dom.check.isBreak(selectionNode)) return false;
200
+
201
+ const key = edge === 'front' ? 'previousSibling' : 'nextSibling';
202
+ let sibling = selectionNode[key];
203
+ while (sibling?.nodeType === 3 && dom.check.isZeroWidth(sibling)) {
204
+ sibling = sibling[key];
205
+ }
206
+
207
+ return !sibling || dom.check.isList(sibling);
208
+ }
209
+
210
+ /**
211
+ * @description The previous/next element in document order — crossing in and out of blocks (list, quote).
212
+ * - Out of list-cell ancestors (a nested list), stopping at a closure block (table cell).
213
+ * @param {EventPorts['format']} format - Format module
214
+ * @param {?HTMLElement} line - The caret's `line` element
215
+ * @param {'front'|'end'} edge - `front` for the previous element, `end` for the next one
216
+ * @returns {?HTMLElement} The adjacent element, or `null` at the document edge
217
+ */
218
+ function getAdjacentElement(format, line, edge) {
219
+ const siblingKey = edge === 'front' ? 'previousElementSibling' : 'nextElementSibling';
220
+ const childKey = edge === 'front' ? 'lastElementChild' : 'firstElementChild';
221
+
222
+ let node = line;
223
+ let adjacent = null;
224
+ while (node && !(adjacent = node[siblingKey])) {
225
+ const parent = node.parentElement;
226
+ if (!parent || format.isClosureBlock(parent) || dom.check.isWysiwygFrame(parent)) return null;
227
+
228
+ if (format.isBlock(parent)) {
229
+ node = parent;
230
+ } else if (dom.check.isListCell(parent)) {
231
+ if (edge === 'front') return /** @type {HTMLElement} */ (parent);
232
+ node = parent;
233
+ } else {
234
+ return null; // the editable root
235
+ }
236
+ }
237
+
238
+ // step into blocks down to the nearest line
239
+ while (adjacent && format.isBlock(adjacent) && !format.isClosureBlock(adjacent)) {
240
+ adjacent = adjacent[childKey];
241
+ }
242
+
243
+ return /** @type {?HTMLElement} */ (adjacent);
244
+ }
245
+
246
+ /**
247
+ * @description The previous/next `line` in document order — {@link getAdjacentElement} filtered to lines.
248
+ * - `null` means either the document edge or a non-`line` neighbour (a component); use
249
+ * {@link getAdjacentElement} when the two must be told apart.
250
+ * @param {EventPorts['format']} format - Format module
251
+ * @param {?HTMLElement} line - The caret's `line` element
252
+ * @param {'front'|'end'} edge - `front` for the previous line, `end` for the next one
253
+ * @returns {?HTMLElement} The adjacent line, or `null`
254
+ */
255
+ function getAdjacentLine(format, line, edge) {
256
+ const adjacent = getAdjacentElement(format, line, edge);
257
+ return format.isLine(adjacent) ? /** @type {HTMLElement} */ (adjacent) : null;
258
+ }
259
+
260
+ /**
261
+ * @description The neighbouring `line` an empty line collapses into, or `null` when there is nothing to merge.
262
+ * - A cell owning a nested list belongs to {@link getNestedListTarget} instead.
263
+ * @param {EventPorts['format']} format - Format module
264
+ * @param {?HTMLElement} formatEl - The caret's `line` element
265
+ * @param {'front'|'end'} edge - `front` for Backspace (previous line), `end` for Delete (next line)
266
+ * @returns {?HTMLElement} The neighbouring line to merge into, or `null`
267
+ */
268
+ function getEmptyLineMergeTarget(format, formatEl, edge) {
269
+ if (!formatEl || !format.isNormalLine(formatEl) || !dom.check.isEmptyLine(formatEl)) return null;
270
+ if (dom.utils.arrayFind(formatEl.children, dom.check.isList)) return null;
271
+
272
+ const neighbor = /** @type {HTMLElement} */ (
273
+ edge === 'front' ? formatEl.previousElementSibling : formatEl.nextElementSibling
274
+ );
275
+
276
+ return format.isNormalLine(neighbor) || format.isBrLine(neighbor) ? neighbor : null;
277
+ }
278
+
279
+ /**
280
+ * @description The nested list a list-cell Backspace/Delete would lift, or `null` when there is none.
281
+ * - The rules gate their list branch on it so the branch can't claim the key with nothing to do.
282
+ * @param {HTMLElement} formatEl - The caret's list cell
283
+ * @param {HTMLElement} rangeEl - The list (`UL`/`OL`) the cell belongs to
284
+ * @returns {?HTMLElement} The element carrying the nested list, or `null`
285
+ */
286
+ function getNestedListTarget(formatEl, rangeEl) {
287
+ const next = /** @type {HTMLElement} */ (
288
+ dom.utils.arrayFind(formatEl.children, dom.check.isList) ||
289
+ formatEl.nextElementSibling ||
290
+ rangeEl?.parentElement?.nextElementSibling
291
+ );
292
+
293
+ if (!next) return null;
294
+
295
+ return dom.check.isList(next) || dom.utils.arrayFind(next.children, dom.check.isList) ? next : null;
296
+ }
297
+
298
+ export {
299
+ hardDelete,
300
+ cleanRemovedTags,
301
+ isUneditableNode,
302
+ setDefaultLine,
303
+ isRtlBidiMismatch,
304
+ isEdgeBreakCaret,
305
+ getAdjacentElement,
306
+ getAdjacentLine,
307
+ getEmptyLineMergeTarget,
308
+ getNestedListTarget,
309
+ };
@@ -1,5 +1,5 @@
1
1
  import KernelInjector from '../kernel/kernelInjector';
2
- import { dom, unicode, numbers, env, converter, msOffice } from '../../helper';
2
+ import { dom, unicode, numbers, env, converter, msOffice, googleDocs } from '../../helper';
3
3
  import { _DragHandle } from '../../modules/ui';
4
4
 
5
5
  // event handlers
@@ -234,9 +234,14 @@ class EventOrchestrator extends KernelInjector {
234
234
  r.startContainer.nodeType === 3 &&
235
235
  dom.check.isZeroWidth(r.startContainer)
236
236
  ) {
237
- const br = r.startContainer.nextSibling;
238
- if (br && dom.check.isBreak(br)) this.$.selection.setRange(br, 0, br, 0);
239
- else this.$.selection.setRange(r.endContainer, r.endOffset, r.endContainer, r.endOffset);
237
+ const zeroWidth = r.startContainer;
238
+ const br = zeroWidth.nextSibling;
239
+ if (br && dom.check.isBreak(br)) {
240
+ dom.utils.removeItem(zeroWidth);
241
+ this.$.selection.setRange(br, 0, br, 0);
242
+ } else {
243
+ this.$.selection.setRange(r.endContainer, r.endOffset, r.endContainer, r.endOffset);
244
+ }
240
245
  }
241
246
 
242
247
  return this.$.selection.getNode();
@@ -269,7 +274,7 @@ class EventOrchestrator extends KernelInjector {
269
274
  /**
270
275
  * @internal
271
276
  * @description Processes clipboard data for `paste` and `drop` events, handling text and HTML cleanup.
272
- * - Supports specific handling for content from Microsoft Office applications.
277
+ * - Supports specific handling for content from Microsoft Office applications and Google Docs.
273
278
  * @param {"paste"|"drop"} type The type of event
274
279
  * @param {Event} e The original event object
275
280
  * @param {DataTransfer} clipboardData The clipboard data object
@@ -292,8 +297,10 @@ class EventOrchestrator extends KernelInjector {
292
297
  /content=["']*Word.Document/i.test(cleanData) ||
293
298
  /content=["']*OneNote.File/i.test(cleanData) ||
294
299
  /content=["']*Excel.Sheet/i.test(cleanData);
300
+ // Google Docs
301
+ const GDData = !SEData && !MSData && googleDocs.isGoogleDocs(cleanData);
295
302
  // from
296
- const from = SEData ? 'SE' : MSData ? 'MS' : '';
303
+ const from = SEData ? 'SE' : MSData ? 'MS' : GDData ? 'GOOGLE' : '';
297
304
 
298
305
  if (onlyText) {
299
306
  cleanData = converter.htmlToEntity(plainText).replace(/\n/g, '<br>');
@@ -306,6 +313,8 @@ class EventOrchestrator extends KernelInjector {
306
313
  cleanData = cleanData.replace(/\n/g, ' ');
307
314
  plainText = plainText.replace(/\n/g, ' ');
308
315
  cleanData = msOffice.cleanHTML(cleanData);
316
+ } else if (GDData) {
317
+ cleanData = googleDocs.cleanHTML(cleanData);
309
318
  }
310
319
  }
311
320
 
@@ -5,6 +5,9 @@ import {
5
5
  isUneditableNode,
6
6
  setDefaultLine,
7
7
  isRtlBidiMismatch,
8
+ isEdgeBreakCaret,
9
+ getEmptyLineMergeTarget,
10
+ getAdjacentLine,
8
11
  } from '../effects/ruleHelpers';
9
12
  import { A } from '../actions';
10
13
 
@@ -79,8 +82,8 @@ export function reduceBackspaceDown(actions, ports, ctx) {
79
82
  !dom.check.isListCell(formatEl) &&
80
83
  format.isEdgeLine(range.startContainer, range.startOffset, 'front')
81
84
  ) {
82
- const prevLine = findPrevMergeLine(format, formatEl);
83
- if (prevLine) {
85
+ const prevLine = getAdjacentLine(format, formatEl, 'front');
86
+ if (prevLine && prevLine.parentElement !== formatEl.parentElement && format.isNormalLine(prevLine)) {
84
87
  actions.push(A.preventStop());
85
88
  actions.push(A.mergeLineInto(prevLine, formatEl));
86
89
  actions.push(A.historyPush(true));
@@ -152,6 +155,7 @@ export function reduceBackspaceDown(actions, ports, ctx) {
152
155
  : dom.check.isEdgePoint(range.startContainer, range.startOffset)
153
156
  ? dom.query.getPreviousDeepestNode(range.startContainer)
154
157
  : null;
158
+
155
159
  if (component.is(sel)) {
156
160
  const fileComponentInfo = component.get(sel);
157
161
  if (fileComponentInfo) {
@@ -185,6 +189,7 @@ export function reduceBackspaceDown(actions, ports, ctx) {
185
189
  dom.check.isList(rangeEl) &&
186
190
  (dom.check.isListCell(rangeEl.parentElement) || formatEl.previousElementSibling) &&
187
191
  (selectionNode === formatEl ||
192
+ isEdgeBreakCaret(range, selectionNode, 'front') ||
188
193
  (selectionNode.nodeType === 3 &&
189
194
  (!selectionNode.previousSibling || dom.check.isList(selectionNode.previousSibling)))) &&
190
195
  (format.getLine(range.startContainer, null) !== format.getLine(range.endContainer, null)
@@ -344,16 +349,9 @@ export function reduceBackspaceDown(actions, ports, ctx) {
344
349
  return false;
345
350
  }
346
351
 
347
- // empty line: merge into the previous line
348
- const emptyLinePrev = formatEl?.previousElementSibling;
349
- if (
350
- !selectRange &&
351
- formatEl &&
352
- format.isNormalLine(formatEl) &&
353
- !dom.check.isListCell(formatEl) &&
354
- dom.check.isEmptyLine(formatEl) &&
355
- (format.isNormalLine(emptyLinePrev) || format.isBrLine(emptyLinePrev))
356
- ) {
352
+ // empty line: merge into the previous line — plain lines and list cells alike
353
+ const emptyLinePrev = getEmptyLineMergeTarget(format, formatEl, 'front');
354
+ if (!selectRange && emptyLinePrev) {
357
355
  actions.push(A.preventStop());
358
356
  actions.push(A.backspaceEmptyLineMergePrev(formatEl, emptyLinePrev));
359
357
  actions.push(A.historyPush(true));
@@ -364,38 +362,3 @@ export function reduceBackspaceDown(actions, ports, ctx) {
364
362
  actions.push(A.caretScrollTo(range));
365
363
  return true;
366
364
  }
367
-
368
- /**
369
- * @description Mirror of the Delete rule's `findNextMergeLine`: finds the previous line to merge INTO when
370
- * Backspace is pressed at the start of `formatEl` and the previous line in document order lies across a block
371
- * boundary. Returns `null` for a plain line→line neighbour (left to native) or when nothing is safely mergeable
372
- * (list cell, closure, brLine, start of document).
373
- * @param {EventPorts['format']} format
374
- * @param {HTMLElement} formatEl
375
- * @returns {?HTMLElement}
376
- */
377
- function findPrevMergeLine(format, formatEl) {
378
- let prev = formatEl.previousElementSibling;
379
- if (!prev) {
380
- // `formatEl` is the first line of its block — look at what precedes the block.
381
- const block = formatEl.parentElement;
382
- if (!format.isBlock(block) || format.isClosureBlock(block)) return null;
383
- prev = block.previousElementSibling;
384
- if (!prev) return null;
385
- } else if (!format.isBlock(prev)) {
386
- return null; // plain line→line — let the browser merge it natively
387
- }
388
-
389
- // Descend into blocks to the LAST line; only merge into a simple, non-list, non-closure, non-brLine line.
390
- let line = prev;
391
- while (line && format.isBlock(line) && !format.isClosureBlock(line)) line = line.lastElementChild;
392
- if (
393
- !format.isNormalLine(line) ||
394
- dom.check.isListCell(line) ||
395
- format.isBrLine(line) ||
396
- format.isClosureBrLine(line)
397
- ) {
398
- return null;
399
- }
400
- return /** @type {HTMLElement} */ (line);
401
- }
@@ -1,5 +1,13 @@
1
1
  import { dom } from '../../../helper';
2
- import { hardDelete, isUneditableNode, isRtlBidiMismatch } from '../effects/ruleHelpers';
2
+ import {
3
+ hardDelete,
4
+ isUneditableNode,
5
+ isRtlBidiMismatch,
6
+ isEdgeBreakCaret,
7
+ getEmptyLineMergeTarget,
8
+ getNestedListTarget,
9
+ getAdjacentElement,
10
+ } from '../effects/ruleHelpers';
3
11
  import { A } from '../actions';
4
12
 
5
13
  /**
@@ -37,31 +45,24 @@ export function reduceDeleteDown(actions, ports, ctx) {
37
45
  return true;
38
46
  }
39
47
 
40
- if (
41
- !selectRange &&
42
- !bidiNotEnd &&
43
- formatEl &&
44
- format.isNormalLine(formatEl) &&
45
- !dom.check.isListCell(formatEl) &&
46
- format.isEdgeLine(range.endContainer, range.endOffset, 'end')
47
- ) {
48
- const targetLine = findNextMergeLine(format, formatEl);
49
- if (targetLine) {
48
+ if (!selectRange && !bidiNotEnd && formatEl && format.isEdgeLine(range.endContainer, range.endOffset, 'end')) {
49
+ const adjacent = getAdjacentElement(format, formatEl, 'end');
50
+ if (!adjacent) {
50
51
  actions.push(A.preventStop());
51
- actions.push(A.mergeLineInto(formatEl, targetLine));
52
- actions.push(A.historyPush(true));
53
52
  return false;
54
53
  }
55
- }
56
54
 
57
- if (
58
- !selectRange &&
59
- !bidiNotEnd &&
60
- format.isEdgeLine(range.endContainer, range.endOffset, 'end') &&
61
- !formatEl.nextSibling
62
- ) {
63
- actions.push(A.preventStop());
64
- return false;
55
+ if (
56
+ format.isLine(adjacent) &&
57
+ adjacent.parentElement !== formatEl.parentElement &&
58
+ format.isNormalLine(formatEl) &&
59
+ format.isNormalLine(adjacent)
60
+ ) {
61
+ actions.push(A.preventStop());
62
+ actions.push(A.mergeLineInto(formatEl, adjacent));
63
+ actions.push(A.historyPush(true));
64
+ return false;
65
+ }
65
66
  }
66
67
 
67
68
  // line delete
@@ -158,7 +159,9 @@ export function reduceDeleteDown(actions, ports, ctx) {
158
159
  if (
159
160
  dom.check.isListCell(formatEl) &&
160
161
  dom.check.isList(rangeEl) &&
162
+ (range.startContainer !== range.endContainer || getNestedListTarget(formatEl, rangeEl)) &&
161
163
  (selectionNode === formatEl ||
164
+ isEdgeBreakCaret(range, selectionNode, 'end') ||
162
165
  (selectionNode.nodeType === 3 &&
163
166
  (!selectionNode.nextSibling || dom.check.isList(selectionNode.nextSibling)) &&
164
167
  (format.getLine(range.startContainer, null) !== format.getLine(range.endContainer, null)
@@ -215,16 +218,9 @@ export function reduceDeleteDown(actions, ports, ctx) {
215
218
  }
216
219
  }
217
220
 
218
- // empty line
219
- const emptyLineNext = formatEl?.nextElementSibling;
220
- if (
221
- !selectRange &&
222
- formatEl &&
223
- format.isNormalLine(formatEl) &&
224
- !dom.check.isListCell(formatEl) &&
225
- dom.check.isEmptyLine(formatEl) &&
226
- (format.isNormalLine(emptyLineNext) || format.isBrLine(emptyLineNext))
227
- ) {
221
+ // empty line — plain lines and list cells alike
222
+ const emptyLineNext = getEmptyLineMergeTarget(format, formatEl, 'end');
223
+ if (!selectRange && emptyLineNext) {
228
224
  actions.push(A.preventStop());
229
225
  actions.push(A.deleteEmptyLineMergeNext(formatEl, emptyLineNext));
230
226
  actions.push(A.historyPush(true));
@@ -233,37 +229,3 @@ export function reduceDeleteDown(actions, ports, ctx) {
233
229
 
234
230
  return true;
235
231
  }
236
-
237
- /**
238
- * @description Finds the line to merge up when Delete is pressed at the end of `formatEl` and the next line in
239
- * document order lies across a block boundary. Returns `null` for a plain line→line neighbour (left to native)
240
- * or when there is nothing safely mergeable (list cell, closure, brLine, end of document).
241
- * @param {EventPorts['format']} format
242
- * @param {HTMLElement} formatEl
243
- * @returns {?HTMLElement}
244
- */
245
- function findNextMergeLine(format, formatEl) {
246
- let next = formatEl.nextElementSibling;
247
- if (!next) {
248
- // `formatEl` is the last line of its block — look at what follows the block.
249
- const block = formatEl.parentElement;
250
- if (!format.isBlock(block) || format.isClosureBlock(block)) return null;
251
- next = block.nextElementSibling;
252
- if (!next) return null;
253
- } else if (!format.isBlock(next)) {
254
- return null; // plain line→line — let the browser merge it natively
255
- }
256
-
257
- // Descend into blocks to the first line; only merge a simple, non-list, non-closure, non-brLine line.
258
- let line = next;
259
- while (line && format.isBlock(line) && !format.isClosureBlock(line)) line = line.firstElementChild;
260
- if (
261
- !format.isNormalLine(line) ||
262
- dom.check.isListCell(line) ||
263
- format.isBrLine(line) ||
264
- format.isClosureBrLine(line)
265
- ) {
266
- return null;
267
- }
268
- return /** @type {HTMLElement} */ (line);
269
- }
@@ -74,7 +74,7 @@ class Format {
74
74
  ) {
75
75
  newFormat = /** @type {HTMLElement} */ (element.cloneNode(false));
76
76
  dom.utils.copyFormatAttributes(newFormat, node);
77
- newFormat.innerHTML = node.innerHTML;
77
+ while (node.firstChild) newFormat.appendChild(node.firstChild);
78
78
 
79
79
  node.parentNode.replaceChild(newFormat, node);
80
80
  }
@@ -290,7 +290,7 @@ class Format {
290
290
  const lineAttrReset = this.#options.get('lineAttrReset');
291
291
  let newEl;
292
292
 
293
- if (/^H[1-6]$|^HR$/i.test(tag)) {
293
+ if (/^H[1-6]$/i.test(tag) || this.#$.component.is(element)) {
294
294
  newEl = dom.utils.createElement(this.#options.get('defaultLine'), null, '<br>');
295
295
  } else if (this.isBrLine(element)) {
296
296
  newEl = dom.utils.createElement(this.#options.get('defaultLine'), null, '<br>');