suneditor 3.2.6 → 3.3.1

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 (51) hide show
  1. package/README.md +18 -1
  2. package/dist/suneditor-contents.min.css +1 -1
  3. package/dist/suneditor.min.css +3 -3
  4. package/dist/suneditor.min.js +1 -1
  5. package/package.json +4 -4
  6. package/src/assets/design/color.css +5 -2
  7. package/src/assets/design/size.css +6 -2
  8. package/src/assets/icons/defaultIcons.js +195 -162
  9. package/src/assets/suneditor.css +5101 -5105
  10. package/src/core/config/eventManager.js +24 -1
  11. package/src/core/config/optionProvider.js +5 -4
  12. package/src/core/event/actions/index.js +8 -4
  13. package/src/core/event/effects/keydown.registry.js +59 -10
  14. package/src/core/event/ports.js +2 -0
  15. package/src/core/event/rules/keydown.rule.backspace.js +52 -1
  16. package/src/core/event/rules/keydown.rule.delete.js +51 -0
  17. package/src/core/logic/dom/format.js +1 -1
  18. package/src/core/logic/dom/html.js +31 -0
  19. package/src/core/logic/panel/blockHandle.js +61 -24
  20. package/src/core/logic/panel/blockResolver.js +41 -4
  21. package/src/core/logic/panel/finder.js +3 -0
  22. package/src/core/logic/panel/menu.js +38 -1
  23. package/src/core/logic/panel/toolbar.js +1 -0
  24. package/src/core/logic/shell/_commandExecutor.js +2 -1
  25. package/src/core/logic/shell/pluginManager.js +15 -2
  26. package/src/core/logic/shell/ui.js +73 -32
  27. package/src/core/schema/frameContext.js +1 -1
  28. package/src/core/schema/options.js +49 -3
  29. package/src/core/section/constructor.js +17 -9
  30. package/src/modules/contract/Controller.js +14 -0
  31. package/src/modules/contract/Figure.js +5 -2
  32. package/src/modules/ui/CommandMenu.js +53 -14
  33. package/src/modules/ui/SelectMenu.js +103 -36
  34. package/src/plugins/dropdown/table/index.js +25 -8
  35. package/src/plugins/dropdown/table/services/table.cell.js +4 -7
  36. package/src/plugins/dropdown/table/shared/table.constants.js +2 -0
  37. package/src/plugins/field/slashCommand.js +59 -12
  38. package/types/assets/icons/defaultIcons.d.ts +1 -0
  39. package/types/core/event/actions/index.d.ts +1 -0
  40. package/types/core/event/effects/keydown.registry.d.ts +4 -0
  41. package/types/core/event/ports.d.ts +3 -0
  42. package/types/core/logic/panel/blockHandle.d.ts +3 -16
  43. package/types/core/logic/panel/menu.d.ts +8 -0
  44. package/types/core/logic/shell/ui.d.ts +31 -5
  45. package/types/core/schema/frameContext.d.ts +6 -2
  46. package/types/core/schema/options.d.ts +95 -4
  47. package/types/modules/ui/CommandMenu.d.ts +11 -0
  48. package/types/modules/ui/SelectMenu.d.ts +13 -7
  49. package/types/plugins/dropdown/table/index.d.ts +11 -0
  50. package/types/plugins/dropdown/table/shared/table.constants.d.ts +1 -0
  51. 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
 
@@ -229,13 +229,14 @@ export default class OptionProvider {
229
229
  }
230
230
 
