suneditor 3.3.0 → 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.
- package/README.md +18 -1
- package/dist/suneditor.min.css +2 -2
- package/dist/suneditor.min.js +1 -1
- package/package.json +1 -1
- package/src/assets/suneditor.css +5101 -5105
- package/src/core/config/eventManager.js +24 -1
- package/src/core/logic/dom/format.js +1 -1
- package/src/core/logic/dom/html.js +31 -0
- package/src/core/logic/panel/blockHandle.js +61 -24
- package/src/core/logic/panel/blockResolver.js +41 -4
- package/src/core/logic/shell/pluginManager.js +15 -2
- package/src/core/logic/shell/ui.js +32 -27
- package/src/core/schema/options.js +16 -0
- package/src/core/section/constructor.js +12 -8
- package/src/modules/contract/Figure.js +5 -2
- package/src/modules/ui/CommandMenu.js +47 -5
- package/src/modules/ui/SelectMenu.js +97 -36
- package/src/plugins/dropdown/table/index.js +25 -8
- package/src/plugins/dropdown/table/shared/table.constants.js +2 -0
- package/src/plugins/field/slashCommand.js +59 -12
- package/types/core/logic/panel/blockHandle.d.ts +3 -16
- package/types/core/logic/shell/ui.d.ts +14 -5
- package/types/core/schema/options.d.ts +25 -0
- package/types/modules/ui/CommandMenu.d.ts +11 -0
- package/types/modules/ui/SelectMenu.d.ts +13 -7
- package/types/plugins/dropdown/table/index.d.ts +11 -0
- package/types/plugins/dropdown/table/shared/table.constants.d.ts +1 -0
- 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
|
-
|
|
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
|
|
|
@@ -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]
|
|
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
|
|
78
|
-
*
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
593
|
-
|
|
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
|
-
|
|
790
|
-
|
|
824
|
+
return;
|
|
825
|
+
}
|
|
791
826
|
|
|
792
|
-
|
|
793
|
-
const lines = this.#$.format.getLines(null);
|
|
794
|
-
if (lines.length > 0) {
|
|
795
|
-
this.#setHoverLines(lines);
|
|
796
|
-
}
|
|
827
|
+
this.#expandRangeToFullLines();
|
|
797
828
|
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
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:
|
|
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
|
-
|
|
197
|
-
|
|
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))
|
|
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.
|
|
@@ -151,9 +151,22 @@ class PluginManager {
|
|
|
151
151
|
let retainFilter;
|
|
152
152
|
if ((retainFilter = this.#options.get('__pluginRetainFilter'))) {
|
|
153
153
|
this.#retainFormatCheckers.forEach((plugin, query) => {
|
|
154
|
-
|
|
154
|
+
let infoLst;
|
|
155
|
+
try {
|
|
156
|
+
infoLst = domParser.querySelectorAll(query);
|
|
157
|
+
} catch (error) {
|
|
158
|
+
console.warn(`[SUNEDITOR.retainFormat.fail]-[${plugin.key}]`, error.message);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
155
162
|
for (let i = 0, len = infoLst.length; i < len; i++) {
|
|
156
|
-
if (retainFilter
|
|
163
|
+
if (retainFilter !== true && retainFilter[plugin.key] === false) continue;
|
|
164
|
+
|
|
165
|
+
try {
|
|
166
|
+
plugin.method(infoLst[i]);
|
|
167
|
+
} catch (error) {
|
|
168
|
+
console.warn(`[SUNEDITOR.retainFormat.fail]-[${plugin.key}]`, error.message);
|
|
169
|
+
}
|
|
157
170
|
}
|
|
158
171
|
});
|
|
159
172
|
}
|
|
@@ -72,6 +72,16 @@ class UIManager {
|
|
|
72
72
|
*/
|
|
73
73
|
#blockHandle = null;
|
|
74
74
|
|
|
75
|
+
/**
|
|
76
|
+
* @description Currently open `SelectMenu` instances.
|
|
77
|
+
* - There is one editor-wide "a select menu is open" flag but many `SelectMenu` instances, so it
|
|
78
|
+
* cannot be a plain boolean: the last writer would win. A field plugin that closes its own menu on
|
|
79
|
+
* every keystroke (`autocomplete`) used to flip the flag off while another menu (`slashCommand`) was
|
|
80
|
+
* still open, which let the wysiwyg keydown handler run against the open menu.
|
|
81
|
+
* @type {Set<*>}
|
|
82
|
+
*/
|
|
83
|
+
#openSelectMenus = new Set();
|
|
84
|
+
|
|
75
85
|
/**
|
|
76
86
|
* @constructor
|
|
77
87
|
* @param {SunEditor.Kernel} kernel
|
|
@@ -110,12 +120,6 @@ class UIManager {
|
|
|
110
120
|
this.#closeSignal = false;
|
|
111
121
|
this.#backWrapper = /** @type {HTMLElement} */ (this.#carrierWrapper.querySelector('.se-back-wrapper'));
|
|
112
122
|
|
|
113
|
-
/**
|
|
114
|
-
* @description Whether `SelectMenu` is open
|
|
115
|
-
* @type {boolean}
|
|
116
|
-
*/
|
|
117
|
-
this.selectMenuOn = false;
|
|
118
|
-
|
|
119
123
|
/**
|
|
120
124
|
* @description Currently open `Controller` info array
|
|
121
125
|
* @type {Array<SunEditor.Module.Controller.Info>}
|
|
@@ -136,6 +140,27 @@ class UIManager {
|
|
|
136
140
|
this._figureContainer = null;
|
|
137
141
|
}
|
|
138
142
|
|
|
143
|
+
/**
|
|
144
|
+
* @description Whether any `SelectMenu` is currently open.
|
|
145
|
+
* - Read-only: a menu announces itself through {@link setSelectMenuOpen}. Derived from the set of
|
|
146
|
+
* open instances so an unrelated menu closing cannot clear the flag for a menu that is still open.
|
|
147
|
+
* @returns {boolean}
|
|
148
|
+
*/
|
|
149
|
+
get selectMenuOn() {
|
|
150
|
+
return this.#openSelectMenus.size > 0;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* @internal
|
|
155
|
+
* @description `SelectMenu` open-state notification. Called by `SelectMenu.open()` / `.close()`.
|
|
156
|
+
* @param {*} instance The `SelectMenu` instance changing state
|
|
157
|
+
* @param {boolean} open `true` on open, `false` on close
|
|
158
|
+
*/
|
|
159
|
+
setSelectMenuOpen(instance, open) {
|
|
160
|
+
if (open) this.#openSelectMenus.add(instance);
|
|
161
|
+
else this.#openSelectMenus.delete(instance);
|
|
162
|
+
}
|
|
163
|
+
|
|
139
164
|
/**
|
|
140
165
|
* @description Set editor frame styles.
|
|
141
166
|
* - Define the style of the edit area
|
|
@@ -291,12 +316,6 @@ class UIManager {
|
|
|
291
316
|
|
|
292
317
|
this.#activeDirBtn(rtl);
|
|
293
318
|
|
|
294
|
-
// reverse toolbar buttons
|
|
295
|
-
this.#reverseToolbarButtons(this.#context.get('toolbar_buttonTray'));
|
|
296
|
-
if (this.#context.has('toolbar_sub_buttonTray')) {
|
|
297
|
-
this.#reverseToolbarButtons(this.#context.get('toolbar_sub_buttonTray'));
|
|
298
|
-
}
|
|
299
|
-
|
|
300
319
|
if (this.#store.mode.isBalloon) this.#$.toolbar._showBalloon();
|
|
301
320
|
else if (this.#store.mode.isSubBalloon) this.#$.subToolbar._showBalloon();
|
|
302
321
|
} catch (e) {
|
|
@@ -614,7 +633,7 @@ class UIManager {
|
|
|
614
633
|
rt.get('blockHandle'),
|
|
615
634
|
rt.get('blockHandlePlus'),
|
|
616
635
|
rt.get('blockHandleDrag'),
|
|
617
|
-
blockHandleOpt
|
|
636
|
+
blockHandleOpt,
|
|
618
637
|
);
|
|
619
638
|
}
|
|
620
639
|
}
|
|
@@ -783,20 +802,6 @@ class UIManager {
|
|
|
783
802
|
}
|
|
784
803
|
}
|
|
785
804
|
|
|
786
|
-
/**
|
|
787
|
-
* @description Reverse the order of toolbar button groups (excluding the more-layer).
|
|
788
|
-
* @param {HTMLElement} buttonTray - The `.se-btn-tray` element.
|
|
789
|
-
*/
|
|
790
|
-
#reverseToolbarButtons(buttonTray) {
|
|
791
|
-
if (!buttonTray) return;
|
|
792
|
-
const moreLayer = buttonTray.querySelector('.se-toolbar-more-layer');
|
|
793
|
-
const children = Array.from(buttonTray.children).filter((c) => c !== moreLayer);
|
|
794
|
-
for (let i = children.length - 1; i >= 0; i--) {
|
|
795
|
-
buttonTray.appendChild(children[i]);
|
|
796
|
-
}
|
|
797
|
-
if (moreLayer) buttonTray.appendChild(moreLayer);
|
|
798
|
-
}
|
|
799
|
-
|
|
800
805
|
/**
|
|
801
806
|
* @internal
|
|
802
807
|
* @description Set the disabled button list
|
|
@@ -327,9 +327,25 @@ export const DEFAULTS = {
|
|
|
327
327
|
* menu: [
|
|
328
328
|
* 'p', 'heading', 'blockStyle',
|
|
329
329
|
* { title: 'Duplicate', icon: 'copy', action: ($, { block }) => block.after(block.cloneNode(true)) },
|
|
330
|
+
* // `'table'` as a string opens the size picker; a custom item inserts a default table directly
|
|
331
|
+
* { title: 'Table', icon: 'table', action: ($) => $.plugins.table.insert(3, 3) },
|
|
330
332
|
* ],
|
|
331
333
|
* }
|
|
332
334
|
* ```
|
|
335
|
+
* @property {string} [blockHandle.maxHeight=""] - Max height of the menu list. Any CSS length; the list scrolls past it.
|
|
336
|
+
* - Unset by default: the menu grows with its items and is only clamped when it would overflow the viewport.
|
|
337
|
+
* @property {string} [blockHandle.minWidth="200px"] - Min width of the menu.
|
|
338
|
+
* @property {function(SunEditor.Deps, { block: HTMLElement, openMenu: function(): void }): void} [blockHandle.onPlusClick] - Runs after the plus button inserted a new line.
|
|
339
|
+
* - Adding the line is fixed behavior; this hook decides what happens next. Nothing does by default.
|
|
340
|
+
* - `block` is the new line, already focused. `openMenu()` opens the block handle's own `menu`.
|
|
341
|
+
* ```js
|
|
342
|
+
* blockHandle: {
|
|
343
|
+
* // open the block handle menu
|
|
344
|
+
* onPlusClick: ($, { openMenu }) => openMenu(),
|
|
345
|
+
* // ...or the slash command menu
|
|
346
|
+
* onPlusClick: ($, { block }) => $.plugins.slashCommand.open(block),
|
|
347
|
+
* }
|
|
348
|
+
* ```
|
|
333
349
|
* @property {string} [type=""] - Editor type. Use `"document"` for a document-style layout, with optional sub-types after `:`.
|
|
334
350
|
* ```js
|
|
335
351
|
* // type
|
|
@@ -44,12 +44,16 @@ function Constructor(editorTargets, options) {
|
|
|
44
44
|
if (options.plugins) {
|
|
45
45
|
const excludedPlugins = options.excludedPlugins || [];
|
|
46
46
|
const originPlugins = options.plugins;
|
|
47
|
-
const
|
|
48
|
-
.
|
|
49
|
-
.map((name) => originPlugins[name]);
|
|
50
|
-
|
|
51
|
-
for (let i = 0, len =
|
|
52
|
-
|
|
47
|
+
const pluginsEntries = Array.isArray(originPlugins)
|
|
48
|
+
? originPlugins.map((plugin) => [null, plugin])
|
|
49
|
+
: Object.keys(originPlugins).map((name) => [name, originPlugins[name]]);
|
|
50
|
+
|
|
51
|
+
for (let i = 0, len = pluginsEntries.length, name, p; i < len; i++) {
|
|
52
|
+
name = pluginsEntries[i][0];
|
|
53
|
+
p = pluginsEntries[i][1];
|
|
54
|
+
p = p?.default || p;
|
|
55
|
+
if (!p?.key) continue;
|
|
56
|
+
if (excludedPlugins.includes(p.key) || (name !== null && excludedPlugins.includes(name))) continue;
|
|
53
57
|
plugins[p.key] = p;
|
|
54
58
|
}
|
|
55
59
|
}
|
|
@@ -921,7 +925,7 @@ export function InitOptions(options, editorTargets, plugins) {
|
|
|
921
925
|
'toolbar_sub_width',
|
|
922
926
|
subbar.width ? (numbers.is(subbar.width) ? subbar.width + 'px' : subbar.width) : 'auto',
|
|
923
927
|
);
|
|
924
|
-
subButtons =
|
|
928
|
+
subButtons = subbar.buttonList;
|
|
925
929
|
o.set('buttons_sub', new Set(subButtons.toString().split(',')));
|
|
926
930
|
}
|
|
927
931
|
}
|
|
@@ -1054,7 +1058,7 @@ export function InitOptions(options, editorTargets, plugins) {
|
|
|
1054
1058
|
i: icons,
|
|
1055
1059
|
l: /** @type {Object<string, string>} */ (options.lang || _defaultLang),
|
|
1056
1060
|
v: (options.value = typeof options.value === 'string' ? options.value : null),
|
|
1057
|
-
buttons:
|
|
1061
|
+
buttons: buttonList,
|
|
1058
1062
|
subButtons: subButtons,
|
|
1059
1063
|
statusbarContainer:
|
|
1060
1064
|
typeof options.statusbar_container === 'string'
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import Controller from './Controller';
|
|
2
2
|
import SelectMenu from '../ui/SelectMenu';
|
|
3
3
|
import { _DragHandle } from '../ui/_DragHandle';
|
|
4
|
-
import { dom, numbers, env, converter, keyCodeMap } from '../../helper';
|
|
4
|
+
import { dom, numbers, env, converter, keyCodeMap, unicode } from '../../helper';
|
|
5
5
|
|
|
6
6
|
const { _w, ON_OVER_COMPONENT } = env;
|
|
7
7
|
const DIRECTION_CURSOR_MAP = {
|
|
@@ -975,10 +975,13 @@ class Figure {
|
|
|
975
975
|
retainFigureFormat(container, originEl, anchorCover, fileManagerInst) {
|
|
976
976
|
const isInline = this.#$.component.isInline(container);
|
|
977
977
|
const originParent = originEl.parentNode;
|
|
978
|
+
const isBareWrapper =
|
|
979
|
+
originParent.children?.length === 1 &&
|
|
980
|
+
!originParent.textContent.replace(unicode.zeroWidthRegExp, '').trim();
|
|
978
981
|
let existElement =
|
|
979
982
|
this.#$.format.isBlock(originParent) || dom.check.isWysiwygFrame(originParent) || originParent.nodeType >= 9
|
|
980
983
|
? originEl
|
|
981
|
-
: Figure.GetContainer(originEl)?.container || originParent
|
|
984
|
+
: Figure.GetContainer(originEl)?.container || (isBareWrapper ? originParent : originEl);
|
|
982
985
|
|
|
983
986
|
if (dom.query.getParentElement(originEl, dom.check.isExcludeFormat)) {
|
|
984
987
|
existElement = anchorCover && anchorCover !== originEl ? anchorCover : originEl;
|
|
@@ -69,6 +69,10 @@ export function buildRowHTML(label, iconHTML) {
|
|
|
69
69
|
* @property {Object} selectMenuParams - Base SelectMenu params (`position`, `minWidth`, `keydownTarget`, etc.).
|
|
70
70
|
* @property {function(CommandMenuItem, { icons: Object }): string} [renderCustomItem] - Optional renderer
|
|
71
71
|
* applied to custom (object) items only. Plugin-string items always render with `buildRowHTML`.
|
|
72
|
+
* @property {function(): void} [prepareCommit] - Optional owner hook run once,
|
|
73
|
+
* - immediately before the user commits inside a dropdown-free flyout (SlashCommand uses it to delete the typed `/query`).
|
|
74
|
+
* - A native submenu gets this for free — its commit routes back through `SelectMenu`'s select callback —
|
|
75
|
+
* - but a flyout is the plugin's own DOM, so the moment has to be intercepted.
|
|
72
76
|
*/
|
|
73
77
|
|
|
74
78
|
/**
|
|
@@ -97,8 +101,9 @@ class CommandMenu {
|
|
|
97
101
|
|
|
98
102
|
/** @type {Map<number, { name: string, plugin: any, li: HTMLElement }>} */
|
|
99
103
|
#freeMap = new Map();
|
|
100
|
-
/** @type {?{ dropdown: HTMLElement, plugin: any, originalParent: ?Node, anchorLi: HTMLElement, unsub: () => void }} */
|
|
104
|
+
/** @type {?{ dropdown: HTMLElement, plugin: any, originalParent: ?Node, anchorLi: HTMLElement, unsub: () => void, offCommit: () => void }} */
|
|
101
105
|
#flyoutState = null;
|
|
106
|
+
#prepareCommit = null;
|
|
102
107
|
|
|
103
108
|
/**
|
|
104
109
|
* @type {Array<{ name: string, idx: number }>}
|
|
@@ -124,6 +129,7 @@ class CommandMenu {
|
|
|
124
129
|
this.#resolveButton = params.resolveButton;
|
|
125
130
|
this.#rawItems = Array.isArray(params.items) ? params.items : [];
|
|
126
131
|
this.#renderCustomItem = typeof params.renderCustomItem === 'function' ? params.renderCustomItem : null;
|
|
132
|
+
this.#prepareCommit = typeof params.prepareCommit === 'function' ? params.prepareCommit : null;
|
|
127
133
|
|
|
128
134
|
// Wrap the host's closeMethod so the flyout is always torn down with the menu.
|
|
129
135
|
const userClose = params.selectMenuParams?.closeMethod;
|
|
@@ -134,6 +140,7 @@ class CommandMenu {
|
|
|
134
140
|
this.#unregisterAll();
|
|
135
141
|
userClose?.();
|
|
136
142
|
},
|
|
143
|
+
subCheckMethod: (index) => this.#freeMap.has(index),
|
|
137
144
|
subEscMethod: () => {
|
|
138
145
|
if (!this.#flyoutState) return false;
|
|
139
146
|
const anchorLi = this.#flyoutState.anchorLi;
|
|
@@ -156,11 +163,12 @@ class CommandMenu {
|
|
|
156
163
|
this.selectMenu.on(referElement, /** @type {*} */ (onSelect), attr);
|
|
157
164
|
|
|
158
165
|
this.#$.eventManager.addEvent(this.selectMenu.form, 'mousedown', (e) => {
|
|
159
|
-
if (env.isMobile) {
|
|
166
|
+
if (env.isMobile || dom.check.isInputElement(dom.query.getEventTarget(e))) {
|
|
160
167
|
this.#$.store.set('_preventBlur', true);
|
|
161
|
-
|
|
162
|
-
e.preventDefault();
|
|
168
|
+
return;
|
|
163
169
|
}
|
|
170
|
+
|
|
171
|
+
e.preventDefault();
|
|
164
172
|
});
|
|
165
173
|
|
|
166
174
|
this.#$.eventManager.addEvent(this.selectMenu.form, 'mousemove', this.#onMenuMouseMove.bind(this));
|
|
@@ -499,13 +507,46 @@ class CommandMenu {
|
|
|
499
507
|
dom.utils.addClass(anchorLi, 'se-submenu-open');
|
|
500
508
|
plugin.on?.(anchorLi);
|
|
501
509
|
|
|
510
|
+
const offCommit = this.#bindFlyoutCommit(dropdown);
|
|
511
|
+
|
|
502
512
|
// dropdown-off event and unsubscribe on close (see `#closeFlyout`) rather than patching core.
|
|
503
513
|
const unsub = this.#$.menu.subscribeDropdownOff(() => {
|
|
504
514
|
this.#closeFlyout();
|
|
505
515
|
this.selectMenu.close();
|
|
506
516
|
});
|
|
507
517
|
|
|
508
|
-
this.#flyoutState = { dropdown, plugin, originalParent, anchorLi, unsub };
|
|
518
|
+
this.#flyoutState = { dropdown, plugin, originalParent, anchorLi, unsub, offCommit };
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* @description Run the owner's `prepareCommit` on the first commit gesture inside `dropdown`.
|
|
523
|
+
* - Text inputs are skipped: `prepareCommit` moves the caret back into the wysiwyg, which would pull
|
|
524
|
+
* focus out of a field the user is still typing in (e.g. the color picker's hex box). Those commit
|
|
525
|
+
* through their own submit, and the hook runs on that instead.
|
|
526
|
+
* @param {HTMLElement} dropdown
|
|
527
|
+
* @returns {() => void} Unbind function
|
|
528
|
+
*/
|
|
529
|
+
#bindFlyoutCommit(dropdown) {
|
|
530
|
+
if (!this.#prepareCommit) return () => {};
|
|
531
|
+
|
|
532
|
+
let done = false;
|
|
533
|
+
const onCommit = (e) => {
|
|
534
|
+
if (done) return;
|
|
535
|
+
const target = /** @type {HTMLElement} */ (dom.query.getEventTarget(e));
|
|
536
|
+
if (dom.check.isInputElement(target)) return;
|
|
537
|
+
done = true;
|
|
538
|
+
this.#prepareCommit();
|
|
539
|
+
};
|
|
540
|
+
|
|
541
|
+
dropdown.addEventListener('mousedown', onCommit, true);
|
|
542
|
+
dropdown.addEventListener('keydown', onCommit, true);
|
|
543
|
+
dropdown.addEventListener('submit', onCommit, true);
|
|
544
|
+
|
|
545
|
+
return () => {
|
|
546
|
+
dropdown.removeEventListener('mousedown', onCommit, true);
|
|
547
|
+
dropdown.removeEventListener('keydown', onCommit, true);
|
|
548
|
+
dropdown.removeEventListener('submit', onCommit, true);
|
|
549
|
+
};
|
|
509
550
|
}
|
|
510
551
|
|
|
511
552
|
/**
|
|
@@ -518,6 +559,7 @@ class CommandMenu {
|
|
|
518
559
|
this.#flyoutState = null;
|
|
519
560
|
|
|
520
561
|
s.unsub?.();
|
|
562
|
+
s.offCommit?.();
|
|
521
563
|
s.dropdown.style.cssText = '';
|
|
522
564
|
s.dropdown.style.display = 'none';
|
|
523
565
|
|