suneditor 3.0.6 → 3.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/dist/suneditor.min.css +1 -1
  2. package/dist/suneditor.min.js +1 -1
  3. package/package.json +1 -1
  4. package/src/assets/suneditor.css +2 -2
  5. package/src/core/editor.js +20 -3
  6. package/src/core/event/eventOrchestrator.js +2 -1
  7. package/src/core/event/handlers/handler_ww_key.js +2 -2
  8. package/src/core/event/rules/keydown.rule.enter.js +2 -2
  9. package/src/core/logic/dom/format.js +5 -1
  10. package/src/core/logic/dom/html.js +23 -1
  11. package/src/core/logic/dom/offset.js +24 -1
  12. package/src/core/logic/panel/menu.js +74 -3
  13. package/src/core/logic/panel/viewer.js +6 -4
  14. package/src/core/logic/shell/shortcuts.js +1 -1
  15. package/src/core/schema/options.js +1 -1
  16. package/src/core/section/constructor.js +2 -2
  17. package/src/helper/index.js +3 -0
  18. package/src/helper/msOffice.js +849 -0
  19. package/src/interfaces/plugins.js +1 -1
  20. package/src/langs/ckb.js +1 -0
  21. package/src/langs/cs.js +1 -0
  22. package/src/langs/da.js +1 -0
  23. package/src/langs/de.js +1 -0
  24. package/src/langs/en.js +1 -1
  25. package/src/langs/es.js +1 -0
  26. package/src/langs/fa.js +1 -0
  27. package/src/langs/fr.js +1 -0
  28. package/src/langs/he.js +1 -0
  29. package/src/langs/hu.js +1 -0
  30. package/src/langs/it.js +1 -0
  31. package/src/langs/ja.js +1 -0
  32. package/src/langs/km.js +1 -0
  33. package/src/langs/ko.js +1 -0
  34. package/src/langs/lv.js +1 -0
  35. package/src/langs/nl.js +1 -0
  36. package/src/langs/pl.js +1 -0
  37. package/src/langs/pt_br.js +1 -0
  38. package/src/langs/ro.js +1 -0
  39. package/src/langs/ru.js +1 -0
  40. package/src/langs/se.js +1 -0
  41. package/src/langs/tr.js +1 -0
  42. package/src/langs/uk.js +1 -0
  43. package/src/langs/ur.js +1 -0
  44. package/src/langs/zh_cn.js +1 -0
  45. package/src/modules/contract/Browser.js +1 -0
  46. package/src/plugins/dropdown/layout.js +1 -1
  47. package/src/plugins/dropdown/template.js +2 -1
  48. package/src/plugins/field/autocomplete.js +383 -0
  49. package/src/plugins/index.js +3 -3
  50. package/src/typedef.js +1 -1
  51. package/types/core/logic/shell/shortcuts.d.ts +2 -2
  52. package/types/core/schema/options.d.ts +2 -2
  53. package/types/helper/index.d.ts +4 -0
  54. package/types/helper/msOffice.d.ts +11 -0
  55. package/types/interfaces/plugins.d.ts +1 -1
  56. package/types/langs/_Lang.d.ts +1 -2
  57. package/types/plugins/field/autocomplete.d.ts +251 -0
  58. package/types/plugins/index.d.ts +3 -3
  59. package/types/typedef.d.ts +1 -1
  60. package/src/plugins/field/mention.js +0 -251
  61. package/types/plugins/field/mention.d.ts +0 -104