231
231
  if (diff.has('placeholder_line')) {
232
- const phLineText = newRootOptions.get('placeholder_line');
233
- fc.set('placeholder_line', phLineText);
232
+ const phLine = newRootOptions.get('placeholder_line');
233
+ fc.set('placeholder_line', phLine);
234
234
 
235
235
  const marked = fc.get('wysiwyg').querySelector('.se-placeholder-line');
236
236
  if (marked) {
237
- if (phLineText) {
238
- marked.setAttribute('data-se-placeholder-line', phLineText);
237
+ const phText = ui.resolveLinePlaceholder(marked, phLine);
238
+ if (phText) {
239
+ marked.setAttribute('data-se-placeholder-line', phText);
239
240
  } else {
240
241
  marked.classList.remove('se-placeholder-line');
241
242
  marked.removeAttribute('data-se-placeholder-line');
@@ -138,7 +138,6 @@ export const A = {
138
138
  * @returns {Action}
139
139
  */
140
140
  backspaceEmptyLineMergePrev: (formatEl, prev) => ({ t: 'backspace.emptyLine.mergePrev', p: { formatEl, prev } }),
141
-
142
141
  /**
143
142
  * @param {Node} rowEndBr - the `<br>` the caret sits on (end of the empty row)
144
143
  * @param {Node} rowStartBr - the `<br>` that starts the empty row (removed to merge it up)
@@ -148,7 +147,6 @@ export const A = {
148
147
  t: 'backspace.brline.rowMerge',
149
148
  p: { rowEndBr, rowStartBr },
150
149
  }),
151
-
152
150
  /**
153
151
  * @description Soft line break merge (Backspace): the caret sits in the zero-width anchor right after a soft-break `<br>` (a Shift+Enter).
154
152
  * @param {Node} zws - the zero-width Text node holding the caret (its `previousSibling` is the `<br>`)
@@ -187,13 +185,11 @@ export const A = {
187
185
  * @returns {Action}
188
186
  */
189
187
  deleteEmptyLineMergeNext: (formatEl, next) => ({ t: 'delete.emptyLine.mergeNext', p: { formatEl, next } }),
190
-
191
188
  /**
192
189
  * @param {Node} rowEndBr - the `<br>` the caret sits on (end of the empty row); removed to pull the next row up
193
190
  * @returns {Action}
194
191
  */
195
192
  deleteBrLineRowMerge: (rowEndBr) => ({ t: 'delete.brline.rowMerge', p: { rowEndBr } }),
196
-
197
193
  /**
198
194
  * @description Soft line break merge (Delete): the caret sits just before a soft-break `<br>`.
199
195
  * @param {Node} br - the soft-break `<br>` to remove
@@ -201,6 +197,14 @@ export const A = {
201
197
  */
202
198
  deleteSoftBreakMerge: (br) => ({ t: 'delete.softBreak.merge', p: { br } }),
203
199
 
200
+ // === line ===
201
+ /**
202
+ * @param {HTMLElement} into - Line that receives the content (caret lands at its original end).
203
+ * @param {HTMLElement} from - Line whose content is moved into `into`, then removed.
204
+ * @returns {Action}
205
+ */
206
+ mergeLineInto: (into, from) => ({ t: 'line.merge', p: { into, from } }),
207
+
204
208
  // === tab ===
205
209
  /**
206
210
  * @param {Range} range
@@ -250,6 +250,39 @@ export default {
250
250
  caretToLineEdge(ports, next, false);
251
251
  },
252
252
 
253
+ /**
254
+ * @action mergeLineInto — Merge one line into another across a block boundary.
255
+ */
256
+ 'line.merge': ({ ports }, { into, from }) => {
257
+ const format = ports.format;
258
+ const container = from.parentElement;
259
+
260
+ stripTrailingBreaks(into);
261
+ const focusNode = into.lastChild;
262
+ stripTrailingBreaks(from);
263
+
264
+ while (from.firstChild) into.appendChild(from.firstChild);
265
+ dom.utils.removeItem(from);
266
+
267
+ let block = container;
268
+ while (
269
+ block &&
270
+ block !== into.parentElement &&
271
+ format.isBlock(block) &&
272
+ !format.isClosureBlock(block) &&
273
+ dom.check.isZeroWidth(block)
274
+ ) {
275
+ const parent = block.parentElement;
276
+ dom.utils.removeItem(block);
277
+ block = parent;
278
+ }
279
+
280
+ if (!into.firstChild) into.appendChild(dom.utils.createElement('BR'));
281
+
282
+ if (focusNode) caretToNodeEnd(ports, focusNode);
283
+ else ports.selection.setRange(into, 0, into, 0);
284
+ },
285
+
253
286
  /** @action deleteBrLineRowMerge — remove an empty row inside a brLine (PRE), pull the next row up */
254
287
  'delete.brline.rowMerge': ({ ports }, { rowEndBr }) => {
255
288
  let next = rowEndBr.nextSibling;
@@ -462,7 +495,8 @@ export default {
462
495
  shouldDelete: true,
463
496
  skipHistory: true,
464
497
  });
465
- edge.cc.insertBefore(newEl, edge.ec);
498
+
499
+ edge.cc.insertBefore(newEl, edge.sc ? edge.sc.nextSibling : edge.cc.firstChild);
466
500
  }
467
501
 
468
502
  newEl.innerHTML = '<br>';
@@ -596,15 +630,17 @@ export default {
596
630
  dom.utils.copyTagAttributes(newFormat, formatEl, ctx.options.get('lineAttrReset'));
597
631
 
598
632
  let child = focusBR;
599
- let sNode = selectionNode;
600
- do {
601
- if (!dom.check.isBreak(sNode) && sNode.nodeType === 1) {
602
- const f = /** @type {HTMLElement} */ (sNode.cloneNode(false));
603
- f.appendChild(child);
604
- child = f;
605
- }
606
- sNode = sNode.parentElement;
607
- } while (formatEl !== sNode && formatEl.contains(sNode));
633
+ if (!(formatEndEdge && ctx.options.get('lineBreakClearStyle'))) {
634
+ let sNode = selectionNode;
635
+ do {
636
+ if (!dom.check.isBreak(sNode) && sNode.nodeType === 1) {
637
+ const f = /** @type {HTMLElement} */ (sNode.cloneNode(false));
638
+ f.appendChild(child);
639
+ child = f;
640
+ }
641
+ sNode = sNode.parentElement;
642
+ } while (formatEl !== sNode && formatEl.contains(sNode));
643
+ }
608
644
 
609
645
  newFormat.appendChild(child);
610
646
  formatEl.parentNode.insertBefore(
@@ -755,6 +791,19 @@ function caretToNodeEnd(ports, node) {
755
791
  ports.selection.setRange(node, offset, node, offset);
756
792
  }
757
793
 
794
+ /**
795
+ * @description Removes trailing `<br>` elements and zero-width text nodes from `el`.
796
+ * @param {HTMLElement} el
797
+ */
798
+ function stripTrailingBreaks(el) {
799
+ let last = el.lastChild;
800
+ while (last && (dom.check.isBreak(last) || (last.nodeType === 3 && dom.check.isZeroWidth(last)))) {
801
+ const prev = last.previousSibling;
802
+ dom.utils.removeItem(last);
803
+ last = prev;
804
+ }
805
+ }
806
+
758
807
  /**
759
808
  * @param {HTMLElement} formatEl - Format element
760
809
  * @returns {Node}
@@ -34,6 +34,7 @@ import { useEnterFromBeforeInput } from './reducers/keydown.reducer';
34
34
  * @property {(...args: Parameters<Format['isNormalLine']>) => ReturnType<Format['isNormalLine']>} isNormalLine
35
35
  * @property {(...args: Parameters<Format['isBrLine']>) => ReturnType<Format['isBrLine']>} isBrLine
36
36
  * @property {(...args: Parameters<Format['isClosureBrLine']>) => ReturnType<Format['isClosureBrLine']>} isClosureBrLine
37
+ * @property {(...args: Parameters<Format['isBlock']>) => ReturnType<Format['isBlock']>} isBlock
37
38
  * @property {(...args: Parameters<Format['isClosureBlock']>) => ReturnType<Format['isClosureBlock']>} isClosureBlock
38
39
  * @property {(...args: Parameters<Format['isEdgeLine']>) => ReturnType<Format['isEdgeLine']>} isEdgeLine
39
40
  * @property {(...args: Parameters<Format['removeBlock']>) => ReturnType<Format['removeBlock']>} removeBlock
@@ -124,6 +125,7 @@ export function makePorts(inst, { _styleNodes }) {
124
125
  isNormalLine: (n) => format.isNormalLine(n),
125
126
  isBrLine: (n) => format.isBrLine(n),
126
127
  isClosureBrLine: (n) => format.isClosureBrLine(n),
128
+ isBlock: (n) => format.isBlock(n),
127
129
  isClosureBlock: (n) => format.isClosureBlock(n),
128
130
  isEdgeLine: (node, offset, dir) => format.isEdgeLine(node, offset, dir),
129
131
  removeBlock: (n, p) => format.removeBlock(n, p),
@@ -71,6 +71,23 @@ export function reduceBackspaceDown(actions, ports, ctx) {
71
71
  return false;
72
72
  }
73
73
 
74
+ if (
75
+ !selectRange &&
76
+ !bidiNotFront &&
77
+ formatEl &&
78
+ format.isNormalLine(formatEl) &&
79
+ !dom.check.isListCell(formatEl) &&
80
+ format.isEdgeLine(range.startContainer, range.startOffset, 'front')
81
+ ) {
82
+ const prevLine = findPrevMergeLine(format, formatEl);
83
+ if (prevLine) {
84
+ actions.push(A.preventStop());
85
+ actions.push(A.mergeLineInto(prevLine, formatEl));
86
+ actions.push(A.historyPush(true));
87
+ return false;
88
+ }
89
+ }
90
+
74
91
  // closure, default
75
92
  if (
76
93
  !selectRange &&
@@ -84,7 +101,6 @@ export function reduceBackspaceDown(actions, ports, ctx) {
84
101
  format.isClosureBrLine(formatEl) ||
85
102
  dom.check.isWysiwygFrame(formatEl.parentNode))
86
103
  ) {
87
- // closure range
88
104
  if (format.isClosureBlock(formatEl.parentNode)) {
89
105
  actions.push(A.preventStop());
90
106
  return false;
@@ -348,3 +364,38 @@ export function reduceBackspaceDown(actions, ports, ctx) {
348
364
  actions.push(A.caretScrollTo(range));
349
365
  return true;
350
366
  }
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
+ }
@@ -37,6 +37,23 @@ export function reduceDeleteDown(actions, ports, ctx) {
37
37
  return true;
38
38
  }
39
39
 
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) {
50
+ actions.push(A.preventStop());
51
+ actions.push(A.mergeLineInto(formatEl, targetLine));
52
+ actions.push(A.historyPush(true));
53
+ return false;
54
+ }
55
+ }
56
+
40
57
  if (
41
58
  !selectRange &&
42
59
  !bidiNotEnd &&
@@ -216,3 +233,37 @@ export function reduceDeleteDown(actions, ports, ctx) {
216
233
 
217
234
  return true;
218
235
  }
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
+ }
@@ -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>');
@@ -33,6 +33,7 @@ class HTML {
33
33
  #disallowedTagsRegExp;
34
34
  #disallowedTagNameRegExp;
35
35
  #allowedTagNameRegExp;
36
+ #emptyLineRegExp;
36
37
 
37
38
  /** @type {Object<string, RegExp>} */
38
39
  #attributeWhitelist;
@@ -97,6 +98,12 @@ class HTML {
97
98
  this.#disallowedTagNameRegExp = new RegExp(`^(${disallowedExtraTags})$`, 'i');
98
99
  this.#allowedTagNameRegExp = new RegExp(`^(${allowedExtraTags})$`, 'i');
99
100
 
101
+ // empty default line probe
102
+ this.#emptyLineRegExp = new RegExp(
103
+ `<${options.get('defaultLine')}(?:\\s[^>]*)?></${options.get('defaultLine')}>`,
104
+ 'i',
105
+ );
106
+
100
107
  // set disallow text nodes
101
108
  const disallowStyleNodes = Object.keys(options.get('_defaultStyleTagMap'));
102
109
  const allowStyleNodes = !options.get('elementWhitelist')
@@ -374,6 +381,8 @@ class HTML {
374
381
  });
375
382
  }
376
383
 
384
+ if (formatFilter && !_freeCodeViewMode) cleanData = this.#dropEmptyLines(cleanData);
385
+
377
386
  return cleanData;
378
387
  }
379
388
 
@@ -1699,6 +1708,28 @@ class HTML {
1699
1708
  return '';
1700
1709
  }
1701
1710
 
1711
+ /**
1712
+ * @description Drops empty default lines (`<p></p>`) from a cleaned HTML string.
1713
+ * Wrapping block-level content in a default line is invalid HTML, so the parser tears the line
1714
+ * apart and leaves caret-less debris behind. An intentionally blank line always carries `<br>`.
1715
+ * @param {string} html Cleaned HTML string
1716
+ * @returns {string} The string without empty default lines
1717
+ */
1718
+ #dropEmptyLines(html) {
1719
+ if (!this.#emptyLineRegExp.test(html)) return html;
1720
+
1721
+ const holder = dom.utils.createElement('DIV', null, html);
1722
+ const lines = holder.querySelectorAll(this.#options.get('defaultLine'));
1723
+ let removed = false;
1724
+ for (let i = lines.length - 1; i >= 0; i--) {
1725
+ if (lines[i].firstChild) continue;
1726
+ dom.utils.removeItem(lines[i]);
1727
+ removed = true;
1728
+ }
1729
+
1730
+ return removed ? holder.innerHTML : html;
1731
+ }
1732
+
1702
1733
  /**
1703
1734
  * @description Checks whether a node is a block-level container in which whitespace-only text
1704
1735
  * children are insignificant formatting whitespace (safe to drop), as opposed to an inline/line
@@ -37,6 +37,10 @@ class BlockHandle {
37
37
  #plusBtn;
38
38
  #dragBtn;
39
39
  #menuConfig;
40
+ #menuMaxHeight;
41
+ #menuMinWidth;
42
+ /** @type {?function(SunEditor.Deps, { block: HTMLElement, openMenu: function(): void }): void} */
43
+ #onPlusClickHook;
40
44
 
41
45
  /** @type {CommandMenu|null} */
42
46
  #actionMenu = null;
@@ -74,17 +78,21 @@ class BlockHandle {
74
78
  * @param {HTMLElement} blockHandle - Handle group (.se-block-handle)
75
79
  * @param {HTMLElement} blockHandlePlus - Plus button
76
80
  * @param {HTMLElement} blockHandleDrag - Drag button
77
- * @param {Array<string | { title: string, icon?: string, action: function(SunEditor.Deps, { block: HTMLElement }): void }>|null} menuConfig
78
- * Menu entries. Strings resolve via `ResolveButton` (plugin names, built-in commands). Objects
79
- * define a custom row whose `action` is invoked with the Deps bag and the current block element.
81
+ * @param {Object|Array<*>|null} blockHandleOptions - The `blockHandle` option object (`{ menu, onPlusClick, maxHeight, minWidth }`).
82
+ * - An array is accepted as a shorthand for `{ menu: [...] }`.
80
83
  */
81
- constructor($, blockHandleArea, blockHandle, blockHandlePlus, blockHandleDrag, menuConfig) {
84
+ constructor($, blockHandleArea, blockHandle, blockHandlePlus, blockHandleDrag, blockHandleOptions) {
82
85
  this.#$ = $;
83
86
  this.#area = blockHandleArea;
84
87
  this.#handle = blockHandle;
85
88
  this.#plusBtn = blockHandlePlus;
86
89
  this.#dragBtn = blockHandleDrag;
87
- this.#menuConfig = menuConfig || null;
90
+
91
+ const opts = Array.isArray(blockHandleOptions) ? { menu: blockHandleOptions } : blockHandleOptions || {};
92
+ this.#menuConfig = opts.menu || null;
93
+ this.#menuMaxHeight = typeof opts.maxHeight === 'string' ? opts.maxHeight : '';
94
+ this.#menuMinWidth = typeof opts.minWidth === 'string' ? opts.minWidth : '200px';
95
+ this.#onPlusClickHook = typeof opts.onPlusClick === 'function' ? opts.onPlusClick : null;
88
96
 
89
97
  this.#$.contextProvider.carrierWrapper.appendChild(this.#handle);
90
98
 
@@ -579,6 +587,7 @@ class BlockHandle {
579
587
  /**
580
588
  * @description Plus button click — insert new line after current block.
581
589
  * Mirrors Enter-at-end-of-line behavior from keydown.rule.enter.
590
+ * Adding the line is the fixed behavior; `onPlusClick` decides what happens next (nothing by default).
582
591
  * @param {MouseEvent} e
583
592
  */
584
593
  #onPlusClick(e) {
@@ -588,10 +597,20 @@ class BlockHandle {
588
597
  if (!this.#currentBlock) return;
589
598
 
590
599
  const newLine = this.#$.format.addLineAfter(this.#currentBlock);
591
- if (newLine) {
592
- this.#$.selection.setRange(newLine, 1, newLine, 1);
593
- this.#$.history.push(false);
594
- }
600
+ if (!newLine) return;
601
+
602
+ this.#$.selection.setRange(newLine, 1, newLine, 1);
603
+ this.#$.history.push(false);
604
+
605
+ if (!this.#onPlusClickHook) return;
606
+
607
+ this.#setCurrentBlock(newLine);
608
+ this.#updatePosition(newLine);
609
+
610
+ this.#onPlusClickHook(this.#$, {
611
+ block: newLine,
612
+ openMenu: () => this.#toggleActionMenu(),
613
+ });
595
614
  }
596
615
 
597
616
  /**
@@ -777,6 +796,22 @@ class BlockHandle {
777
796
  // Skip if this click was actually a drag
778
797
  if (this.#isDragging) return;
779
798
 
799
+ const componentInfo = this.#$.component.get(this.#currentBlock);
800
+ if (componentInfo) {
801
+ this.#actionMenu?.close();
802
+ this.#clearHoverLines();
803
+ this.#$.component.select(componentInfo.target, componentInfo.pluginName);
804
+ return;
805
+ }
806
+
807
+ this.#toggleActionMenu();
808
+ }
809
+
810
+ /**
811
+ * @description Open the block action menu (or close it when already open). Shared by the drag button
812
+ * and by the `openMenu` helper handed to the `onPlusClick` hook.
813
+ */
814
+ #toggleActionMenu() {
780
815
  if (!this.#menuConfig) return;
781
816
 
782
817
  // Lazy build — plugins are not yet instantiated when BlockHandle is constructed
@@ -786,23 +821,24 @@ class BlockHandle {
786
821
 
787
822
  if (this.#actionMenu.isOpen) {
788
823
  this.#actionMenu.close();
789
- } else {
790
- this.#expandRangeToFullLines();
824
+ return;
825
+ }
791
826
 
792
- // Highlight selected range lines
793
- const lines = this.#$.format.getLines(null);
794
- if (lines.length > 0) {
795
- this.#setHoverLines(lines);
796
- }
827
+ this.#expandRangeToFullLines();
797
828
 
798
- // Choose open direction based on available space.
799
- const btnGlobal = this.#$.offset.getGlobal(this.#dragBtn);
800
- const spaceBelow = dom.utils.getClientSize().h - (btnGlobal.top - _w.scrollY + btnGlobal.height);
801
- const spaceAbove = btnGlobal.top - _w.scrollY;
802
- const horiz = this.#$.options.get('_rtl') ? 'left' : 'right';
803
- const dir = `${horiz}-${spaceBelow >= spaceAbove ? 'bottom' : 'top'}`;
804
- this.#actionMenu.open(dir);
829
+ // Highlight selected range lines
830
+ const lines = this.#$.format.getLines(null);
831
+ if (lines.length > 0) {
832
+ this.#setHoverLines(lines);
805
833
  }
834
+
835
+ // Choose open direction based on available space.
836
+ const btnGlobal = this.#$.offset.getGlobal(this.#dragBtn);
837
+ const spaceBelow = dom.utils.getClientSize().h - (btnGlobal.top - _w.scrollY + btnGlobal.height);
838
+ const spaceAbove = btnGlobal.top - _w.scrollY;
839
+ const horiz = this.#$.options.get('_rtl') ? 'left' : 'right';
840
+ const dir = `${horiz}-${spaceBelow >= spaceAbove ? 'bottom' : 'top'}`;
841
+ this.#actionMenu.open(dir);
806
842
  }
807
843
 
808
844
  /**
@@ -816,7 +852,8 @@ class BlockHandle {
816
852
  selectMenuParams: {
817
853
  position: 'right-top',
818
854
  dir: this.#$.options.get('_rtl') ? 'rtl' : 'ltr',
819
- minWidth: '200px',
855
+ minWidth: this.#menuMinWidth,
856
+ maxHeight: this.#menuMaxHeight,
820
857
  keydownTarget: _w,
821
858
  closeMethod: () => {
822
859
  dom.utils.removeClass(this.#dragBtn, 'on');
@@ -35,6 +35,7 @@ const TABLE_INNER_RE = /^(THEAD|TBODY|TR|TD|TH)$/;
35
35
  */
36
36
  function classifyType(el) {
37
37
  const tag = el.nodeName;
38
+ if (isComponentContainer(el)) return 'component';
38
39
  if (tag === 'P' || tag === 'DIV') return 'p';
39
40
  if (HEADING_RE.test(tag)) return 'heading';
40
41
  if (tag === 'LI') return 'list-item';
@@ -74,6 +75,24 @@ function isInsideComponent(node) {
74
75
  return false;
75
76
  }
76
77
 
78
+ /**
79
+ * @description Resolve a node to its outermost component container, but only when that container sits directly on the wysiwyg root.
80
+ * @param {Node} node
81
+ * @returns {HTMLElement|null} The top-level component container, or `null` if there is none
82
+ */
83
+ function resolveTopLevelComponent(node) {
84
+ let el = node;
85
+ let outermost = null;
86
+
87
+ while (el && !isWysiwygFrame(el)) {
88
+ if (el.nodeType === 1 && isComponentContainer(/** @type {Element} */ (el)))
89
+ outermost = /** @type {HTMLElement} */ (el);
90
+ el = el.parentNode;
91
+ }
92
+
93
+ return outermost && outermost.parentNode && isWysiwygFrame(outermost.parentNode) ? outermost : null;
94
+ }
95
+
77
96
  /**
78
97
  * @description Count block-level ancestors between element and wysiwyg root.
79
98
  * @param {HTMLElement} element
@@ -193,8 +212,10 @@ export function resolveBlock(node, format, wysiwygFrame, mouseY) {
193
212
  // Already at wysiwyg root
194
213
  if (isWysiwygFrame(node)) return null;
195
214
 
196
- // Skip components (images, videos, etc.) — they have their own interaction
197
- if (isInsideComponent(node)) return null;
215
+ if (isInsideComponent(node)) {
216
+ const component = resolveTopLevelComponent(node);
217
+ return component ? describeBlock(component, format, mouseY) : null;
218
+ }
198
219
 
199
220
  let resolved = null;
200
221
 
@@ -248,8 +269,24 @@ export function resolveBlock(node, format, wysiwygFrame, mouseY) {
248
269
 
249
270
  if (!resolved) return null;
250
271
 
251
- // Final component check on resolved element
252
- if (isInsideComponent(resolved)) return null;
272
+ // Final component check on the resolved element (e.g. getLine walked into a component)
273
+ if (isInsideComponent(resolved)) {
274
+ const component = resolveTopLevelComponent(resolved);
275
+ return component ? describeBlock(component, format, mouseY) : null;
276
+ }
277
+
278
+ return describeBlock(resolved, format, mouseY);
279
+ }
280
+
281
+ /**
282
+ * @description Build the `BlockInfo` for an already-resolved block element.
283
+ * @param {HTMLElement} element - Resolved block-level element
284
+ * @param {FormatAPI} format - Injected format methods
285
+ * @param {number} [mouseY] - Mouse clientY for nested list resolution
286
+ * @returns {BlockInfo}
287
+ */
288
+ function describeBlock(element, format, mouseY) {
289
+ let resolved = element;
253
290
 
254
291
  // For UL/OL, resolve to the closest child LI by mouse Y.
255
292
  // For LI with nested sub-lists, find the deepest child LI.
@@ -125,6 +125,8 @@ class Finder {
125
125
 
126
126
  this.#isOpen = true;
127
127
 
128
+ dom.utils.addClass(this.#$.commandDispatcher.targets.get('finder'), 'active');
129
+
128
130
  // Listen for wysiwyg content changes to refresh highlights
129
131
  this.#addContentInputListener();
130
132
 
@@ -175,6 +177,7 @@ class Finder {
175
177
  if (!this.#isOpen) return;
176
178
 
177
179
  this.#isOpen = false;
180
+ dom.utils.removeClass(this.#$.commandDispatcher.targets.get('finder'), 'active');
178
181
  this.#clearHighlights();
179
182
  this.#matches = [];
180
183
  this.#currentIndex = -1;