suneditor 3.3.0 → 3.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -1
- package/dist/suneditor.min.css +2 -2
- package/dist/suneditor.min.js +1 -1
- package/package.json +3 -2
- package/src/assets/suneditor.css +5117 -5105
- package/src/core/config/eventManager.js +24 -1
- package/src/core/editor.js +6 -1
- package/src/core/event/effects/keydown.registry.js +14 -9
- package/src/core/event/effects/ruleHelpers.js +119 -1
- package/src/core/event/eventOrchestrator.js +15 -6
- package/src/core/event/rules/keydown.rule.backspace.js +10 -47
- package/src/core/event/rules/keydown.rule.delete.js +28 -66
- package/src/core/logic/dom/format.js +2 -2
- package/src/core/logic/dom/html.js +85 -5
- package/src/core/logic/dom/nodeTransform.js +5 -3
- 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 +38 -28
- package/src/core/schema/options.js +20 -3
- package/src/core/section/constructor.js +12 -8
- package/src/events.js +1 -1
- package/src/helper/dom/domQuery.js +10 -4
- package/src/helper/googleDocs.js +40 -0
- package/src/helper/index.js +3 -0
- 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/event/effects/ruleHelpers.d.ts +56 -0
- 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 +31 -5
- package/types/events.d.ts +2 -2
- package/types/helper/dom/domQuery.d.ts +4 -2
- package/types/helper/googleDocs.d.ts +19 -0
- package/types/helper/index.d.ts +5 -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
|
@@ -513,7 +513,8 @@ export function getEdgeChildNodes(first, last) {
|
|
|
513
513
|
|
|
514
514
|
/**
|
|
515
515
|
* @template {Node} T
|
|
516
|
-
* @description Gets the previous sibling last child. If there is no sibling, then it'll take it from the closest ancestor with child
|
|
516
|
+
* @description Gets the previous sibling last child. If there is no sibling, then it'll take it from the closest ancestor with child.
|
|
517
|
+
* - Components (image, table, etc.) are treated as a single tag and not traversed into.
|
|
517
518
|
* @param {Node} node Reference element
|
|
518
519
|
* @param {?Node} [ceiling] Highest boundary allowed
|
|
519
520
|
* @returns {T|null} Not found: `null`
|
|
@@ -533,14 +534,17 @@ export function getPreviousDeepestNode(node, ceiling) {
|
|
|
533
534
|
|
|
534
535
|
if (domCheck.isNonEditable(previousNode)) return /** @type {T} */ (/** @type {unknown} */ (previousNode));
|
|
535
536
|
|
|
536
|
-
while (previousNode
|
|
537
|
+
while (!domCheck.isComponentContainer(previousNode) && previousNode.lastChild) {
|
|
538
|
+
previousNode = previousNode.lastChild;
|
|
539
|
+
}
|
|
537
540
|
|
|
538
541
|
return /** @type {T} */ (/** @type {unknown} */ (previousNode));
|
|
539
542
|
}
|
|
540
543
|
|
|
541
544
|
/**
|
|
542
545
|
* @template {Node} T
|
|
543
|
-
* @description Gets the next sibling first child. If there is no sibling, then it'll take it from the closest ancestor with child
|
|
546
|
+
* @description Gets the next sibling first child. If there is no sibling, then it'll take it from the closest ancestor with child.
|
|
547
|
+
* - Components (image, table, etc.) are treated as a single tag and not traversed into.
|
|
544
548
|
* @param {Node} node Reference element
|
|
545
549
|
* @param {?Node} [ceiling] Highest boundary allowed
|
|
546
550
|
* @returns {T|null} Not found: `null`
|
|
@@ -560,7 +564,9 @@ export function getNextDeepestNode(node, ceiling) {
|
|
|
560
564
|
|
|
561
565
|
if (domCheck.isNonEditable(nextNode)) return /** @type {T} */ (/** @type {unknown} */ (nextNode));
|
|
562
566
|
|
|
563
|
-
while (nextNode
|
|
567
|
+
while (!domCheck.isComponentContainer(nextNode) && nextNode.firstChild) {
|
|
568
|
+
nextNode = nextNode.firstChild;
|
|
569
|
+
}
|
|
564
570
|
|
|
565
571
|
return /** @type {T} */ (/** @type {unknown} */ (nextNode));
|
|
566
572
|
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @description Cleans Google Docs clipboard HTML.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** Matches the Google Docs clipboard wrapper id */
|
|
6
|
+
const _RE_GUID = /id=["']?docs-internal-guid-/i;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @description Whether the HTML string is a Google Docs clipboard payload.
|
|
10
|
+
* @param {string} html HTML string
|
|
11
|
+
* @returns {boolean}
|
|
12
|
+
*/
|
|
13
|
+
export function isGoogleDocs(html) {
|
|
14
|
+
return _RE_GUID.test(html);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @description Removes the Google Docs clipboard wrapper tags, keeping their children.
|
|
19
|
+
* - Only the guid-carrying inline wrappers (`b`/`span`) are unwrapped; real formatting tags inside are untouched.
|
|
20
|
+
* @param {string} html HTML string
|
|
21
|
+
* @returns {string} HTML string
|
|
22
|
+
*/
|
|
23
|
+
export function cleanHTML(html) {
|
|
24
|
+
const doc = new DOMParser().parseFromString(html, 'text/html');
|
|
25
|
+
const wrappers = doc.body.querySelectorAll('b[id^="docs-internal-guid-"], span[id^="docs-internal-guid-"]');
|
|
26
|
+
if (wrappers.length === 0) return html;
|
|
27
|
+
|
|
28
|
+
for (let i = 0, len = wrappers.length, w; i < len; i++) {
|
|
29
|
+
w = wrappers[i];
|
|
30
|
+
while (w.firstChild) w.parentNode.insertBefore(w.firstChild, w);
|
|
31
|
+
w.parentNode.removeChild(w);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return doc.body.innerHTML;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export default {
|
|
38
|
+
isGoogleDocs,
|
|
39
|
+
cleanHTML,
|
|
40
|
+
};
|
package/src/helper/index.js
CHANGED
|
@@ -7,6 +7,7 @@ import KeyCodeMap from './keyCodeMap';
|
|
|
7
7
|
import Clipboard from './clipboard';
|
|
8
8
|
import Markdown from './markdown';
|
|
9
9
|
import MSOffice from './msOffice';
|
|
10
|
+
import GoogleDocs from './googleDocs';
|
|
10
11
|
|
|
11
12
|
export const env = Env;
|
|
12
13
|
export const unicode = Unicode;
|
|
@@ -17,6 +18,7 @@ export const keyCodeMap = KeyCodeMap;
|
|
|
17
18
|
export const clipboard = Clipboard;
|
|
18
19
|
export const markdown = Markdown;
|
|
19
20
|
export const msOffice = MSOffice;
|
|
21
|
+
export const googleDocs = GoogleDocs;
|
|
20
22
|
|
|
21
23
|
export default {
|
|
22
24
|
env,
|
|
@@ -28,4 +30,5 @@ export default {
|
|
|
28
30
|
clipboard,
|
|
29
31
|
markdown,
|
|
30
32
|
msOffice,
|
|
33
|
+
googleDocs,
|
|
31
34
|
};
|
|
@@ -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
|
|
|
@@ -16,9 +16,10 @@ const MENU_MIN_HEIGHT = 38;
|
|
|
16
16
|
* @property {number} [splitNum=0] Optional split number for horizontal positioning; defines how many items per row
|
|
17
17
|
* @property {() => void} [openMethod] Optional method to call when the menu is opened
|
|
18
18
|
* @property {() => void} [closeMethod] Optional method to call when the menu is closed
|
|
19
|
-
* @property {() => boolean} [subEscMethod] Optional owner hook
|
|
20
|
-
* Return `true`
|
|
21
|
-
*
|
|
19
|
+
* @property {() => boolean} [subEscMethod] Optional owner hook that dismisses an owner-managed sub-panel (e.g. CommandMenu's dropdown-free flyout) and puts the cursor back on its row.
|
|
20
|
+
* Return `true` when a sub-panel was actually dismissed.
|
|
21
|
+
* @property {(index: number) => boolean} [subCheckMethod] Optional owner hook answering "does the row at `index` own a sub-panel?".
|
|
22
|
+
* - A query only — it must not open anything.
|
|
22
23
|
* @property {string} [maxHeight] Optional max-height CSS value (e.g. `"200px"`). Enables scrolling when items exceed this height.
|
|
23
24
|
* @property {string} [minWidth] Optional min-width CSS value (e.g. `"130px"`).
|
|
24
25
|
* @property {*} [keydownTarget] Optional override for the keyboard navigation target. By default `on()` listens
|
|
@@ -41,6 +42,7 @@ class SelectMenu {
|
|
|
41
42
|
#eventHandlers;
|
|
42
43
|
#globalEventHandlers;
|
|
43
44
|
|
|
45
|
+
#listInner = null;
|
|
44
46
|
#refer = null;
|
|
45
47
|
#keydownTarget = null;
|
|
46
48
|
#keydownTargetOverride = null;
|
|
@@ -86,6 +88,7 @@ class SelectMenu {
|
|
|
86
88
|
this.openMethod = params.openMethod;
|
|
87
89
|
this.closeMethod = params.closeMethod;
|
|
88
90
|
this.subEscMethod = params.subEscMethod || null;
|
|
91
|
+
this.subCheckMethod = params.subCheckMethod || null;
|
|
89
92
|
this.maxHeight = params.maxHeight || '';
|
|
90
93
|
this.minWidth = params.minWidth || '';
|
|
91
94
|
this.#keydownTargetOverride = params.keydownTarget || null;
|
|
@@ -135,7 +138,7 @@ class SelectMenu {
|
|
|
135
138
|
* );
|
|
136
139
|
*/
|
|
137
140
|
create(items, menus) {
|
|
138
|
-
this.
|
|
141
|
+
this.#listInner.innerHTML = '';
|
|
139
142
|
|
|
140
143
|
// remove existing submenu elements from form
|
|
141
144
|
for (const [, data] of this.#submenuData) {
|
|
@@ -228,6 +231,8 @@ class SelectMenu {
|
|
|
228
231
|
'<div class="se-list-inner"' + (innerStyle ? ' style="' + innerStyle + '"' : '') + '></div>',
|
|
229
232
|
);
|
|
230
233
|
|
|
234
|
+
this.#listInner = /** @type {HTMLElement} */ (this.form.firstElementChild);
|
|
235
|
+
|
|
231
236
|
referElement.parentNode.insertBefore(this.form, referElement);
|
|
232
237
|
}
|
|
233
238
|
|
|
@@ -247,7 +252,7 @@ class SelectMenu {
|
|
|
247
252
|
* selectMenu.open('', '[data-command="' + this.align + '"]');
|
|
248
253
|
*/
|
|
249
254
|
open(position, onItemQuerySelector) {
|
|
250
|
-
this.#$.ui.
|
|
255
|
+
this.#$.ui.setSelectMenuOpen(this, true);
|
|
251
256
|
|
|
252
257
|
this.openMethod?.();
|
|
253
258
|
|
|
@@ -298,10 +303,11 @@ class SelectMenu {
|
|
|
298
303
|
* @description Select menu close
|
|
299
304
|
*/
|
|
300
305
|
close() {
|
|
301
|
-
this.#$.ui.
|
|
306
|
+
this.#$.ui.setSelectMenuOpen(this, false);
|
|
302
307
|
dom.utils.removeClass(this.#refer, 'on');
|
|
303
308
|
this.#init();
|
|
304
309
|
this.form?.removeAttribute('style');
|
|
310
|
+
if (this.#listInner) this.#resetClamp();
|
|
305
311
|
this.isOpen = false;
|
|
306
312
|
|
|
307
313
|
this.closeMethod?.();
|
|
@@ -521,7 +527,7 @@ class SelectMenu {
|
|
|
521
527
|
* @param {string} html - The HTML string representing the menu items.
|
|
522
528
|
*/
|
|
523
529
|
#createFormat(html) {
|
|
524
|
-
this.
|
|
530
|
+
this.#listInner.innerHTML += `<ul class="se-list-basic se-list-checked${this.horizontal ? ' se-list-horizontal' : ''}">${html}</ul>`;
|
|
525
531
|
}
|
|
526
532
|
|
|
527
533
|
/**
|
|
@@ -551,6 +557,44 @@ class SelectMenu {
|
|
|
551
557
|
this.#selectItem(selectIndex);
|
|
552
558
|
}
|
|
553
559
|
|
|
560
|
+
/**
|
|
561
|
+
* @description Restores the list box to its `maxHeight` param, dropping any previous viewport clamp.
|
|
562
|
+
* - `#setPosition` may run twice (the `_re` retry), so the clamp must not accumulate.
|
|
563
|
+
*/
|
|
564
|
+
#resetClamp() {
|
|
565
|
+
this.form.style.height = '';
|
|
566
|
+
this.#listInner.style.maxHeight = this.maxHeight;
|
|
567
|
+
this.#listInner.style.overflowY = this.maxHeight ? 'auto' : '';
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* @description Clamps the menu to `h` when it doesn't fit the viewport.
|
|
572
|
+
* @param {number} h - Target form height in px.
|
|
573
|
+
*/
|
|
574
|
+
#clampHeight(h) {
|
|
575
|
+
const chrome = this.form.offsetHeight - this.#listInner.offsetHeight; // padding + border
|
|
576
|
+
this.form.style.height = h + 'px';
|
|
577
|
+
this.#listInner.style.maxHeight = (h - chrome > 0 ? h - chrome : 0) + 'px';
|
|
578
|
+
this.#listInner.style.overflowY = 'auto';
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* @description Scrolls the list so the given item is fully visible.
|
|
583
|
+
* @param {Element} item - The item element to reveal.
|
|
584
|
+
*/
|
|
585
|
+
#scrollToItem(item) {
|
|
586
|
+
if (!item) return;
|
|
587
|
+
|
|
588
|
+
const list = this.#listInner;
|
|
589
|
+
if (!list.style.maxHeight) return;
|
|
590
|
+
const top = list.getBoundingClientRect().top + list.clientTop;
|
|
591
|
+
const bottom = top + list.clientHeight;
|
|
592
|
+
const { top: itemTop, bottom: itemBottom } = item.getBoundingClientRect();
|
|
593
|
+
|
|
594
|
+
if (itemTop < top) list.scrollTop -= top - itemTop;
|
|
595
|
+
else if (itemBottom > bottom) list.scrollTop += itemBottom - bottom;
|
|
596
|
+
}
|
|
597
|
+
|
|
554
598
|
/**
|
|
555
599
|
* @description Highlights and selects an item by index.
|
|
556
600
|
* @param {number} selectIndex - The index of the item to select.
|
|
@@ -569,6 +613,7 @@ class SelectMenu {
|
|
|
569
613
|
|
|
570
614
|
this.index = selectIndex;
|
|
571
615
|
this.item = this.items[selectIndex];
|
|
616
|
+
this.#scrollToItem(this.menus[selectIndex]);
|
|
572
617
|
}
|
|
573
618
|
|
|
574
619
|
/**
|
|
@@ -584,7 +629,8 @@ class SelectMenu {
|
|
|
584
629
|
const target = this.#refer;
|
|
585
630
|
form.style.visibility = 'hidden';
|
|
586
631
|
form.style.display = 'block';
|
|
587
|
-
|
|
632
|
+
|
|
633
|
+
this.#resetClamp();
|
|
588
634
|
dom.utils.addClass(target, 'on');
|
|
589
635
|
|
|
590
636
|
const formW = form.offsetWidth;
|
|
@@ -635,7 +681,7 @@ class SelectMenu {
|
|
|
635
681
|
h += formT - 4;
|
|
636
682
|
t -= formT - 4;
|
|
637
683
|
}
|
|
638
|
-
|
|
684
|
+
this.#clampHeight(h);
|
|
639
685
|
break;
|
|
640
686
|
}
|
|
641
687
|
case 'top':
|
|
@@ -645,7 +691,7 @@ class SelectMenu {
|
|
|
645
691
|
break;
|
|
646
692
|
}
|
|
647
693
|
overH = targetGlobalTop - 4 + sideAddH;
|
|
648
|
-
if (overH >= MENU_MIN_HEIGHT)
|
|
694
|
+
if (overH >= MENU_MIN_HEIGHT) this.#clampHeight(overH);
|
|
649
695
|
}
|
|
650
696
|
t = targetOffsetTop - form.offsetHeight + sideAddH;
|
|
651
697
|
break;
|
|
@@ -656,7 +702,7 @@ class SelectMenu {
|
|
|
656
702
|
break;
|
|
657
703
|
}
|
|
658
704
|
overH = wbottom - 4 + sideAddH;
|
|
659
|
-
if (overH >= MENU_MIN_HEIGHT)
|
|
705
|
+
if (overH >= MENU_MIN_HEIGHT) this.#clampHeight(overH);
|
|
660
706
|
}
|
|
661
707
|
t = targetOffsetTop + (side ? 0 : targetHeight);
|
|
662
708
|
break;
|
|
@@ -708,7 +754,7 @@ class SelectMenu {
|
|
|
708
754
|
}
|
|
709
755
|
|
|
710
756
|
if (onItemQuerySelector) {
|
|
711
|
-
const item =
|
|
757
|
+
const item = this.#listInner.querySelector(onItemQuerySelector);
|
|
712
758
|
if (item) {
|
|
713
759
|
this._onItem = item;
|
|
714
760
|
dom.utils.addClass(item, 'se-select-on');
|
|
@@ -794,22 +840,18 @@ class SelectMenu {
|
|
|
794
840
|
this.#moveSubmenuItem(1);
|
|
795
841
|
return;
|
|
796
842
|
case 'ArrowLeft':
|
|
843
|
+
case 'ArrowRight': {
|
|
797
844
|
e.preventDefault();
|
|
798
845
|
e.stopPropagation();
|
|
799
|
-
//
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
dom.utils.removeClass(
|
|
806
|
-
subData.element.querySelectorAll('.se-select-item'),
|
|
807
|
-
'se-select-cursor',
|
|
808
|
-
);
|
|
809
|
-
}
|
|
846
|
+
// Close the submenu and put the cursor back on its trigger row — the same thing ESC
|
|
847
|
+
// does. Either direction closes, so the gesture reads the same in LTR and RTL and
|
|
848
|
+
// there is no writing-direction branch to keep in sync.
|
|
849
|
+
const parentIndex = this.#activeSubmenuIndex;
|
|
850
|
+
this.#closeSubmenu();
|
|
851
|
+
if (parentIndex > -1) this.#selectItem(parentIndex);
|
|
810
852
|
return;
|
|
853
|
+
}
|
|
811
854
|
case 'Enter':
|
|
812
|
-
case 'Space':
|
|
813
855
|
if (this.#submenuItemIndex > -1) {
|
|
814
856
|
e.preventDefault();
|
|
815
857
|
e.stopPropagation();
|
|
@@ -841,24 +883,40 @@ class SelectMenu {
|
|
|
841
883
|
moveIndex = 1;
|
|
842
884
|
}
|
|
843
885
|
break;
|
|
844
|
-
case 'ArrowLeft':
|
|
845
|
-
|
|
846
|
-
e.stopPropagation();
|
|
847
|
-
moveIndex = -1;
|
|
848
|
-
break;
|
|
849
|
-
case 'ArrowRight': // right — enter submenu if available
|
|
886
|
+
case 'ArrowLeft':
|
|
887
|
+
case 'ArrowRight':
|
|
850
888
|
e.preventDefault();
|
|
851
889
|
e.stopPropagation();
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
this.#
|
|
890
|
+
|
|
891
|
+
if (this.#activeSubmenuIndex > -1) {
|
|
892
|
+
const parentIndex = this.#activeSubmenuIndex;
|
|
893
|
+
this.#closeSubmenu();
|
|
894
|
+
this.#selectItem(parentIndex);
|
|
856
895
|
return;
|
|
857
896
|
}
|
|
858
|
-
|
|
897
|
+
|
|
898
|
+
if (this.subEscMethod?.()) return;
|
|
899
|
+
|
|
900
|
+
if (this.index > -1) {
|
|
901
|
+
if (this.#submenuData.has(this.index)) {
|
|
902
|
+
this.#openSubmenu(this.index);
|
|
903
|
+
this.#inSubmenu = true;
|
|
904
|
+
this.#moveSubmenuItem(1);
|
|
905
|
+
return;
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
if (this.subCheckMethod?.(this.index)) {
|
|
909
|
+
this.#select(this.index);
|
|
910
|
+
return;
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
if (!this.horizontal) return;
|
|
915
|
+
|
|
916
|
+
moveIndex = e.code === 'ArrowLeft' ? -1 : 1;
|
|
917
|
+
|
|
859
918
|
break;
|
|
860
919
|
case 'Enter':
|
|
861
|
-
case 'Space': // enter, space
|
|
862
920
|
if (this.index > -1) {
|
|
863
921
|
e.preventDefault();
|
|
864
922
|
e.stopPropagation();
|
|
@@ -875,6 +933,9 @@ class SelectMenu {
|
|
|
875
933
|
|
|
876
934
|
if (moveIndex) {
|
|
877
935
|
this.#closeSubmenu();
|
|
936
|
+
// Moving off a row must take its sub-panel with it, otherwise the flyout stays open while the
|
|
937
|
+
// cursor is somewhere else entirely.
|
|
938
|
+
this.subEscMethod?.();
|
|
878
939
|
this.#moveItem(moveIndex);
|
|
879
940
|
}
|
|
880
941
|
}
|
|
@@ -924,9 +924,24 @@ class Table extends PluginDropdownFree {
|
|
|
924
924
|
* @description Executes the selected action when the table picker is clicked.
|
|
925
925
|
*/
|
|
926
926
|
#OnClickTablePicker() {
|
|
927
|
+
this.insert(this.#tableXY[0], this.#tableXY[1]);
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
/**
|
|
931
|
+
* @description Insert a table of the given size at the caret and place the caret in its first cell.
|
|
932
|
+
* @param {number} [cols=3] - Column count
|
|
933
|
+
* @param {number} [rows=3] - Row count
|
|
934
|
+
* @returns {boolean} `true` when the table was inserted
|
|
935
|
+
* @example
|
|
936
|
+
* // insert a 3x3 table without going through the size picker
|
|
937
|
+
* editor.plugins.table.insert();
|
|
938
|
+
* editor.plugins.table.insert(4, 2);
|
|
939
|
+
*/
|
|
940
|
+
insert(cols, rows) {
|
|
941
|
+
const x = cols > 0 ? cols : Constants.DEFAULT_SIZE[0];
|
|
942
|
+
const y = rows > 0 ? rows : Constants.DEFAULT_SIZE[1];
|
|
943
|
+
|
|
927
944
|
const oTable = dom.utils.createElement('TABLE');
|
|
928
|
-
const x = this.#tableXY[0];
|
|
929
|
-
const y = this.#tableXY[1];
|
|
930
945
|
|
|
931
946
|
const body = `<tbody>${`<tr>${CreateCellsString('td', x)}</tr>`.repeat(y)}</tbody>`;
|
|
932
947
|
const colGroup = `<colgroup>${`<col style="width: ${numbers.get(100 / x, Constants.CELL_DECIMAL_END)}%;">`.repeat(x)}</colgroup>`;
|
|
@@ -945,12 +960,14 @@ class Table extends PluginDropdownFree {
|
|
|
945
960
|
figure.appendChild(oTable);
|
|
946
961
|
this.#maxWidth = true;
|
|
947
962
|
|
|
948
|
-
if (this.$.component.insert(figure, { insertBehavior: 'none' }))
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
963
|
+
if (!this.$.component.insert(figure, { insertBehavior: 'none' })) return false;
|
|
964
|
+
|
|
965
|
+
this.#resetTablePicker();
|
|
966
|
+
this.$.menu.dropdownOff();
|
|
967
|
+
const target = oTable.querySelector('td div');
|
|
968
|
+
this.$.selection.setRange(target, 0, target, 0);
|
|
969
|
+
|
|
970
|
+
return true;
|
|
954
971
|
}
|
|
955
972
|
|
|
956
973
|
/**
|
|
@@ -2,6 +2,8 @@ export const ROW_SELECT_MARGIN = 6;
|
|
|
2
2
|
export const CELL_SELECT_MARGIN = 6;
|
|
3
3
|
export const CELL_DECIMAL_END = 0;
|
|
4
4
|
|
|
5
|
+
export const DEFAULT_SIZE = [3, 3];
|
|
6
|
+
|
|
5
7
|
export const RESIZE_CELL_CLASS = '.se-table-resize-line';
|
|
6
8
|
export const RESIZE_CELL_PREV_CLASS = '.se-table-resize-line-prev';
|
|
7
9
|
export const RESIZE_ROW_CLASS = '.se-table-resize-row';
|
|
@@ -31,7 +31,10 @@ const { debounce } = converter;
|
|
|
31
31
|
* (plugin names, built-in commands like `'bold'`); objects are custom items with their own `action`.
|
|
32
32
|
* Required.
|
|
33
33
|
* @property {number} [delayTime=120] - Debounce delay (ms) before the input is inspected for the trigger.
|
|
34
|
-
* @property {number} [limitSize=
|
|
34
|
+
* @property {number} [limitSize=0] - Maximum number of items kept after filtering. `0` (default) keeps every match
|
|
35
|
+
* - the list scrolls within `maxHeight`, so a cap only hides matches the user can no longer reach.
|
|
36
|
+
* @property {string} [maxHeight='320px'] - Max height of the menu list. Any CSS length; the list scrolls past it.
|
|
37
|
+
* @property {string} [minWidth='200px'] - Min width of the menu.
|
|
35
38
|
* @property {string} [emptyMessage] - Message shown when no items match the query. If unset, the menu closes on no match.
|
|
36
39
|
* @property {function(SlashCommandItem, { icons: Object }): string} [renderItem] - Custom item HTML renderer.
|
|
37
40
|
* Applied only to custom item objects; plugin-name entries always render with the canonical BlockHandle row.
|
|
@@ -64,6 +67,15 @@ const { debounce } = converter;
|
|
|
64
67
|
* // A container block (BLOCKQUOTE, DIV…): `applyBlock` WRAPS the selected lines → `<blockquote>…</blockquote>`.
|
|
65
68
|
* action: ($) => $.format.applyBlock(document.createElement('BLOCKQUOTE')),
|
|
66
69
|
* },
|
|
70
|
+
* {
|
|
71
|
+
* key: 'table',
|
|
72
|
+
* title: 'Table',
|
|
73
|
+
* icon: 'table',
|
|
74
|
+
* // A plugin-name entry (`'table'`) opens that plugin's own UI — for table, the size picker,
|
|
75
|
+
* // which is driven by the pointer. A custom item skips it and inserts straight away, which
|
|
76
|
+
* // keeps the whole gesture on the keyboard: type the trigger, press Enter, done.
|
|
77
|
+
* action: ($) => $.plugins.table.insert(3, 3),
|
|
78
|
+
* },
|
|
67
79
|
* 'bold',
|
|
68
80
|
* 'image',
|
|
69
81
|
* 'blockStyle',
|
|
@@ -111,7 +123,9 @@ class SlashCommand extends PluginField {
|
|
|
111
123
|
? pluginOptions.triggerChar
|
|
112
124
|
: '/';
|
|
113
125
|
this.#limitSize =
|
|
114
|
-
typeof pluginOptions.limitSize === 'number' && pluginOptions.limitSize > 0
|
|
126
|
+
typeof pluginOptions.limitSize === 'number' && pluginOptions.limitSize > 0
|
|
127
|
+
? pluginOptions.limitSize
|
|
128
|
+
: Infinity;
|
|
115
129
|
this.#emptyMessage = typeof pluginOptions.emptyMessage === 'string' ? pluginOptions.emptyMessage : '';
|
|
116
130
|
const delayTime = typeof pluginOptions.delayTime === 'number' ? pluginOptions.delayTime : 120;
|
|
117
131
|
|
|
@@ -119,11 +133,12 @@ class SlashCommand extends PluginField {
|
|
|
119
133
|
items: Array.isArray(pluginOptions.items) ? pluginOptions.items : [],
|
|
120
134
|
resolveButton: ResolveButton,
|
|
121
135
|
renderCustomItem: typeof pluginOptions.renderItem === 'function' ? pluginOptions.renderItem : null,
|
|
136
|
+
prepareCommit: () => this.#removeTrigger(),
|
|
122
137
|
selectMenuParams: {
|
|
123
138
|
position: 'bottom-left',
|
|
124
139
|
dir: 'ltr',
|
|
125
|
-
minWidth: '200px',
|
|
126
|
-
maxHeight: '320px',
|
|
140
|
+
minWidth: typeof pluginOptions.minWidth === 'string' ? pluginOptions.minWidth : '200px',
|
|
141
|
+
maxHeight: typeof pluginOptions.maxHeight === 'string' ? pluginOptions.maxHeight : '320px',
|
|
127
142
|
closeMethod: () => this.#onMenuClose(),
|
|
128
143
|
},
|
|
129
144
|
});
|
|
@@ -219,6 +234,32 @@ class SlashCommand extends PluginField {
|
|
|
219
234
|
this.#cacheAnchor(anchorNode, lastPos, anchorOffset);
|
|
220
235
|
}
|
|
221
236
|
|
|
237
|
+
/**
|
|
238
|
+
* @description Open the command menu programmatically, with no trigger character typed and the full tem list shown.
|
|
239
|
+
* - Intended for host UI that wants the same menu without the `/` shortcut — e.g. the
|
|
240
|
+
* - block handle's plus button:
|
|
241
|
+
* ```js
|
|
242
|
+
* blockHandle: { onPlusClick: ($, { block }) => $.plugins.slashCommand.open(block) }
|
|
243
|
+
* ```
|
|
244
|
+
* @param {Node} anchorNode - Node the menu anchors to (typically the line the caret sits on).
|
|
245
|
+
* @returns {boolean} `true` if the menu was opened
|
|
246
|
+
*/
|
|
247
|
+
open(anchorNode) {
|
|
248
|
+
if (!anchorNode) return false;
|
|
249
|
+
|
|
250
|
+
const filtered = this.#menu.filter('', this.#limitSize);
|
|
251
|
+
if (filtered.length === 0) return false;
|
|
252
|
+
|
|
253
|
+
this.controller.open(anchorNode, null, { isWWTarget: true, initMethod: null, addOffset: null });
|
|
254
|
+
this.#menu.createRows(filtered);
|
|
255
|
+
this.#menu.open();
|
|
256
|
+
this.#menu.setItem(0);
|
|
257
|
+
|
|
258
|
+
this.#cacheAnchor(anchorNode, 0, 0);
|
|
259
|
+
|
|
260
|
+
return true;
|
|
261
|
+
}
|
|
262
|
+
|
|
222
263
|
/**
|
|
223
264
|
* @description Close the menu from the plugin itself (invalid query, or after a selection). Flags
|
|
224
265
|
* the close as internal so `#onMenuClose` does not treat it as a user dismiss.
|
|
@@ -289,17 +330,23 @@ class SlashCommand extends PluginField {
|
|
|
289
330
|
if (!anchorNode) return false;
|
|
290
331
|
|
|
291
332
|
const triggerChar = this.#triggerChar;
|
|
292
|
-
const query = anchorNode.textContent.substring(
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
this.$.selection.setRange(anchorNode, this.#lastTriggerPos, anchorNode, this.#anchorOffset);
|
|
297
|
-
const range = this.$.selection.getRange();
|
|
298
|
-
if (range && !range.collapsed) this.$.html.remove();
|
|
333
|
+
const query = (anchorNode.textContent || '').substring(
|
|
334
|
+
this.#lastTriggerPos + triggerChar.length,
|
|
335
|
+
this.#anchorOffset,
|
|
336
|
+
);
|
|
299
337
|
|
|
300
|
-
|
|
338
|
+
if (item.kind !== 'dropdownFree') {
|
|
339
|
+
this.#removeTrigger();
|
|
340
|
+
this.#closeMenu();
|
|
341
|
+
this.#menu.dispatch(item, { triggerChar, query, item: item.raw });
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
301
344
|
|
|
345
|
+
// `dispatch` toggles, so picking the row whose flyout is already up (hovered, then picked) closes
|
|
346
|
+
// it and keeps the menu — as BlockHandle does. Only a flyout that never opened leaves nothing to show.
|
|
347
|
+
const hadSubPanel = this.#menu.hasOpenSubPanel();
|
|
302
348
|
this.#menu.dispatch(item, { triggerChar, query, item: item.raw });
|
|
349
|
+
if (!hadSubPanel && !this.#menu.hasOpenSubPanel()) this.#closeMenu();
|
|
303
350
|
}
|
|
304
351
|
}
|
|
305
352
|
|