@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/README.md +83 -6
- package/core.js +5 -0
- package/dist/core.esm.js +1 -1
- package/dist/core.esm.js.map +1 -1
- package/dist/rich-editor.esm.js +1 -1
- package/dist/rich-editor.esm.js.map +1 -1
- package/dist/rich-editor.min.js +1 -1
- package/dist/rich-editor.min.js.map +1 -1
- package/index.d.ts +96 -0
- package/index.js +71 -1
- package/lib/core/editor.js +94 -15
- package/lib/formats/image.js +16 -22
- package/lib/modules/mention.js +200 -0
- package/lib/serialize.js +241 -0
- package/lib/static.js +28 -0
- package/lib/styles.css +107 -0
- package/lib/styles.css.js +1 -1
- package/lib/styles.min.css +1 -1
- package/package.json +1 -1
- package/umd-entry.js +6 -5
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
|
|
package/lib/core/editor.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
938
|
-
|
|
939
|
-
|
|
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(
|
|
944
|
-
if (!img) return;
|
|
945
|
-
|
|
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
|
-
|
|
1024
|
+
el.remove();
|
|
948
1025
|
}
|
|
949
|
-
this.focus();
|
|
950
|
-
execFormat('insertHTML', imgHtml);
|
|
951
1026
|
this.onContentChange();
|
|
952
|
-
}
|
|
953
|
-
|
|
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
|
/**
|
package/lib/formats/image.js
CHANGED
|
@@ -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',
|
|
170
|
+
input.addEventListener('change', () => {
|
|
171
171
|
const file = input.files && input.files[0];
|
|
172
172
|
if (file) {
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
+
}
|