@@ -171,7 +171,7 @@ export class PluginDropdownFree extends Base {
171
171
  * These plugins typically respond to input events in the wysiwyg area
172
172
  *
173
173
  * **Commonly used hooks:**
174
- * - `onInput()` - Responds to input events in the editor (See: `mention` plugin)
174
+ * - `onInput()` - Responds to input events in the editor (See: `autocomplete` plugin)
175
175
  * - Other event hooks can be used as needed (`onKeydown`, `onClick`, etc.)
176
176
  *
177
177
  * Child classes MAY optionally implement event hook methods
package/src/langs/ckb.js CHANGED
@@ -134,6 +134,7 @@
134
134
  math_modal_title: 'بیركاری',
135
135
  maxSize: 'گه‌وره‌ترین قه‌باره‌',
136
136
  mediaGallery: 'گەلەری میدیا',
137
+ autocomplete: 'تەواوکردنی ئۆتۆماتیکی',
137
138
  mention: 'تنويه ب',
138
139
  menu_bordered: 'لێواری هه‌بێت',
139
140
  menu_code: 'كۆد',
package/src/langs/cs.js CHANGED
@@ -134,6 +134,7 @@
134
134
  math_modal_title: 'Matematika',
135
135
  maxSize: 'Max. velikost',
136
136
  mediaGallery: 'Galerie médií',
137
+ autocomplete: 'Automatické doplňování',
137
138
  mention: 'Zmínka',
138
139
  menu_bordered: 'Ohraničené',
139
140
  menu_code: 'Kód',
package/src/langs/da.js CHANGED
@@ -134,6 +134,7 @@
134
134
  math_modal_title: 'Math',
135
135
  maxSize: 'Max størrelse',
136
136
  mediaGallery: 'Mediegalleri',
137
+ autocomplete: 'Autofuldførelse',
137
138
  mention: 'Nævne',
138
139
  menu_bordered: 'Afgrænsningslinje',
139
140
  menu_code: 'Code',
package/src/langs/de.js CHANGED
@@ -134,6 +134,7 @@
134
134
  math_modal_title: 'Mathematik',
135
135
  maxSize: 'Maximale Größe',
136
136
  mediaGallery: 'Mediengalerie',
137
+ autocomplete: 'Autovervollständigung',
137
138
  mention: 'Erwähnen',
138
139
  menu_bordered: 'Umrandet',
139
140
  menu_code: 'Quellcode',
package/src/langs/en.js CHANGED
@@ -133,7 +133,7 @@
133
133
  math_modal_title: 'Math',
134
134
  maxSize: 'Max size',
135
135
  mediaGallery: 'Media gallery',
136
- mention: 'Mention',
136
+ autocomplete: 'Autocomplete',
137
137
  menu_bordered: 'Bordered',
138
138
  menu_code: 'Code',
139
139
  menu_neon: 'Neon',
package/src/langs/es.js CHANGED
@@ -134,6 +134,7 @@
134
134
  math_modal_title: 'Matemáticas',
135
135
  maxSize: 'Tamaño máximo',
136
136
  mediaGallery: 'Galería de medios',
137
+ autocomplete: 'Autocompletar',
137
138
  mention: 'Mencionar',
138
139
  menu_bordered: 'Bordeado',
139
140
  menu_code: 'Código',
package/src/langs/fa.js CHANGED
@@ -134,6 +134,7 @@
134
134
  math_modal_title: 'فرمول ریاضی',
135
135
  maxSize: 'حداکثر اندازه',
136
136
  mediaGallery: 'گالری رسانه',
137
+ autocomplete: 'تکمیل خودکار',
137
138
  mention: 'ذکر کردن',
138
139
  menu_bordered: 'لبه‌دار',
139
140
  menu_code: 'کُد',
package/src/langs/fr.js CHANGED
@@ -134,6 +134,7 @@
134
134
  math_modal_title: 'Math',
135
135
  maxSize: 'Taille max',
136
136
  mediaGallery: 'Galerie multimédia',
137
+ autocomplete: 'Saisie semi-automatique',
137
138
  mention: 'Mention',
138
139
  menu_bordered: 'Ligne de démarcation',
139
140
  menu_code: 'Code',
package/src/langs/he.js CHANGED
@@ -134,6 +134,7 @@
134
134
  math_modal_title: 'נוסחה',
135
135
  maxSize: 'גודל מרבי',
136
136
  mediaGallery: 'גלריית מדיה',
137
+ autocomplete: 'השלמה אוטומטית',
137
138
  mention: 'הזכר',
138
139
  menu_bordered: 'בעל מיתאר',
139
140
  menu_code: 'קוד',
package/src/langs/hu.js CHANGED
@@ -134,6 +134,7 @@
134
134
  math_modal_title: 'Matematika',
135
135
  maxSize: 'Maximális méret',
136
136
  mediaGallery: 'Média galéria',
137
+ autocomplete: 'Automatikus kiegészítés',
137
138
  mention: 'Említés',
138
139
  menu_bordered: 'Keretezett',
139
140
  menu_code: 'Kód',
package/src/langs/it.js CHANGED
@@ -134,6 +134,7 @@
134
134
  math_modal_title: 'Matematica',
135
135
  maxSize: 'Dimensione massima',
136
136
  mediaGallery: 'Galleria multimediale',
137
+ autocomplete: 'Completamento automatico',
137
138
  mention: 'Menzione',
138
139
  menu_bordered: 'Bordato',
139
140
  menu_code: 'Codice',
package/src/langs/ja.js CHANGED
@@ -133,6 +133,7 @@
133
133
  math_modal_title: '数学',
134
134
  maxSize: '最大サイズ',
135
135
  mediaGallery: 'メディア ギャラリー',
136
+ autocomplete: 'オートコンプリート',
136
137
  mention: '言及する',
137
138
  menu_bordered: '境界線',
138
139
  menu_code: 'コード',
package/src/langs/km.js CHANGED
@@ -133,6 +133,7 @@
133
133
  math_modal_title: 'គណិត',
134
134
  maxSize: 'ទំហំធំ',
135
135
  mediaGallery: 'វិចិត្រសាលមេឌៀ',
136
+ autocomplete: 'បំពេញដោយស្វ័យប្រវត្តិ',
136
137
  mention: 'លើកឡើង',
137
138
  menu_bordered: 'មានស៊ុម',
138
139
  menu_code: 'កូដ',
package/src/langs/ko.js CHANGED
@@ -133,6 +133,7 @@
133
133
  math_modal_title: '수식',
134
134
  maxSize: '최대화',
135
135
  mediaGallery: '미디어 갤러리',
136
+ autocomplete: '자동 완성',
136
137
  mention: '멘션',
137
138
  menu_bordered: '경계선',
138
139
  menu_code: '코드',
package/src/langs/lv.js CHANGED
@@ -134,6 +134,7 @@
134
134
  math_modal_title: 'Matemātika',
135
135
  maxSize: 'Maksimālais izmērs',
136
136
  mediaGallery: 'Mediju galerija',
137
+ autocomplete: 'Automātiskā pabeigšana',
137
138
  mention: 'Pieminēt',
138
139
  menu_bordered: 'Robežojās',
139
140
  menu_code: 'Kods',
package/src/langs/nl.js CHANGED
@@ -134,6 +134,7 @@
134
134
  math_modal_title: 'Wiskunde',
135
135
  maxSize: 'Maximale grootte',
136
136
  mediaGallery: 'Mediagalerij',
137
+ autocomplete: 'Automatisch aanvullen',
137
138
  mention: 'Vermelding',
138
139
  menu_bordered: 'Omlijnd',
139
140
  menu_code: 'Code',
package/src/langs/pl.js CHANGED
@@ -134,6 +134,7 @@
134
134
  math_modal_title: 'Matematyczne',
135
135
  maxSize: 'Maksymalny rozmiar',
136
136
  mediaGallery: 'Galeria multimediów',
137
+ autocomplete: 'Automatyczne uzupełnianie',
137
138
  mention: 'Wzmianka',
138
139
  menu_bordered: 'Z obwódką',
139
140
  menu_code: 'Kod',
@@ -134,6 +134,7 @@
134
134
  math_modal_title: 'Matemática',
135
135
  maxSize: 'Tam máx',
136
136
  mediaGallery: 'Galeria de mídia',
137
+ autocomplete: 'Preenchimento automático',
137
138
  mention: 'Menção',
138
139
  menu_bordered: 'Com borda',
139
140
  menu_code: 'Código',
package/src/langs/ro.js CHANGED
@@ -134,6 +134,7 @@
134
134
  math_modal_title: 'Matematică',
135
135
  maxSize: 'Dimensiune maximă',
136
136
  mediaGallery: 'Galeria media',
137
+ autocomplete: 'Completare automată',
137
138
  mention: 'Mentiune',
138
139
  menu_bordered: 'Mărginit',
139
140
  menu_code: 'Citat',
package/src/langs/ru.js CHANGED
@@ -134,6 +134,7 @@
134
134
  math_modal_title: 'математический',
135
135
  maxSize: 'Ширина по размеру страницы',
136
136
  mediaGallery: 'Галерея мультимедиа',
137
+ autocomplete: 'Автозаполнение',
137
138
  mention: 'Упоминание',
138
139
  menu_bordered: 'Граничная Линия',
139
140
  menu_code: 'Код',
package/src/langs/se.js CHANGED
@@ -134,6 +134,7 @@
134
134
  math_modal_title: 'Math',
135
135
  maxSize: 'Maxstorlek',
136
136
  mediaGallery: 'Rel attribut',
137
+ autocomplete: 'Autoslutför',
137
138
  mention: 'Namn',
138
139
  menu_bordered: 'Avgränsningslinje',
139
140
  menu_code: 'Kod',
package/src/langs/tr.js CHANGED
@@ -134,6 +134,7 @@
134
134
  math_modal_title: 'Matematik',
135
135
  maxSize: 'En Büyük Boyut',
136
136
  mediaGallery: 'Medya galerisi',
137
+ autocomplete: 'Otomatik Tamamlama',
137
138
  mention: 'Belirtmek',
138
139
  menu_bordered: 'Çerçeveli',
139
140
  menu_code: 'Kod',
package/src/langs/uk.js CHANGED
@@ -134,6 +134,7 @@
134
134
  math_modal_title: 'Формула',
135
135
  maxSize: 'Ширина за розміром сторінки',
136
136
  mediaGallery: 'Медіа галерея',
137
+ autocomplete: 'Автозаповнення',
137
138
  mention: 'Згадати',
138
139
  menu_bordered: 'З лініями',
139
140
  menu_code: 'Код',
package/src/langs/ur.js CHANGED
@@ -134,6 +134,7 @@
134
134
  math_modal_title: 'ریاضی',
135
135
  maxSize: 'زیادہ سے زیادہ سائز',
136
136
  mediaGallery: 'میڈیا گیلری',
137
+ autocomplete: 'خود بخود مکمل',
137
138
  mention: 'تذکرہ',
138
139
  menu_bordered: 'سرحدی',
139
140
  menu_code: 'کوڈ',
@@ -134,6 +134,7 @@
134
134
  math_modal_title: '数学',
135
135
  maxSize: '最大尺寸',
136
136
  mediaGallery: '媒体库',
137
+ autocomplete: '自动完成',
137
138
  mention: '提到',
138
139
  menu_bordered: '边界线',
139
140
  menu_code: '代码',
@@ -595,6 +595,7 @@ class Browser {
595
595
  dom.utils.removeClass(this.side.querySelectorAll('.active'), 'active');
596
596
  dom.utils.addClass([cmdTarget, dom.query.getParentElement(cmdTarget, '.se-menu-folder')], 'active');
597
597
  this.tagArea.innerHTML = '';
598
+ this.selectedTags = [];
598
599
 
599
600
  if (typeof data === 'string') {
600
601
  this.#drawFileList(data, this.urlHeader, true);
@@ -75,7 +75,7 @@ function CreateHTML(layoutList) {
75
75
  t = layoutList[i];
76
76
  list += /*html*/ `
77
77
  <li>
78
- <button type="button" class="se-btn se-btn-list" data-value="${i}" title="${t.name}" aria-label="${t.name}">
78
+ <button type="button" class="se-btn se-btn-list" data-command="layout" data-value="${i}" title="${t.name}" aria-label="${t.name}">
79
79
  ${t.name}
80
80
  </button>
81
81
  </li>`;
@@ -74,7 +74,8 @@ function CreateHTML(templateList) {
74
74
  <li>
75
75
  <button
76
76
  type="button"
77
- class="se-btn se-btn-list"
77
+ class="se-btn se-btn-list"
78
+ data-command="template"
78
79
  data-value="${i}"
79
80
  title="${t.name}"
80
81
  aria-label="${t.name}"
@@ -0,0 +1,383 @@
1
+ import { PluginField } from '../../interfaces';
2
+ import { Controller } from '../../modules/contract';
3
+ import { ApiManager } from '../../modules/manager';
4
+ import { SelectMenu } from '../../modules/ui';
5
+ import { dom, converter } from '../../helper';
6
+
7
+ const { debounce } = converter;
8
+
9
+ /**
10
+ * @description Default render function for dropdown items.
11
+ * @param {{key: string, name?: string}} item - The data item.
12
+ * @returns {string} HTML string for the dropdown item.
13
+ */
14
+ function defaultRenderItem(item) {
15
+ return `<div class="se-autocomplete-item"><span>${item.key}</span>${item.name ? `<span>${item.name}</span>` : ''}</div>`;
16
+ }
17
+
18
+ /**
19
+ * @description Default select handler. Creates a span element with the trigger text + key.
20
+ * @param {{key: string}} item - The selected data item.
21
+ * @param {string} triggerText - The trigger character.
22
+ * @returns {{tag: string, attrs: Object, text: string}} Descriptor for element creation.
23
+ */
24
+ function defaultOnSelect(item, triggerText) {
25
+ return {
26
+ tag: 'span',
27
+ attrs: { 'data-se-autocomplete': triggerText + item.key },
28
+ text: triggerText + item.key,
29
+ };
30
+ }
31
+
32
+ /**
33
+ * @typedef {Object} AutocompleteTriggerConfig
34
+ * @property {Array<{key: string, [x: string]: any}>} [data] - Static data array. Each item must have a `key` field. Mutually exclusive with `apiUrl`.
35
+ * ```js
36
+ * // data
37
+ * [{ key: 'john', name: 'John Doe', url: '/users/john' }]
38
+ * ```
39
+ * @property {string} [apiUrl] - API endpoint URL. Supports `{key}` and `{limitSize}` placeholders. Mutually exclusive with `data`.
40
+ * @property {Object<string, string>} [apiHeaders] - HTTP headers for the API request.
41
+ * @property {function(Object, XMLHttpRequest): Array<{key: string}>} [transformResponse] - Transforms parsed JSON response into an array of data items.
42
+ * @property {number} [limitSize] - Override global `limitSize` for this trigger.
43
+ * @property {number} [searchStartLength] - Override global `searchStartLength` for this trigger.
44
+ * @property {boolean} [useCachingData] - Override global `useCachingData` for this trigger.
45
+ * @property {boolean} [useCachingFieldData] - Override global `useCachingFieldData` for this trigger.
46
+ * @property {function({key: string, [x: string]: any}, string): string} [renderItem] - Custom dropdown item renderer. Receives `(item, triggerText)`, returns HTML string.
47
+ * @property {function({key: string, [x: string]: any}, string): (string|Element|{tag: string, attrs?: Object, text?: string})} [onSelect] - Custom selection handler. Returns:
48
+ * - `string`: inserted as text node
49
+ * - `Element`: inserted as-is
50
+ * - `{tag, attrs, text}`: creates element via `dom.utils.createElement`
51
+ */
52
+
53
+ /**
54
+ * @typedef {Object} AutocompletePluginOptions
55
+ * @property {number} [delayTime=120] - Debounce delay in ms before processing input.
56
+ * @property {number} [limitSize=5] - Maximum number of items to display in the dropdown.
57
+ * @property {number} [searchStartLength=0] - Minimum input length before triggering search.
58
+ * @property {boolean} [useCachingData=true] - Whether to cache query responses per trigger.
59
+ * @property {boolean} [useCachingFieldData=true] - Whether to cache selected items for priority display.
60
+ * @property {Object<string, AutocompleteTriggerConfig>} triggers - Per-trigger configurations keyed by trigger character.
61
+ * ```js
62
+ * // Basic usage with static data — mention trigger
63
+ * const editor = SUNEDITOR.create('#editor', {
64
+ * plugins: [autocomplete],
65
+ * autocomplete: {
66
+ * triggers: {
67
+ * '@': {
68
+ * data: [
69
+ * { key: 'john', name: 'John Doe' },
70
+ * { key: 'jane', name: 'Jane Smith' },
71
+ * ],
72
+ * },
73
+ * },
74
+ * },
75
+ * });
76
+ *
77
+ * // API-based trigger with custom rendering and selection
78
+ * const editor = SUNEDITOR.create('#editor', {
79
+ * plugins: [autocomplete],
80
+ * autocomplete: {
81
+ * delayTime: 200,
82
+ * limitSize: 10,
83
+ * triggers: {
84
+ * '@': {
85
+ * apiUrl: '/api/users?q={key}&limit={limitSize}',
86
+ * apiHeaders: { Authorization: 'Bearer TOKEN' },
87
+ * transformResponse: (json) => json.data.map((u) => ({ key: u.username, name: u.displayName, id: u.id })),
88
+ * renderItem: (item) => `<div class="user-item"><strong>${item.key}</strong> <span>${item.name}</span></div>`,
89
+ * onSelect: (item, trigger) => ({
90
+ * tag: 'a',
91
+ * attrs: { href: `/users/${item.id}`, 'data-se-autocomplete': trigger + item.key },
92
+ * text: trigger + item.key,
93
+ * }),
94
+ * },
95
+ * '#': {
96
+ * apiUrl: '/api/tags?q={key}',
97
+ * transformResponse: (json) => json.tags,
98
+ * searchStartLength: 2,
99
+ * useCachingData: false,
100
+ * },
101
+ * },
102
+ * },
103
+ * });
104
+ * ```
105
+ */
106
+
107
+ /**
108
+ * @class
109
+ * @description Autocomplete Plugin
110
+ * - A generic autocomplete plugin supporting multiple trigger characters.
111
+ * - Each trigger can have its own data source, rendering, and selection behavior.
112
+ * - Supports static data arrays and API-based data fetching.
113
+ * - Uses per-trigger caching for optimized performance.
114
+ */
115
+ class Autocomplete extends PluginField {
116
+ static key = 'autocomplete';
117
+ static className = '';
118
+
119
+ #lastTriggerPos = 0;
120
+ #anchorOffset = 0;
121
+ #anchorNode = null;
122
+ #activeTrigger = null;
123
+
124
+ /**
125
+ * @constructor
126
+ * @param {SunEditor.Kernel} kernel - The Kernel instance
127
+ * @param {AutocompletePluginOptions} pluginOptions
128
+ */
129
+ constructor(kernel, pluginOptions) {
130
+ super(kernel);
131
+ this.title = this.$.lang.autocomplete;
132
+ this.icon = 'autocomplete';
133
+
134
+ // global defaults
135
+ const limitSize = pluginOptions.limitSize || 5;
136
+ const searchStartLength = pluginOptions.searchStartLength || 0;
137
+ const delayTime = typeof pluginOptions.delayTime === 'number' ? pluginOptions.delayTime : 120;
138
+ const useCachingData = pluginOptions.useCachingData ?? true;
139
+ const useCachingFieldData = pluginOptions.useCachingFieldData ?? true;
140
+
141
+ // build trigger contexts
142
+ this.triggerContexts = new Map();
143
+ const triggers = pluginOptions.triggers || {};
144
+ for (const [triggerChar, config] of Object.entries(triggers)) {
145
+ const triggerLimit = config.limitSize ?? limitSize;
146
+ this.triggerContexts.set(triggerChar, {
147
+ trigger: triggerChar,
148
+ limitSize: triggerLimit,
149
+ searchStartLength: config.searchStartLength ?? searchStartLength,
150
+ directData: config.data || null,
151
+ apiUrl: config.apiUrl?.replace(/\s/g, '').replace(/\{limitSize\}/i, String(triggerLimit)) || '',
152
+ apiHeaders: config.apiHeaders || null,
153
+ transformResponse: config.transformResponse || null,
154
+ renderItem: config.renderItem || defaultRenderItem,
155
+ onSelect: config.onSelect || defaultOnSelect,
156
+ apiManager: config.apiUrl ? new ApiManager(this, this.$, { headers: config.apiHeaders }) : null,
157
+ cachingData: (config.useCachingData ?? useCachingData) ? new Map() : null,
158
+ cachingFieldData: (config.useCachingFieldData ?? useCachingFieldData) ? [] : null,
159
+ });
160
+ }
161
+
162
+ // sort triggers by length descending (longest match first)
163
+ this.sortedTriggers = [...this.triggerContexts.keys()].sort((a, b) => b.length - a.length);
164
+
165
+ // controller
166
+ const controllerEl = CreateHTML_controller();
167
+ this.selectMenu = new SelectMenu(this.$, { position: 'right-bottom', dir: 'ltr', closeMethod: () => this.controller.close() });
168
+ this.controller = new Controller(
169
+ this,
170
+ this.$,
171
+ controllerEl,
172
+ {
173
+ position: 'bottom',
174
+ initMethod: () => {
175
+ this.#cancelActiveApi();
176
+ this.selectMenu.close();
177
+ },
178
+ },
179
+ null,
180
+ );
181
+ this.selectMenu.on(controllerEl.firstElementChild, this.#onSelectItem.bind(this));
182
+
183
+ // onInput debounce
184
+ this.onInput = debounce(this.onInput.bind(this), delayTime);
185
+ }
186
+
187
+ /**
188
+ * @description Cancels the active trigger's in-flight API request.
189
+ */
190
+ #cancelActiveApi() {
191
+ if (this.#activeTrigger?.apiManager) {
192
+ this.#activeTrigger.apiManager.cancel();
193
+ }
194
+ }
195
+
196
+ /**
197
+ * @hook Editor.EventManager
198
+ * @type {SunEditor.Hook.Event.OnInputAsync}
199
+ */
200
+ async onInput() {
201
+ this.#cancelActiveApi();
202
+
203
+ const sel = this.$.selection.get();
204
+ if (!sel.rangeCount) {
205
+ this.selectMenu.close();
206
+ return;
207
+ }
208
+
209
+ const anchorNode = sel.anchorNode;
210
+ const anchorOffset = sel.anchorOffset;
211
+ const textBeforeCursor = anchorNode.textContent.substring(0, anchorOffset);
212
+
213
+ // find matching trigger (longest first)
214
+ for (const trigger of this.sortedTriggers) {
215
+ const lastPos = textBeforeCursor.lastIndexOf(trigger);
216
+ if (lastPos === -1) continue;
217
+
218
+ const query = textBeforeCursor.substring(lastPos + trigger.length, anchorOffset);
219
+ const beforeText = textBeforeCursor[lastPos - 1]?.trim();
220
+
221
+ if (!/\s/.test(query) && (!beforeText || dom.check.isZeroWidth(beforeText))) {
222
+ const ctx = this.triggerContexts.get(trigger);
223
+ if (query.length < ctx.searchStartLength) return;
224
+
225
+ const anchorParent = anchorNode.parentNode;
226
+ if (dom.check.isAnchor(anchorParent) && !anchorParent.getAttribute('data-se-autocomplete')) {
227
+ return;
228
+ }
229
+
230
+ try {
231
+ this.#activeTrigger = ctx;
232
+ await this.#createList(ctx, query, anchorNode);
233
+ this.#lastTriggerPos = lastPos;
234
+ this.#anchorNode = anchorNode;
235
+ this.#anchorOffset = anchorOffset;
236
+ return;
237
+ } catch (error) {
238
+ console.warn('[SUNEDITOR.autocomplete.api] ', error);
239
+ }
240
+ }
241
+
242
+ continue;
243
+ }
244
+
245
+ this.selectMenu.close();
246
+ }
247
+
248
+ /**
249
+ * @description Generates the autocomplete dropdown list.
250
+ * @param {Object} ctx - The trigger context.
251
+ * @param {string} value - The query text after the trigger.
252
+ * @param {Node} targetNode - The node where the trigger was detected.
253
+ * @returns {Promise<boolean>}
254
+ */
255
+ async #createList(ctx, value, targetNode) {
256
+ const limit = ctx.limitSize;
257
+ const lowerValue = value.toLowerCase();
258
+ let response = null;
259
+
260
+ if (ctx.cachingData) {
261
+ response = ctx.cachingData.get(value);
262
+ }
263
+
264
+ if (!response) {
265
+ if (ctx.directData) {
266
+ response = ctx.directData.filter((item) => item.key.toLowerCase().startsWith(lowerValue)).slice(0, limit);
267
+ } else {
268
+ const xmlHttp = await ctx.apiManager.asyncCall({ method: 'GET', url: this.#createUrl(ctx, value) });
269
+ const json = JSON.parse(xmlHttp.responseText);
270
+ response = ctx.transformResponse ? ctx.transformResponse(json, xmlHttp) : json;
271
+ }
272
+ }
273
+
274
+ if (ctx.cachingFieldData) {
275
+ const uniqueKeys = new Set();
276
+ response = ctx.cachingFieldData
277
+ .concat(response)
278
+ .filter(({ key }) => {
279
+ if (uniqueKeys.has(key)) return false;
280
+ uniqueKeys.add(key);
281
+ return key.toLowerCase().startsWith(lowerValue);
282
+ })
283
+ .slice(0, limit);
284
+ }
285
+
286
+ if (!response?.length) {
287
+ this.selectMenu.close();
288
+ return false;
289
+ }
290
+
291
+ const list = [];
292
+ const menus = [];
293
+ for (let i = 0, len = response.length, v; i < len; i++) {
294
+ v = response[i];
295
+ list.push(v);
296
+ menus.push(ctx.renderItem(v, ctx.trigger));
297
+ }
298
+
299
+ // controller open
300
+ this.controller.open(targetNode, null, { isWWTarget: true, initMethod: null, addOffset: null });
301
+ // select menu create
302
+ this.selectMenu.create(list, menus);
303
+ this.selectMenu.open();
304
+ this.selectMenu.setItem(0);
305
+ if (ctx.cachingData) ctx.cachingData.set(value, list);
306
+ return true;
307
+ }
308
+
309
+ /**
310
+ * @description Constructs the API request URL with the query value.
311
+ * @param {Object} ctx - The trigger context.
312
+ * @param {string} key - The query text.
313
+ * @returns {string}
314
+ */
315
+ #createUrl(ctx, key) {
316
+ return ctx.apiUrl.replace(/\{key\}/i, key);
317
+ }
318
+
319
+ /**
320
+ * @description Handles item selection from the dropdown.
321
+ * @param {{key: string, [x: string]: any}} item - The selected data item.
322
+ * @returns {boolean}
323
+ */
324
+ #onSelectItem(item) {
325
+ if (!item) return false;
326
+
327
+ const ctx = this.#activeTrigger;
328
+ if (!ctx) return false;
329
+
330
+ const result = ctx.onSelect(item, ctx.trigger);
331
+ let insertedNode = null;
332
+
333
+ const anchorParent = this.#anchorNode.parentNode;
334
+
335
+ if (typeof result === 'string') {
336
+ // plain text insertion
337
+ this.$.selection.setRange(this.#anchorNode, this.#lastTriggerPos, this.#anchorNode, this.#anchorOffset);
338
+ insertedNode = dom.utils.createTextNode(result);
339
+ if (!this.$.html.insertNode(insertedNode, { afterNode: null, skipCharCount: false })) return false;
340
+ } else {
341
+ // element insertion (descriptor or DOM element)
342
+ let element;
343
+ if (result?.nodeType) {
344
+ element = result;
345
+ } else if (result?.tag) {
346
+ element = dom.utils.createElement(result.tag.toUpperCase(), result.attrs || {}, result.text || '');
347
+ } else {
348
+ return false;
349
+ }
350
+
351
+ if (anchorParent.getAttribute?.('data-se-autocomplete')) {
352
+ // update existing autocomplete element in-place
353
+ for (const attr of [...anchorParent.attributes]) anchorParent.removeAttribute(attr.name);
354
+ for (const attr of [...element.attributes]) anchorParent.setAttribute(attr.name, attr.value);
355
+ anchorParent.textContent = element.textContent;
356
+ insertedNode = anchorParent;
357
+ } else {
358
+ this.$.selection.setRange(this.#anchorNode, this.#lastTriggerPos, this.#anchorNode, this.#anchorOffset);
359
+ if (!this.$.html.insertNode(element, { afterNode: null, skipCharCount: false })) return false;
360
+ insertedNode = element;
361
+ }
362
+ }
363
+
364
+ this.selectMenu.close();
365
+
366
+ const space = dom.utils.createTextNode('\u00A0');
367
+ insertedNode.parentNode.insertBefore(space, insertedNode.nextSibling);
368
+ this.$.selection.setRange(space, 1, space, 1);
369
+
370
+ if (ctx.cachingFieldData && !ctx.cachingFieldData.some((data) => data.key === item.key)) {
371
+ ctx.cachingFieldData.push(item);
372
+ }
373
+ }
374
+ }
375
+
376
+ /**
377
+ * @returns {HTMLElement}
378
+ */
379
+ function CreateHTML_controller() {
380
+ return dom.utils.createElement('DIV', { class: 'se-controller se-empty-controller' }, '<div></div>');
381
+ }
382
+
383
+ export default Autocomplete;