suneditor 3.2.6 → 3.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.
Files changed (34) hide show
  1. package/dist/suneditor-contents.min.css +1 -1
  2. package/dist/suneditor.min.css +3 -3
  3. package/dist/suneditor.min.js +1 -1
  4. package/package.json +4 -4
  5. package/src/assets/design/color.css +5 -2
  6. package/src/assets/design/size.css +6 -2
  7. package/src/assets/icons/defaultIcons.js +195 -162
  8. package/src/assets/suneditor.css +99 -99
  9. package/src/core/config/optionProvider.js +5 -4
  10. package/src/core/event/actions/index.js +8 -4
  11. package/src/core/event/effects/keydown.registry.js +59 -10
  12. package/src/core/event/ports.js +2 -0
  13. package/src/core/event/rules/keydown.rule.backspace.js +52 -1
  14. package/src/core/event/rules/keydown.rule.delete.js +51 -0
  15. package/src/core/logic/panel/finder.js +3 -0
  16. package/src/core/logic/panel/menu.js +38 -1
  17. package/src/core/logic/panel/toolbar.js +1 -0
  18. package/src/core/logic/shell/_commandExecutor.js +2 -1
  19. package/src/core/logic/shell/ui.js +41 -5
  20. package/src/core/schema/frameContext.js +1 -1
  21. package/src/core/schema/options.js +33 -3
  22. package/src/core/section/constructor.js +5 -1
  23. package/src/modules/contract/Controller.js +14 -0
  24. package/src/modules/ui/CommandMenu.js +8 -11
  25. package/src/modules/ui/SelectMenu.js +6 -0
  26. package/src/plugins/dropdown/table/services/table.cell.js +4 -7
  27. package/types/assets/icons/defaultIcons.d.ts +1 -0
  28. package/types/core/event/actions/index.d.ts +1 -0
  29. package/types/core/event/effects/keydown.registry.d.ts +4 -0
  30. package/types/core/event/ports.d.ts +3 -0
  31. package/types/core/logic/panel/menu.d.ts +8 -0
  32. package/types/core/logic/shell/ui.d.ts +17 -0
  33. package/types/core/schema/frameContext.d.ts +6 -2
  34. package/types/core/schema/options.d.ts +70 -4
@@ -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
+ }
@@ -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;
@@ -1,4 +1,4 @@
1
- import { dom, converter, env } from '../../../helper';
1
+ import { dom, converter, env, keyCodeMap } from '../../../helper';
2
2
 
3
3
  const { isMobile, _w } = env;
4
4
 
