@oix1987/yjd 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.d.ts CHANGED
@@ -2,6 +2,63 @@
2
2
  // These declarations are the package entry types (referenced via "types" in package.json),
3
3
  // so they are declared at top level rather than wrapped in `declare module`.
4
4
 
5
+ /** A person/task suggestion returned by a mention source. */
6
+ export interface MentionItem {
7
+ id: string | number;
8
+ name?: string;
9
+ label?: string;
10
+ avatar_url?: string;
11
+ [key: string]: any;
12
+ }
13
+
14
+ /** Config for a single mention trigger character. */
15
+ export interface MentionTrigger {
16
+ /** Trigger character, e.g. '#'. */
17
+ char: string;
18
+ /** Async (or sync) lookup of suggestions for the typed query. */
19
+ source: (query: string) => MentionItem[] | Promise<MentionItem[]>;
20
+ /** Custom HTML for a suggestion row. Defaults to avatar + name. */
21
+ renderItem?: (item: MentionItem) => string;
22
+ }
23
+
24
+ /** @mention configuration. The token inserted carries `data-id`. */
25
+ export interface MentionOptions {
26
+ /** Primary trigger character (default '@'). */
27
+ trigger?: string;
28
+ source?: (query: string) => MentionItem[] | Promise<MentionItem[]>;
29
+ renderItem?: (item: MentionItem) => string;
30
+ /** Additional triggers, e.g. '#' for task references. */
31
+ triggers?: MentionTrigger[];
32
+ }
33
+
34
+ /** Image upload hook configuration. */
35
+ export interface ImageOptions {
36
+ /**
37
+ * Upload the chosen file and resolve to its URL. While pending, a placeholder
38
+ * is shown; on resolve the src is swapped, on reject the image is removed.
39
+ * Omit to fall back to inline base64 (data URLs).
40
+ */
41
+ upload?: (file: File) => string | Promise<string>;
42
+ /** `accept` attribute for the file picker (default 'image/*'). */
43
+ accept?: string;
44
+ /** Maximum file size in bytes; larger files emit 'image:error'. */
45
+ maxSize?: number;
46
+ }
47
+
48
+ /** A JSON document node produced by getJSON()/domToJson. */
49
+ export interface JsonNode {
50
+ tag?: string;
51
+ text?: string;
52
+ attrs?: Record<string, string>;
53
+ content?: JsonNode[];
54
+ }
55
+
56
+ /** Root JSON document. */
57
+ export interface JsonDoc {
58
+ type: 'doc';
59
+ content: JsonNode[];
60
+ }
61
+
5
62
  // Editor options interface
6
63
  export interface EditorOptions {
7
64
  placeholder?: string;
@@ -24,6 +81,10 @@ export interface EditorOptions {
24
81
  markdown?: boolean;
25
82
  /** Autosave drafts to localStorage. true, or { key, debounce(ms) }. */
26
83
  autosave?: boolean | { key?: string; debounce?: number };
84
+ /** Image upload hook (replaces inline base64 when `upload` is provided). */
85
+ image?: ImageOptions | boolean;
86
+ /** @mention / #task autocomplete. Inert until a `source` is given. */
87
+ mention?: MentionOptions;
27
88
  features?: {
28
89
  emoji?: boolean;
29
90
  image?: boolean;
@@ -48,6 +109,15 @@ export class Editor {
48
109
  emit(event: string, data: any): void;
49
110
  getContent(): string;
50
111
  setContent(content: string): void;
112
+ /** Alias of getContent() / setContent(). */
113
+ getHTML(): string;
114
+ setHTML(html: string): void;
115
+ /** Export/import the document as a JSON tree. */
116
+ getJSON(): JsonDoc;
117
+ setJSON(json: JsonDoc | JsonNode[]): void;
118
+ /** Export/import the document as Markdown (mention ids preserved). */
119
+ getMarkdown(): string;
120
+ setMarkdown(markdown: string): void;
51
121
  getText(): string;
52
122
  isEmpty(): boolean;
53
123
  clear(): void;
@@ -76,10 +146,35 @@ export class RichEditor extends Editor {
76
146
  static register(path: string, definition: any, suppressWarning?: boolean): void;
77
147
  static get(path: string): any;
78
148
  static create(selector: string | Element, options?: EditorOptions): RichEditor;
149
+ /**
150
+ * Progressive-enhance a <textarea> into an editor, keeping textarea.value
151
+ * in sync (and dispatching native input/change events) on every edit.
152
+ */
153
+ static fromTextarea(
154
+ textarea: HTMLTextAreaElement | string,
155
+ options?: EditorOptions & { format?: 'html' | 'markdown' }
156
+ ): RichEditor;
157
+ /** The original textarea, when created via fromTextarea(). */
158
+ textarea?: HTMLTextAreaElement;
79
159
  }
80
160
 
161
+ /** Brand-aligned alias of {@link RichEditor}. */
162
+ export { RichEditor as yjd };
163
+
81
164
  export function createEditor(selector: string | Element, options?: EditorOptions): RichEditor;
82
165
 
166
+ /**
167
+ * Render stored HTML into a read-only view that matches the editor's styling.
168
+ * Sanitizes the HTML and tags the host element with `.yjd-content`.
169
+ */
170
+ export function renderStatic(html: string, target?: Element): Element;
171
+
172
+ // Serialization helpers (also available on the editor as get/set methods)
173
+ export function htmlToMarkdown(html: string): string;
174
+ export function markdownToHtml(markdown: string): string;
175
+ export function domToJson(html: string): JsonDoc;
176
+ export function jsonToHtml(json: JsonDoc | JsonNode[]): string;
177
+
83
178
  // Formats
84
179
  export const Bold: any;
85
180
  export const Italic: any;
@@ -115,6 +210,7 @@ export const TableToolbar: any;
115
210
  export const CodeView: any;
116
211
  export const FindReplace: any;
117
212
  export const SlashMenu: any;
213
+ export const Mention: any;
118
214
  export const ResizeHandles: any;
119
215
 
120
216
  // UI components
package/index.js CHANGED
@@ -3,6 +3,8 @@ import registry from './lib/core/registry.js';
3
3
  import Module from './lib/core/module.js';
4
4
  import { Format, InlineFormat, BlockFormat } from './lib/core/format.js';
5
5
  import StylesLoader from './lib/styles-loader.js';
6
+ import { renderStatic } from './lib/static.js';
7
+ import { htmlToMarkdown, markdownToHtml, domToJson, jsonToHtml } from './lib/serialize.js';
6
8
 
7
9
  // Import formats
8
10
  import Bold from './lib/formats/bold.js';
@@ -38,6 +40,7 @@ import TableToolbar from './lib/modules/table-toolbar.js';
38
40
  import CodeView from './lib/modules/code-view.js';
39
41
  import FindReplace from './lib/modules/find-replace.js';
40
42
  import SlashMenu from './lib/modules/slash-menu.js';
43
+ import Mention from './lib/modules/mention.js';
41
44
 
42
45
  import ResizeHandles from './lib/modules/resize-handles.js';
43
46
 
@@ -93,6 +96,7 @@ registry.register('modules/table-toolbar', TableToolbar, true);
93
96
  registry.register('modules/code-view', CodeView, true);
94
97
  registry.register('modules/find-replace', FindReplace, true);
95
98
  registry.register('modules/slash-menu', SlashMenu, true);
99
+ registry.register('modules/mention', Mention, true);
96
100
 
97
101
  registry.register('modules/resize-handles', ResizeHandles, true);
98
102
 
@@ -142,11 +146,67 @@ class RichEditor extends Editor {
142
146
  static create(selector, options = {}) {
143
147
  return new RichEditor(selector, options);
144
148
  }
149
+
150
+ /**
151
+ * Progressive-enhance a <textarea>: hide it, mount an editor in its place,
152
+ * and keep the textarea's value in sync so existing form submits keep working.
153
+ *
154
+ * const ed = RichEditor.fromTextarea(document.querySelector('#body'), {
155
+ * // any editor option; `format` chooses how the textarea is read/written:
156
+ * format: 'html' | 'markdown', // default 'html'
157
+ * });
158
+ *
159
+ * The textarea's current value seeds the editor (parsed as HTML or Markdown
160
+ * per `format`). On every change the textarea is updated and a native 'input'
161
+ * event is dispatched, so framework bindings / validation keep firing.
162
+ *
163
+ * @param {HTMLTextAreaElement|string} textarea Element or selector.
164
+ * @param {object} [options] Editor options + optional `format`.
165
+ * @returns {RichEditor}
166
+ */
167
+ static fromTextarea(textarea, options = {}) {
168
+ const ta = typeof textarea === 'string' ? document.querySelector(textarea) : textarea;
169
+ if (!ta) throw new Error('RichEditor.fromTextarea: textarea not found');
170
+
171
+ const format = options.format === 'markdown' ? 'markdown' : 'html';
172
+ const readEditor = (ed) => (format === 'markdown' ? ed.getMarkdown() : ed.getContent());
173
+
174
+ // Mount point right after the textarea; hide the original.
175
+ const mount = document.createElement('div');
176
+ ta.after(mount);
177
+ ta.style.display = 'none';
178
+ ta.setAttribute('aria-hidden', 'true');
179
+
180
+ const initial = ta.value || '';
181
+ const editor = new RichEditor(mount, {
182
+ width: '100%',
183
+ ...options,
184
+ // Seed content from the textarea (skip if caller passed explicit content).
185
+ content: options.content != null ? options.content
186
+ : (format === 'markdown' ? markdownToHtml(initial) : initial),
187
+ });
188
+
189
+ const sync = () => {
190
+ const next = readEditor(editor);
191
+ if (ta.value === next) return;
192
+ ta.value = next;
193
+ ta.dispatchEvent(new Event('input', { bubbles: true }));
194
+ ta.dispatchEvent(new Event('change', { bubbles: true }));
195
+ };
196
+ editor.on('change', sync);
197
+ sync(); // normalise the textarea to the editor's serialization up front.
198
+
199
+ editor.textarea = ta;
200
+ return editor;
201
+ }
145
202
  }
146
203
 
147
- // Export classes for extension
204
+ // Export classes for extension. `yjd` is the brand-aligned name; `RichEditor`
205
+ // is kept as an alias for backward compatibility.
148
206
  export {
149
207
  RichEditor as default,
208
+ RichEditor,
209
+ RichEditor as yjd,
150
210
  Editor,
151
211
  Module,
152
212
  Format,
@@ -194,6 +254,7 @@ export {
194
254
  CodeView,
195
255
  FindReplace,
196
256
  SlashMenu,
257
+ Mention,
197
258
 
198
259
  ResizeHandles
199
260
  };
@@ -214,6 +275,15 @@ export {
214
275
  createCustomButton
215
276
  };
216
277
 
278
+ // Static rendering + serialization helpers
279
+ export {
280
+ renderStatic,
281
+ htmlToMarkdown,
282
+ markdownToHtml,
283
+ domToJson,
284
+ jsonToHtml
285
+ };
286
+
217
287
 
218
288
 
219
289
 
@@ -2,6 +2,7 @@ import registry from './registry.js';
2
2
  import Module from './module.js';
3
3
  import { execFormat, queryFormatState } from '../utils/exec-command.js';
4
4
  import { sanitizeHtml } from '../utils/sanitize.js';
5
+ import { htmlToMarkdown, markdownToHtml, domToJson, jsonToHtml } from '../serialize.js';
5
6
 
6
7
  /**
7
8
  * Main Editor class - Inspired by Quill's architecture
@@ -253,9 +254,15 @@ export default class Editor {
253
254
  modulesToLoad = this.options.modules || ['toolbar', 'history'];
254
255
  } else {
255
256
  // No toolbar config - load full feature set
256
- modulesToLoad = this.options.modules || ['toolbar', 'history', 'block-toolbar', 'table-toolbar', 'code-view', 'theme-switcher', 'resize-handles', 'find-replace', 'slash-menu'];
257
+ modulesToLoad = this.options.modules || ['toolbar', 'history', 'block-toolbar', 'table-toolbar', 'code-view', 'theme-switcher', 'resize-handles', 'find-replace', 'slash-menu', 'mention'];
257
258
  }
258
-
259
+
260
+ // @mention is inert without a source, so load it whenever configured —
261
+ // even alongside a custom toolbar that otherwise loads only basics.
262
+ if (this.options.mention && !modulesToLoad.includes('mention')) {
263
+ modulesToLoad.push('mention');
264
+ }
265
+
259
266
 
260
267
  modulesToLoad.forEach(moduleName => {
261
268
  const ModuleClass = this.registry.get(`modules/${moduleName}`);
@@ -500,7 +507,9 @@ export default class Editor {
500
507
  // Persist draft if autosave is enabled
501
508
  this._scheduleAutosave(content);
502
509
 
503
- // Emit text-change event
510
+ // Emit change events ('change' is the documented name; 'text-change' kept
511
+ // for backward compatibility).
512
+ this.emit('change', content);
504
513
  this.emit('text-change', content);
505
514
  }
506
515
 
@@ -823,6 +832,23 @@ export default class Editor {
823
832
  this.onContentChange();
824
833
  }
825
834
 
835
+ /* ----- Export / import in common formats (HTML · JSON · Markdown) ----- */
836
+
837
+ /** HTML string (alias of getContent). */
838
+ getHTML() { return this.getContent(); }
839
+ /** Set content from an HTML string (sanitised). */
840
+ setHTML(html) { this.setContent(sanitizeHtml(html || '')); }
841
+
842
+ /** Structured JSON document `{ type:'doc', content:[…] }`. */
843
+ getJSON() { return domToJson(this.getContent()); }
844
+ /** Set content from a JSON document (produced by getJSON). */
845
+ setJSON(json) { this.setContent(sanitizeHtml(jsonToHtml(json))); }
846
+
847
+ /** Markdown string. */
848
+ getMarkdown() { return htmlToMarkdown(this.getContent()); }
849
+ /** Set content from a Markdown string. */
850
+ setMarkdown(md) { this.setContent(sanitizeHtml(markdownToHtml(md || ''))); }
851
+
826
852
  /**
827
853
  * Get the plain text content of the editor (no markup).
828
854
  * @returns {string}
@@ -934,23 +960,76 @@ export default class Editor {
934
960
  */
935
961
  insertImageFile(file) {
936
962
  if (!file || !file.type || !file.type.startsWith('image/')) return;
937
- const reader = new FileReader();
938
- reader.onload = (ev) => {
939
- const dataUrl = ev.target.result;
963
+
964
+ const cfg = this.options.image || {};
965
+ // Validate against optional accept / maxSize before doing anything.
966
+ if (cfg.accept && cfg.accept !== 'image/*') {
967
+ const ok = cfg.accept.split(',').some(a => {
968
+ a = a.trim();
969
+ return a.endsWith('/*') ? file.type.startsWith(a.slice(0, -1)) : file.type === a;
970
+ });
971
+ if (!ok) { this.emit('image:error', { file, reason: 'type' }); return; }
972
+ }
973
+ if (cfg.maxSize && file.size > cfg.maxSize) {
974
+ this.emit('image:error', { file, reason: 'size' });
975
+ return;
976
+ }
977
+
978
+ // Build a real <img> from a src (validates via the Image format).
979
+ const makeImg = (src, extra = '') => {
940
980
  const ImageClass = this.registry.get('formats/image');
941
- let imgHtml;
942
981
  if (ImageClass && typeof ImageClass.create === 'function') {
943
- const img = ImageClass.create(dataUrl); // validates the data: URL
944
- if (!img) return;
945
- imgHtml = img.outerHTML;
982
+ const img = ImageClass.create(src);
983
+ if (!img) return null;
984
+ if (extra) img.setAttribute('data-state', extra);
985
+ return img;
986
+ }
987
+ return null;
988
+ };
989
+
990
+ // No upload hook → embed as base64 (backward-compatible default).
991
+ if (typeof cfg.upload !== 'function') {
992
+ const reader = new FileReader();
993
+ reader.onload = (ev) => {
994
+ const html = (makeImg(ev.target.result) || {}).outerHTML
995
+ || `<img src="${ev.target.result}" class="inserted-image" style="max-width:100%" contenteditable="false">`;
996
+ this.focus();
997
+ execFormat('insertHTML', html);
998
+ this.onContentChange();
999
+ };
1000
+ reader.readAsDataURL(file);
1001
+ return;
1002
+ }
1003
+
1004
+ // Upload hook: insert a placeholder, await the URL, then swap the src.
1005
+ const placeholderId = 'rte-up-' + Math.round(performance.now()) + '-' + (this._upCounter = (this._upCounter || 0) + 1);
1006
+ const ph = makeImg('data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 width=%27120%27 height=%2790%27%3E%3Crect width=%27100%25%27 height=%27100%25%27 fill=%27%23efedff%27/%3E%3C/svg%3E', 'uploading');
1007
+ if (!ph) return;
1008
+ ph.id = placeholderId;
1009
+ ph.style.opacity = '0.6';
1010
+ this.focus();
1011
+ execFormat('insertHTML', ph.outerHTML);
1012
+ this.emit('image:upload', { file });
1013
+
1014
+ Promise.resolve(cfg.upload(file)).then((url) => {
1015
+ const el = this.editor.querySelector('#' + placeholderId);
1016
+ if (!el) return;
1017
+ if (url) {
1018
+ el.src = url;
1019
+ el.style.opacity = '';
1020
+ el.removeAttribute('data-state');
1021
+ el.removeAttribute('id');
1022
+ this.emit('image:uploaded', { file, url });
946
1023
  } else {
947
- imgHtml = `<img src="${dataUrl}" class="inserted-image" style="max-width:100%" contenteditable="false">`;
1024
+ el.remove();
948
1025
  }
949
- this.focus();
950
- execFormat('insertHTML', imgHtml);
951
1026
  this.onContentChange();
952
- };
953
- reader.readAsDataURL(file);
1027
+ }).catch((err) => {
1028
+ const el = this.editor.querySelector('#' + placeholderId);
1029
+ if (el) el.remove();
1030
+ this.emit('image:error', { file, reason: 'upload', error: err });
1031
+ this.onContentChange();
1032
+ });
954
1033
  }
955
1034
 
956
1035
  /**
@@ -165,32 +165,26 @@ class Image extends InlineFormat {
165
165
 
166
166
  const input = document.createElement('input');
167
167
  input.type = 'file';
168
- input.accept = 'image/*';
168
+ input.accept = (editor.options.image && editor.options.image.accept) || 'image/*';
169
169
  input.style.display = 'none';
170
- input.addEventListener('change', async () => {
170
+ input.addEventListener('change', () => {
171
171
  const file = input.files && input.files[0];
172
172
  if (file) {
173
- try {
174
- const src = await Image.handleFileUpload(file);
175
- editor.focus();
176
- const sel = window.getSelection();
177
- // Restore the caret we had before the file dialog stole focus. If we
178
- // never had one (clicking the toolbar can collapse the selection),
179
- // drop the caret at the end of the editor so insertion still works.
180
- if (savedRange) {
181
- sel.removeAllRanges();
182
- sel.addRange(savedRange);
183
- } else if (!sel.rangeCount || !editor.editor.contains(sel.anchorNode)) {
184
- const r = document.createRange();
185
- r.selectNodeContents(editor.editor);
186
- r.collapse(false);
187
- sel.removeAllRanges();
188
- sel.addRange(r);
189
- }
190
- Image.insertImageAtCurrentPosition(src, file.name || '', this.editorId);
191
- } catch (err) {
192
- console.warn('Image upload failed:', err.message);
173
+ // Restore the caret captured before the file dialog stole focus.
174
+ editor.focus();
175
+ const sel = window.getSelection();
176
+ if (savedRange) {
177
+ sel.removeAllRanges();
178
+ sel.addRange(savedRange);
179
+ } else if (!sel.rangeCount || !editor.editor.contains(sel.anchorNode)) {
180
+ const r = document.createRange();
181
+ r.selectNodeContents(editor.editor);
182
+ r.collapse(false);
183
+ sel.removeAllRanges();
184
+ sel.addRange(r);
193
185
  }
186
+ // Single insertion path → honours the image.upload hook + validation.
187
+ editor.insertImageFile(file);
194
188
  }
195
189
  input.remove();
196
190
  });
@@ -0,0 +1,200 @@
1
+ import Module from '../core/module.js';
2
+
3
+ /**
4
+ * @mention module — trigger-based autocomplete that inserts a token carrying an
5
+ * id, so the serialized HTML/Markdown can tell the server who was tagged.
6
+ *
7
+ * new Editor(el, {
8
+ * mention: {
9
+ * trigger: '@',
10
+ * source: async (query) => [{ id, name, avatar_url }],
11
+ * renderItem: (item) => `<img src="${item.avatar_url}"> ${item.name}`,
12
+ * // optional extra triggers, e.g. '#' for task refs:
13
+ * triggers: [{ char: '#', source: async (q) => [...] }]
14
+ * }
15
+ * })
16
+ *
17
+ * Token HTML: <span class="mention" data-id="ID" data-trigger="@"
18
+ * contenteditable="false">@Name</span>
19
+ * → getMarkdown() emits `@[Name](id)`. Fires editor.on('mention:select', item).
20
+ */
21
+ export default class Mention extends Module {
22
+ constructor(editor, options = {}) {
23
+ super(editor, options);
24
+ this.isOpen = false;
25
+ this.activeIndex = 0;
26
+ this.items = [];
27
+ this.sources = this._buildSources();
28
+ this.buildMenu();
29
+ this.bindEvents();
30
+ }
31
+
32
+ _buildSources() {
33
+ const cfg = this.editor.options.mention || this.options || {};
34
+ const map = {};
35
+ const renderItem = cfg.renderItem;
36
+ if (typeof cfg.source === 'function') {
37
+ map[cfg.trigger || '@'] = { source: cfg.source, renderItem: cfg.renderItem || renderItem };
38
+ }
39
+ (cfg.triggers || []).forEach((t) => {
40
+ if (t && t.char && typeof t.source === 'function') {
41
+ map[t.char] = { source: t.source, renderItem: t.renderItem || renderItem };
42
+ }
43
+ });
44
+ return map;
45
+ }
46
+
47
+ get enabled() { return Object.keys(this.sources).length > 0; }
48
+
49
+ buildMenu() {
50
+ const menu = document.createElement('div');
51
+ menu.className = 'yjd-mention-menu';
52
+ menu.setAttribute('role', 'listbox');
53
+ menu.style.display = 'none';
54
+ this.menu = menu;
55
+ document.body.appendChild(menu);
56
+ }
57
+
58
+ bindEvents() {
59
+ if (!this.enabled) return;
60
+ this._onInput = () => this.handleInput();
61
+ this.editor.editor.addEventListener('input', this._onInput);
62
+
63
+ this._onKeydown = (e) => {
64
+ if (!this.isOpen) return;
65
+ if (e.key === 'ArrowDown') { e.preventDefault(); this.move(1); }
66
+ else if (e.key === 'ArrowUp') { e.preventDefault(); this.move(-1); }
67
+ else if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault(); this.choose(this.activeIndex); }
68
+ else if (e.key === 'Escape') { e.preventDefault(); this.close(); }
69
+ };
70
+ this.editor.editor.addEventListener('keydown', this._onKeydown, true);
71
+
72
+ this._onDocPointer = (e) => { if (this.isOpen && !this.menu.contains(e.target)) this.close(); };
73
+ document.addEventListener('pointerdown', this._onDocPointer, true);
74
+ }
75
+
76
+ handleInput() {
77
+ const sel = window.getSelection();
78
+ if (!sel || !sel.isCollapsed || !sel.rangeCount) return this.close();
79
+ const range = sel.getRangeAt(0);
80
+ const node = range.startContainer;
81
+ if (node.nodeType !== Node.TEXT_NODE) return this.close();
82
+
83
+ const triggers = Object.keys(this.sources).map((c) => c.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('');
84
+ const before = node.textContent.slice(0, range.startOffset);
85
+ const m = before.match(new RegExp(`(?:^|\\s)([${triggers}])([^\\s${triggers}]*)$`));
86
+ if (!m) return this.close();
87
+
88
+ this.char = m[1];
89
+ this.query = m[2];
90
+ this.node = node;
91
+ this.start = range.startOffset - this.query.length - 1; // index of trigger char
92
+ this._loadFor(range);
93
+ }
94
+
95
+ _loadFor(range) {
96
+ const src = this.sources[this.char];
97
+ if (!src) return this.close();
98
+ clearTimeout(this._t);
99
+ const q = this.query, char = this.char;
100
+ this._t = setTimeout(() => {
101
+ Promise.resolve(src.source(q)).then((items) => {
102
+ // Ignore stale responses (user kept typing / switched trigger).
103
+ if (this.char !== char || this.query !== q) return;
104
+ this.items = Array.isArray(items) ? items : [];
105
+ if (!this.items.length) return this.close();
106
+ this.activeIndex = 0;
107
+ this.render(src.renderItem);
108
+ this.open(range);
109
+ }).catch(() => this.close());
110
+ }, 120);
111
+ }
112
+
113
+ open(range) {
114
+ this.isOpen = true;
115
+ this.menu.style.display = 'block';
116
+ const rect = range.getBoundingClientRect();
117
+ const x = rect.left || (range.startContainer.parentElement || this.editor.editor).getBoundingClientRect().left;
118
+ const y = rect.bottom || rect.top;
119
+ this.menu.style.left = `${Math.round(x + window.scrollX)}px`;
120
+ this.menu.style.top = `${Math.round(y + window.scrollY + 6)}px`;
121
+ const mh = this.menu.offsetHeight;
122
+ if (rect.bottom + mh + 8 > window.innerHeight) {
123
+ this.menu.style.top = `${Math.round(rect.top + window.scrollY - mh - 6)}px`;
124
+ }
125
+ }
126
+
127
+ close() {
128
+ if (!this.isOpen) return;
129
+ this.isOpen = false;
130
+ this.menu.style.display = 'none';
131
+ }
132
+
133
+ move(d) {
134
+ this.activeIndex = (this.activeIndex + d + this.items.length) % this.items.length;
135
+ [...this.menu.children].forEach((el, i) => {
136
+ el.classList.toggle('active', i === this.activeIndex);
137
+ el.setAttribute('aria-selected', i === this.activeIndex ? 'true' : 'false');
138
+ });
139
+ }
140
+
141
+ render(renderItem) {
142
+ this.menu.innerHTML = '';
143
+ this.items.forEach((item, i) => {
144
+ const el = document.createElement('button');
145
+ el.type = 'button';
146
+ el.className = 'yjd-mention-item' + (i === this.activeIndex ? ' active' : '');
147
+ el.setAttribute('role', 'option');
148
+ el.setAttribute('aria-selected', i === this.activeIndex ? 'true' : 'false');
149
+ const label = item.name || item.label || item.id || '';
150
+ el.innerHTML = typeof renderItem === 'function'
151
+ ? renderItem(item)
152
+ : `${item.avatar_url ? `<img class="yjd-mention-avatar" src="${item.avatar_url}" alt="">` : ''}<span class="yjd-mention-name">${this.char}${label}</span>`;
153
+ el.addEventListener('pointerdown', (e) => { e.preventDefault(); this.choose(i); });
154
+ this.menu.appendChild(el);
155
+ });
156
+ }
157
+
158
+ choose(index) {
159
+ const item = this.items[index];
160
+ if (!item) return this.close();
161
+ const name = item.name || item.label || item.id || '';
162
+ try {
163
+ const node = this.node;
164
+ const sel = window.getSelection();
165
+ const del = document.createRange();
166
+ del.setStart(node, this.start);
167
+ del.setEnd(node, this.start + this.query.length + 1);
168
+ del.deleteContents();
169
+
170
+ const span = document.createElement('span');
171
+ span.className = 'mention';
172
+ span.setAttribute('data-id', String(item.id != null ? item.id : ''));
173
+ span.setAttribute('data-trigger', this.char);
174
+ span.setAttribute('contenteditable', 'false');
175
+ span.textContent = this.char + name;
176
+ del.insertNode(span);
177
+
178
+ const space = document.createTextNode(' ');
179
+ span.after(space);
180
+ const caret = document.createRange();
181
+ caret.setStart(space, 1);
182
+ caret.collapse(true);
183
+ sel.removeAllRanges();
184
+ sel.addRange(caret);
185
+ } catch (e) { /* node moved */ }
186
+
187
+ this.close();
188
+ this.editor.focus();
189
+ if (typeof this.editor.onContentChange === 'function') this.editor.onContentChange();
190
+ this.editor.emit('mention:select', item);
191
+ }
192
+
193
+ destroy() {
194
+ if (this._onInput) this.editor.editor.removeEventListener('input', this._onInput);
195
+ if (this._onKeydown) this.editor.editor.removeEventListener('keydown', this._onKeydown, true);
196
+ if (this._onDocPointer) document.removeEventListener('pointerdown', this._onDocPointer, true);
197
+ if (this.menu && this.menu.parentNode) this.menu.parentNode.removeChild(this.menu);
198
+ super.destroy();
199
+ }
200
+ }