@@ -19,6 +19,7 @@ class Menu {
19
19
  #dropdownCommands = [];
20
20
  #bindClose_dropdown_mouse = null;
21
21
  #bindClose_dropdown_key = null;
22
+ #bindClose_dropdown_esc = null;
22
23
  #bindClose_cons_mouse = null;
23
24
  #bindMenu_mousemove = null;
24
25
  #bindMenu_mouseout = null;
@@ -27,6 +28,8 @@ class Menu {
27
28
  #deferredShowTimer = null;
28
29
  #viewportListener = null;
29
30
  #visualViewport = null;
31
+ /** @type {Set<() => void>} */
32
+ #dropdownOffSubs = new Set();
30
33
 
31
34
  /**
32
35
  * @constructor
@@ -64,11 +67,13 @@ class Menu {
64
67
  mousedown: this.#OnMouseDown_dropdown.bind(this),
65
68
  containerDown: this.containerOff.bind(this),
66
69
  keydown: this.#OnKeyDown_dropdown.bind(this),
70
+ esc: this.#OnKeyDown_dropdown_esc.bind(this),
67
71
  mousemove: this.#OnMousemove_dropdown.bind(this),
68
72
  mouseout: this.#OnMouseout_dropdown.bind(this),
69
73
  };
70
74
  this.#bindClose_dropdown_mouse = null;
71
75
  this.#bindClose_dropdown_key = null;
76
+ this.#bindClose_dropdown_esc = null;
72
77
  this.#bindClose_cons_mouse = null;
73
78
 
74
79
  // eventManager member (viewport)
@@ -150,6 +155,9 @@ class Menu {
150
155
  this.#globalEventHandler.mousedown,
151
156
  false,
152
157
  );
158
+
159
+ this.#bindClose_dropdown_esc = this.#eventManager.addGlobalEvent('keydown', this.#globalEventHandler.esc, true);
160
+
153
161
  if (this.#dropdownCommands.includes(dropdownName)) {
154
162
  this.menus = converter.nodeListToArray(menu.querySelectorAll('[data-command]'));
155
163
  if (this.menus.length > 0) {
@@ -208,6 +216,20 @@ class Menu {
208
216
 
209
217
  this.#store.set('_preventBlur', false);
210
218
  this.currentDropdownPlugin = null;
219
+
220
+ for (const cb of [...this.#dropdownOffSubs]) cb();
221
+ }
222
+
223
+ /**
224
+ * @description Subscribe to be notified after a dropdown is turned off — i.e. a dropdown-free
225
+ * plugin committed and closed itself via {@link dropdownOff}. Mirrors {@link Store#subscribe}:
226
+ * returns an unsubscribe function.
227
+ * @param {() => void} callback
228
+ * @returns {() => void} Unsubscribe function
229
+ */
230
+ subscribeDropdownOff(callback) {
231
+ this.#dropdownOffSubs.add(callback);
232
+ return () => this.#dropdownOffSubs.delete(callback);
211
233
  }
212
234
 
213
235
  /**
@@ -424,6 +446,7 @@ class Menu {
424
446
  */
425
447
  #removeGlobalEvent() {
426
448
  this.#bindClose_dropdown_mouse &&= this.#eventManager.removeGlobalEvent(this.#bindClose_dropdown_mouse);
449
+ this.#bindClose_dropdown_esc &&= this.#eventManager.removeGlobalEvent(this.#bindClose_dropdown_esc);
427
450
  this.#bindClose_cons_mouse &&= this.#eventManager.removeGlobalEvent(this.#bindClose_cons_mouse);
428
451
  if (this.#bindClose_dropdown_key) {
429
452
  this.#bindClose_dropdown_key = this.#eventManager.removeGlobalEvent(this.#bindClose_dropdown_key);
@@ -491,6 +514,20 @@ class Menu {
491
514
  }
492
515
  }
493
516
 
517
+ /**
518
+ * @description Closes the open dropdown on ESC. Bound as a capture-phase global listener for every dropdown.
519
+ * @param {KeyboardEvent} e - Event object
520
+ */
521
+ #OnKeyDown_dropdown_esc(e) {
522
+ if (!keyCodeMap.isEsc(e.code)) return;
523
+ if (this.#$.ui.opendControllers?.some(({ form }) => form && dom.utils.hasClass(form, 'se-dropdown'))) return;
524
+
525
+ e.preventDefault();
526
+ e.stopPropagation();
527
+
528
+ this.dropdownOff();
529
+ }
530
+
494
531
  /**
495
532
  * @param {MouseEvent} e - Event object
496
533
  */
@@ -95,6 +95,7 @@ class Toolbar {
95
95
  isStickyPosible &&
96
96
  stickyTop >= 0 &&
97
97
  !this.#options.get('toolbar_container') &&
98
+ !this.#options.get('_toolbar_sticky_fixed') &&
98
99
  typeof CSS !== 'undefined' &&
99
100
  CSS.supports('position', 'sticky');
100
101
 
@@ -93,7 +93,8 @@ export default class CommandExecutor {
93
93
  }
94
94
  break;
95
95
  case 'finder':
96
- this.#$.finder.open(true);
96
+ if (this.#$.finder.isOpen) this.#$.finder.close();
97
+ else this.#$.finder.open(true);
97
98
  break;
98
99
  case 'codeView':
99
100
  this.#$.viewer.codeView(!this.#frameContext.get('isCodeView'));
@@ -873,22 +873,58 @@ class UIManager {
873
873
  }
874
874
  }
875
875
 
876
+ /**
877
+ * @description Resolves the per-line placeholder text for an empty line from the `placeholder_line` option.
878
+ * - String option: one hint for every empty line — except list cells and table cells (backward compatible).
879
+ * - Object option: keyed by tag name or a category sentinel matching the editor's format classification
880
+ * ({@link Format#isNormalLine}, `isBrLine`, `isClosureBrLine`, `isBlock`, `isClosureBlock`, list cells).
881
+ * @param {?Node} line - The (empty) line element.
882
+ * @param {string|Object<string, string>} opt - The `placeholder_line` option value.
883
+ * @returns {string} The resolved placeholder text (`''` = none).
884
+ */
885
+ resolveLinePlaceholder(line, opt) {
886
+ if (!line || !opt) return '';
887
+
888
+ if (typeof opt !== 'object') {
889
+ return dom.check.isListCell(line) || dom.query.getParentElement(line, dom.check.isTableCell) ? '' : opt;
890
+ }
891
+
892
+ const format = this.#$.format;
893
+ const tag = line.nodeName.toLowerCase();
894
+
895
+ if (typeof opt[tag] === 'string') return opt[tag];
896
+ if (dom.check.isListCell(line) && typeof opt['@list'] === 'string') return opt['@list'];
897
+ if (format.isClosureBrLine(line) && typeof opt['@closureBrLine'] === 'string') return opt['@closureBrLine'];
898
+ if (format.isBrLine(line) && typeof opt['@brLine'] === 'string') return opt['@brLine'];
899
+
900
+ const block = format.getBlock(line);
901
+ if (block && block !== line) {
902
+ const blockTag = block.nodeName.toLowerCase();
903
+ if (typeof opt[blockTag] === 'string') return opt[blockTag];
904
+ if (format.isClosureBlock(block) && typeof opt['@closureBlock'] === 'string') return opt['@closureBlock'];
905
+ if (typeof opt['@block'] === 'string') return opt['@block'];
906
+ }
907
+
908
+ if (format.isNormalLine(line) && typeof opt['@normalLine'] === 'string') return opt['@normalLine'];
909
+ return typeof opt['@line'] === 'string' ? opt['@line'] : '';
910
+ }
911
+
876
912
  /**
877
913
  * @internal
878
914
  * @description Notion-style per-line placeholder. Marks the focused empty line so a CSS `::before`
879
- * renders the hint text on it.
915
+ * renders the hint text resolved for that line's type (see {@link resolveLinePlaceholder}).
880
916
  * @param {SunEditor.FrameContext} fc - Frame context
881
917
  * @returns {boolean} Whether the line placeholder is currently shown.
882
918
  */
883
919
  #updateLinePlaceholder(fc) {
884
- const text = fc.get('placeholder_line');
885
- if (!text) return false;
920
+ const opt = fc.get('placeholder_line');
921
+ if (!opt) return false;
886
922
 
887
923
  const wysiwyg = fc.get('wysiwyg');
888
924
 
889
925
  const line = this.#store.get('hasFocus') ? this.#$.format.getLine(this.#$.selection.selectionNode) : null;
890
- const inTableCell = !!line && !!dom.query.getParentElement(line, dom.check.isTableCell);
891
- const target = dom.check.isEmptyLine(line) && !dom.check.isListCell(line) && !inTableCell ? line : null;
926
+ const text = dom.check.isEmptyLine(line) ? this.resolveLinePlaceholder(line, opt) : '';
927
+ const target = text ? line : null;
892
928
 
893
929
  const prevMarkers = wysiwyg.querySelectorAll('.se-placeholder-line');
894
930
  for (let i = 0; i < prevMarkers.length; i++) {
@@ -34,7 +34,7 @@ import { get as getNumber } from '../../helper/numbers';
34
34
  * @property {HTMLTextAreaElement} markdown - Markdown view editing element (a <textarea>).
35
35
  * @property {HTMLTextAreaElement} markdownNumbers - Element displaying line numbers in markdown view mode.
36
36
  * @property {HTMLElement} placeholder - Placeholder element shown when the editor is empty.
37
- * @property {string} placeholder_line - Per-line placeholder text, rendered via a `::before` on the focused empty line.
37
+ * @property {string|Object<string, string>} placeholder_line - Per-line placeholder text, rendered via a `::before` on the focused empty line.
38
38
  * @property {HTMLElement} statusbar - Editor status bar element (for resizing, info, etc.).
39
39
  * @property {HTMLElement} navigation - Navigation element (e.g., for outline or bookmarks).
40
40
  * @property {HTMLElement} charWrapper - Wrapper for the character counter element.
@@ -104,8 +104,22 @@ export const DEFAULTS = {
104
104
  * === Content & Editing ===
105
105
  * @property {string} [value=""] - Initial value for the editor.
106
106
  * @property {string} [placeholder=""] - Placeholder text shown when the whole editor is empty.
107
- * @property {string} [placeholder_line=""] - per-line placeholder shown on the focused
107
+ * @property {string|Object<string, string>} [placeholder_line=""] - per-line placeholder shown on the focused
108
108
  * line when that line is empty. Takes priority over `placeholder` while a line is focused.
109
+ * - **string**: one hint for every empty line (list cells and table cells excluded).
110
+ * - **object**: per-type hints keyed by tag name (`p`, `pre`, `blockquote`, ...) or a category sentinel
111
+ * matching the editor's format classification: `@line`, `@normalLine`, `@list`, `@brLine`, `@closureBrLine`,
112
+ * `@block`, `@closureBlock`. Resolved most-specific → least, like `tagStyles`:
113
+ * `<tag>` → `@list` → `@closureBrLine` → `@brLine` → block container (`<blockTag>` → `@closureBlock` → `@block`)
114
+ * → `@normalLine` → `@line`. A missing key = no placeholder for that type; an explicit `''` suppresses it.
115
+ * ```js
116
+ * // one hint everywhere
117
+ * placeholder_line: 'Type something…'
118
+ * // per-type
119
+ * placeholder_line: {
120
+ * '@normalLine': 'Type…', '@list': 'List item', '@block': 'Quote…', '@closureBlock': 'Cell', pre: '// code'
121
+ * }
122
+ * ```
109
123
  * @property {Object<string, string>} [editableFrameAttributes={spellcheck: "false"}] - Attributes for the editable frame[.sun-editor-editable].
110
124
  * ```js
111
125
  * { editableFrameAttributes: { spellcheck: 'true', autocomplete: 'on' } }
@@ -460,6 +474,13 @@ export const DEFAULTS = {
460
474
  * - Formats that include `line`, such as "Quote", still operate on a `line` basis.
461
475
  * - suneditor processes work in `line` units.
462
476
  * - When set to `br`, performance may decrease when editing a lot of data.
477
+ * @property {boolean} [lineBreakClearStyle=false] - When `true`, pressing Enter at the **end** of a line
478
+ * starts a fresh line that does not carry the caret's inline style nodes (e.g. bold/italic/color spans, links);
479
+ * the line-level element and its attributes are preserved.
480
+ * Only affects the end-of-line case — mid-line splits, start-of-line breaks, and Shift+Enter are unchanged.
481
+ * ```js
482
+ * { lineBreakClearStyle: true }
483
+ * ```
463
484
  * @property {string} [lineAttrReset=""] - Line properties that should be reset when changing lines. Delimiter: `"|"`.
464
485
  * ```js
465
486
  * { lineAttrReset: 'id|name' }
@@ -510,12 +531,16 @@ export const DEFAULTS = {
510
531
  * { toolbar_innerWidth: 'auto' }
511
532
  * ```
512
533
  * @property {?HTMLElement} [toolbar_container] - Container element for the toolbar.
513
- * @property {number|{top: number, offset: number}} [toolbar_sticky=0] - Enables sticky toolbar.
534
+ * @property {number|{top: number, offset?: number, position?: "sticky"|"fixed"}} [toolbar_sticky=0] - Enables sticky toolbar.
514
535
  * - `number`: Sets the sticky top position (px). Use `-1` to disable sticky.
515
536
  * - `{top, offset}`: `top` is the sticky position when the page header is visible.
516
537
  * - `offset` is the sticky position when a virtual keyboard shifts the viewport (e.g., on tablets, touch devices).
517
538
  * - When the virtual keyboard is active, `offset` replaces `top` so the toolbar doesn't leave a gap
518
539
  * - for a page header that has scrolled out of view. Default `offset` is `0`.
540
+ * - `position` (default `"sticky"`): the positioning engine.
541
+ * - `"sticky"` uses native CSS `position: sticky` (with a JS `position: fixed` fallback where unsupported).
542
+ * `"fixed"` forces the JS `position: fixed` engine
543
+ * - even when CSS sticky is supported — for environments where CSS sticky silently misbehaves and can't be
519
544
  * ```js
520
545
  * // Basic usage — sticky at top with 0px offset
521
546
  * toolbar_sticky: 0
@@ -525,6 +550,9 @@ export const DEFAULTS = {
525
550
  *
526
551
  * // 92px header on desktop, but 0px when virtual keyboard pushes the viewport
527
552
  * toolbar_sticky: { top: 92, offset: 0 }
553
+ *
554
+ * // Force the JS position:fixed engine (CSS sticky unreliable in this environment)
555
+ * toolbar_sticky: { top: 0, position: 'fixed' }
528
556
  * ```
529
557
  * @property {boolean} [toolbar_hide=false] - Hides toolbar initially.
530
558
  * @property {Object} [subToolbar={}] - Sub-toolbar configuration. A secondary toolbar that appears on text selection.
@@ -776,6 +804,7 @@ export const OPTION_FIXED_FLAG = {
776
804
  printClass: true,
777
805
  defaultLine: 'fixed',
778
806
  defaultLineBreakFormat: true,
807
+ lineBreakClearStyle: true,
779
808
  scopeSelectionTags: true,
780
809
  __defaultElementWhitelist: 'fixed',
781
810
  elementWhitelist: 'fixed',
@@ -822,7 +851,7 @@ export const OPTION_FIXED_FLAG = {
822
851
  };
823
852
 
824
853
  /**
825
- * @typedef {'formatClosureBrLine' | 'formatBrLine' | 'formatLine' | 'formatClosureBlock' | 'formatBlock' | 'toolbar_width' | 'toolbar_container' | '_toolbar_sticky' | '_toolbar_sticky_offset' | 'strictMode' | 'lineAttrReset'} TransformedOptionKeys
854
+ * @typedef {'formatClosureBrLine' | 'formatBrLine' | 'formatLine' | 'formatClosureBlock' | 'formatBlock' | 'toolbar_width' | 'toolbar_container' | '_toolbar_sticky' | '_toolbar_sticky_offset' | '_toolbar_sticky_fixed' | 'strictMode' | 'lineAttrReset'} TransformedOptionKeys
826
855
  */
827
856
 
828
857
  /**
@@ -846,6 +875,7 @@ export const OPTION_FIXED_FLAG = {
846
875
  * @property {HTMLElement|null} toolbar_container
847
876
  * @property {number} _toolbar_sticky
848
877
  * @property {number} _toolbar_sticky_offset
878
+ * @property {boolean} _toolbar_sticky_fixed
849
879
  * @property {StrictModeOptions} strictMode
850
880
  * @property {string[]} lineAttrReset
851
881
  */
@@ -89,7 +89,7 @@ function Constructor(editorTargets, options) {
89
89
  style: 'position: fixed !important; top: -10000px !important; left: -10000px !important; display: block !important; width: 0 !important; height: 0 !important; margin: 0 !important; padding: 0 !important;',
90
90
  })
91
91
  );
92
- focusTemp.tabIndex = 0;
92
+ focusTemp.tabIndex = -1;
93
93
  editor_carrier_wrapper.appendChild(focusTemp);
94
94
 
95
95
  // modal
@@ -749,6 +749,7 @@ export function InitOptions(options, editorTargets, plugins) {
749
749
  typeof options.defaultLine === 'string' && options.defaultLine.length > 0 ? options.defaultLine : 'p',
750
750
  );
751
751
  o.set('defaultLineBreakFormat', options.defaultLineBreakFormat || 'line');
752
+ o.set('lineBreakClearStyle', !!options.lineBreakClearStyle);
752
753
  o.set('scopeSelectionTags', options.scopeSelectionTags || DEFAULTS.SCOPE_SELECTION_TAGS);
753
754
  // element
754
755
  const elw = (typeof options.elementWhitelist === 'string' ? options.elementWhitelist : '').toLowerCase();
@@ -893,12 +894,15 @@ export function InitOptions(options, editorTargets, plugins) {
893
894
  if (_isBalloon) {
894
895
  o.set('_toolbar_sticky', -1);
895
896
  o.set('_toolbar_sticky_offset', 0);
897
+ o.set('_toolbar_sticky_fixed', false);
896
898
  } else if (_stickyOpt !== null && typeof _stickyOpt === 'object') {
897
899
  o.set('_toolbar_sticky', numbers.get(_stickyOpt.top, 0));
898
900
  o.set('_toolbar_sticky_offset', numbers.get(_stickyOpt.offset, 0));
901
+ o.set('_toolbar_sticky_fixed', _stickyOpt.position === 'fixed');
899
902
  } else {
900
903
  o.set('_toolbar_sticky', _stickyOpt === undefined ? 0 : numbers.is(_stickyOpt) ? _stickyOpt : -1);
901
904
  o.set('_toolbar_sticky_offset', 0);
905
+ o.set('_toolbar_sticky_fixed', false);
902
906
  }
903
907
 
904
908
  o.set('toolbar_hide', !!options.toolbar_hide);
@@ -8,6 +8,11 @@ const INDEX_S_1 = '2147483642';
8
8
  const INDEX_1 = '2147483641';
9
9
  const ADD_OFFSET_VALUE = { left: 0, right: 0, top: 0 };
10
10
 
11
+ /**
12
+ * @type {?Controller}
13
+ */
14
+ let _topHoverController = null;
15
+
11
16
  /**
12
17
  * Controller information object
13
18
  * @typedef {Object} ControllerInfo
@@ -414,6 +419,7 @@ class Controller {
414
419
  * @description Hide controller at editor area (link button, image resize button..)
415
420
  */
416
421
  #controllerOff() {
422
+ if (_topHoverController === this) _topHoverController = null;
417
423
  this.form.hidePopover?.();
418
424
  this.form.style.display = 'none';
419
425
  this.#$.ui.opendControllers = this.#$.ui.opendControllers.filter((v) => v.form !== this.form);
@@ -586,6 +592,14 @@ class Controller {
586
592
 
587
593
  const eventTarget = dom.query.getEventTarget(e);
588
594
  eventTarget.style.zIndex = this.toTop ? INDEX_00 : INDEX_0;
595
+
596
+ // The z-index above governs only the no-popover fallback. In the popover top layer, stacking follows
597
+ // show order — so to lift the hovered controller (and its tooltips) above an overlapping sibling, re-show it.
598
+ if (this.sibling && _topHoverController !== this && this.form.matches?.(':popover-open')) {
599
+ _topHoverController = this;
600
+ this.form.hidePopover();
601
+ this.form.showPopover();
602
+ }
589
603
  }
590
604
 
591
605
  /**
@@ -97,7 +97,7 @@ class CommandMenu {
97
97
 
98
98
  /** @type {Map<number, { name: string, plugin: any, li: HTMLElement }>} */
99
99
  #freeMap = new Map();
100
- /** @type {?{ dropdown: HTMLElement, plugin: any, originalParent: ?Node, anchorLi: HTMLElement, evClick: ?SunEditor.Event.Info }} */
100
+ /** @type {?{ dropdown: HTMLElement, plugin: any, originalParent: ?Node, anchorLi: HTMLElement, unsub: () => void }} */
101
101
  #flyoutState = null;
102
102
 
103
103
  /**
@@ -499,16 +499,13 @@ class CommandMenu {
499
499
  dom.utils.addClass(anchorLi, 'se-submenu-open');
500
500
  plugin.on?.(anchorLi);
501
501
 
502
- // Clicking inside the flyout's dropdown should dismiss both the flyout and the parent menu —
503
- // matches the toolbar behavior where a click commits the selection.
504
- const evClick = this.#$.eventManager.addEvent(dropdown, 'click', () =>
505
- _w.setTimeout(() => {
506
- this.#closeFlyout();
507
- this.selectMenu.close();
508
- }, 0),
509
- );
502
+ // dropdown-off event and unsubscribe on close (see `#closeFlyout`) rather than patching core.
503
+ const unsub = this.#$.menu.subscribeDropdownOff(() => {
504
+ this.#closeFlyout();
505
+ this.selectMenu.close();
506
+ });
510
507
 
511
- this.#flyoutState = { dropdown, plugin, originalParent, anchorLi, evClick };
508
+ this.#flyoutState = { dropdown, plugin, originalParent, anchorLi, unsub };
512
509
  }
513
510
 
514
511
  /**
@@ -520,7 +517,7 @@ class CommandMenu {
520
517
  if (!s) return;
521
518
  this.#flyoutState = null;
522
519
 
523
- this.#$?.eventManager.removeEvent(s.evClick);
520
+ s.unsub?.();
524
521
  s.dropdown.style.cssText = '';
525
522
  s.dropdown.style.display = 'none';
526
523
 
@@ -1003,6 +1003,12 @@ class SelectMenu {
1003
1003
  #CloseListener_mousedown(e) {
1004
1004
  const eventTarget = dom.query.getEventTarget(e);
1005
1005
  if (this.form.contains(eventTarget)) return;
1006
+ if (
1007
+ this.#$.ui.opendControllers?.some(
1008
+ ({ form }) => form?.contains?.(eventTarget) && !form.contains(this.#refer),
1009
+ )
1010
+ )
1011
+ return;
1006
1012
  if (!this.#refer.contains(eventTarget)) {
1007
1013
  this.close();
1008
1014
  } else if (!dom.check.isInputElement(eventTarget)) {
@@ -279,15 +279,12 @@ export class TableCellService {
279
279
  * @description Sets the unmerge button visibility.
280
280
  */
281
281
  setUnMergeButton() {
282
- if (
282
+ const hasMergedCells =
283
283
  this.findMergedCells(
284
284
  !this.#state.selectedCells?.length ? [this.#state.fixedCell] : this.#state.selectedCells,
285
- ).length > 0
286
- ) {
287
- this.unmergeButton.disabled = false;
288
- } else {
289
- this.unmergeButton.disabled = true;
290
- }
285
+ ).length > 0;
286
+
287
+ this.unmergeButton.style.display = hasMergedCells ? 'block' : 'none';
291
288
  }
292
289
 
293
290
  /**
@@ -20,6 +20,7 @@ declare namespace _default {
20
20
  export let print: string;
21
21
  export let template: string;
22
22
  export let layout: string;
23
+ export let ai: string;
23
24
  export let new_document: string;
24
25
  export let select_all: string;
25
26
  export let line_height: string;
@@ -44,6 +44,7 @@ export namespace A {
44
44
  function deleteEmptyLineMergeNext(formatEl: Element, next: Element): Action;
45
45
  function deleteBrLineRowMerge(rowEndBr: Node): Action;
46
46
  function deleteSoftBreakMerge(br: Node): Action;
47
+ function mergeLineInto(into: HTMLElement, from: HTMLElement): Action;
47
48
  function tabFormatIndent(range: Range, formatEl: Element, shift: boolean): Action;
48
49
  function caretScrollTo(range: Range): Action;
49
50
  function enterLineAddDefault(formatEl: Element): Action;
@@ -37,6 +37,10 @@ declare const _default: {
37
37
  'delete.list.removeNested': ({ ports, ctx }: EffectContext_keydown, { range, formatEl, rangeEl }: any) => void;
38
38
  /** @action deleteEmptyLineMergeNext — remove an empty line, move caret to the start of the next line */
39
39
  'delete.emptyLine.mergeNext': ({ ports }: EffectContext_keydown, { formatEl, next }: any) => void;
40
+ /**
41
+ * @action mergeLineInto — Merge one line into another across a block boundary.
42
+ */
43
+ 'line.merge': ({ ports }: EffectContext_keydown, { into, from }: any) => void;
40
44
  /** @action deleteBrLineRowMerge — remove an empty row inside a brLine (PRE), pull the next row up */
41
45
  'delete.brline.rowMerge': ({ ports }: EffectContext_keydown, { rowEndBr }: any) => void;
42
46
  /** @action deleteSoftBreakMerge */
@@ -29,6 +29,7 @@ import type {} from '../../typedef';
29
29
  * @property {(...args: Parameters<Format['isNormalLine']>) => ReturnType<Format['isNormalLine']>} isNormalLine
30
30
  * @property {(...args: Parameters<Format['isBrLine']>) => ReturnType<Format['isBrLine']>} isBrLine
31
31
  * @property {(...args: Parameters<Format['isClosureBrLine']>) => ReturnType<Format['isClosureBrLine']>} isClosureBrLine
32
+ * @property {(...args: Parameters<Format['isBlock']>) => ReturnType<Format['isBlock']>} isBlock
32
33
  * @property {(...args: Parameters<Format['isClosureBlock']>) => ReturnType<Format['isClosureBlock']>} isClosureBlock
33
34
  * @property {(...args: Parameters<Format['isEdgeLine']>) => ReturnType<Format['isEdgeLine']>} isEdgeLine
34
35
  * @property {(...args: Parameters<Format['removeBlock']>) => ReturnType<Format['removeBlock']>} removeBlock
@@ -98,6 +99,7 @@ export function makePorts(
98
99
  isNormalLine: (n: any) => n is HTMLElement;
99
100
  isBrLine: (n: any) => n is HTMLElement;
100
101
  isClosureBrLine: (n: any) => n is HTMLElement;
102
+ isBlock: (n: any) => n is HTMLElement;
101
103
  isClosureBlock: (n: any) => n is HTMLElement;
102
104
  isEdgeLine: (node: any, offset: any, dir: any) => node is HTMLElement;
103
105
  removeBlock: (
@@ -206,6 +208,7 @@ export type FormatPorts = {
206
208
  isNormalLine: (...args: Parameters<Format['isNormalLine']>) => ReturnType<Format['isNormalLine']>;
207
209
  isBrLine: (...args: Parameters<Format['isBrLine']>) => ReturnType<Format['isBrLine']>;
208
210
  isClosureBrLine: (...args: Parameters<Format['isClosureBrLine']>) => ReturnType<Format['isClosureBrLine']>;
211
+ isBlock: (...args: Parameters<Format['isBlock']>) => ReturnType<Format['isBlock']>;
209
212
  isClosureBlock: (...args: Parameters<Format['isClosureBlock']>) => ReturnType<Format['isClosureBlock']>;
210
213
  isEdgeLine: (...args: Parameters<Format['isEdgeLine']>) => ReturnType<Format['isEdgeLine']>;
211
214
  removeBlock: (...args: Parameters<Format['removeBlock']>) => ReturnType<Format['removeBlock']>;
@@ -118,6 +118,14 @@ declare class Menu {
118
118
  * @description Closes the currently open dropdown menu.
119
119
  */
120
120
  dropdownOff(): void;
121
+ /**
122
+ * @description Subscribe to be notified after a dropdown is turned off — i.e. a dropdown-free
123
+ * plugin committed and closed itself via {@link dropdownOff}. Mirrors {@link Store#subscribe}:
124
+ * returns an unsubscribe function.
125
+ * @param {() => void} callback
126
+ * @returns {() => void} Unsubscribe function
127
+ */
128
+ subscribeDropdownOff(callback: () => void): () => void;
121
129
  /**
122
130
  * @description Shows a previously hidden dropdown menu that is still in `on` state.
123
131
  * - Only works when a dropdown is active (`currentButton` exists)