@oix1987/yjd 2.1.0 → 2.1.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 +92 -7
- 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 +75 -5
- package/index.js +3 -52
- package/lib/core/editor.js +309 -11
- package/lib/modules/mention.js +33 -5
- package/lib/modules/slash-menu.js +6 -4
- package/lib/modules/toolbar.js +28 -0
- package/lib/serialize.js +7 -0
- package/lib/styles.css +81 -6
- package/lib/styles.css.js +1 -1
- package/lib/styles.min.css +1 -1
- package/lib/ui/icons.js +1 -0
- package/package.json +1 -1
package/index.d.ts
CHANGED
|
@@ -45,6 +45,43 @@ export interface ImageOptions {
|
|
|
45
45
|
maxSize?: number;
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
/** Result of a file.upload hook — a URL, or richer metadata. */
|
|
49
|
+
export interface FileUploadResult {
|
|
50
|
+
url: string;
|
|
51
|
+
name?: string;
|
|
52
|
+
size?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Attachment (non-image file) upload hook. Inserts a "file chip". */
|
|
56
|
+
export interface FileOptions {
|
|
57
|
+
/**
|
|
58
|
+
* Upload the chosen file; resolve to a URL string or { url, name, size }.
|
|
59
|
+
* While pending a placeholder chip is shown. Omit to inline a data: URL.
|
|
60
|
+
*/
|
|
61
|
+
upload?: (file: File) => string | FileUploadResult | Promise<string | FileUploadResult>;
|
|
62
|
+
/** `accept` attribute for the file picker (e.g. '.pdf,.zip,.docx'). */
|
|
63
|
+
accept?: string;
|
|
64
|
+
/** Maximum file size in bytes; larger files emit 'file:error'. */
|
|
65
|
+
maxSize?: number;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Enter-to-submit behaviour (e.g. a comment box). */
|
|
69
|
+
export interface SubmitOptions {
|
|
70
|
+
/**
|
|
71
|
+
* Called when Enter is pressed and no autocomplete popup (mention/slash/emoji)
|
|
72
|
+
* is open. Receives the current HTML and the editor instance.
|
|
73
|
+
*/
|
|
74
|
+
onEnter: (html: string, editor: Editor) => void;
|
|
75
|
+
/** Shift+Enter inserts a newline (default true). */
|
|
76
|
+
newlineOnShiftEnter?: boolean;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Toolbar configuration: a built-in preset, an exclusion of default items,
|
|
81
|
+
* a flat item list (single group), or full custom groups via toolbar1/toolbar2.
|
|
82
|
+
*/
|
|
83
|
+
export type ToolbarOption = 'full' | 'compact' | { exclude: string[] } | string[];
|
|
84
|
+
|
|
48
85
|
/** A JSON document node produced by getJSON()/domToJson. */
|
|
49
86
|
export interface JsonNode {
|
|
50
87
|
tag?: string;
|
|
@@ -83,8 +120,16 @@ export interface EditorOptions {
|
|
|
83
120
|
autosave?: boolean | { key?: string; debounce?: number };
|
|
84
121
|
/** Image upload hook (replaces inline base64 when `upload` is provided). */
|
|
85
122
|
image?: ImageOptions | boolean;
|
|
123
|
+
/** Attachment (non-image file) upload hook → inserts a file chip. */
|
|
124
|
+
file?: FileOptions;
|
|
86
125
|
/** @mention / #task autocomplete. Inert until a `source` is given. */
|
|
87
126
|
mention?: MentionOptions;
|
|
127
|
+
/** Enter-to-submit behaviour for comment-style editors. */
|
|
128
|
+
submit?: SubmitOptions;
|
|
129
|
+
/** Built-in preset / exclusion / flat list, instead of toolbar1/toolbar2. */
|
|
130
|
+
toolbar?: ToolbarOption;
|
|
131
|
+
/** Warn (emit 'content:overflow') when serialized HTML exceeds this many chars. */
|
|
132
|
+
maxContentSize?: number;
|
|
88
133
|
features?: {
|
|
89
134
|
emoji?: boolean;
|
|
90
135
|
image?: boolean;
|
|
@@ -104,7 +149,18 @@ export interface EditorOptions {
|
|
|
104
149
|
|
|
105
150
|
export class Editor {
|
|
106
151
|
constructor(selector: string | Element, options?: EditorOptions);
|
|
152
|
+
/**
|
|
153
|
+
* Progressive-enhance a <textarea> into an editor with two-way sync + a
|
|
154
|
+
* controller (getValue/setValue/destroy). Available from `/core` too.
|
|
155
|
+
*/
|
|
156
|
+
static fromTextarea(
|
|
157
|
+
textarea: HTMLTextAreaElement | string,
|
|
158
|
+
options?: EditorOptions & { format?: 'html' | 'markdown' }
|
|
159
|
+
): TextareaEditor;
|
|
160
|
+
/** The contentEditable element (public — apps may attach listeners to it). */
|
|
161
|
+
editor: HTMLElement;
|
|
107
162
|
on(event: string, handler: (data: any) => void): void;
|
|
163
|
+
/** Remove a previously-added listener (symmetric with on()). */
|
|
108
164
|
off(event: string, handler: (data: any) => void): void;
|
|
109
165
|
emit(event: string, data: any): void;
|
|
110
166
|
getContent(): string;
|
|
@@ -126,6 +182,12 @@ export class Editor {
|
|
|
126
182
|
clearFormatting(): void;
|
|
127
183
|
insertHorizontalRule(): void;
|
|
128
184
|
insertImageFile(file: File): void;
|
|
185
|
+
/** Insert a non-image File as a file chip (uses options.file.upload). */
|
|
186
|
+
insertFileAttachment(file: File): void;
|
|
187
|
+
/** Open the native picker for a file attachment. */
|
|
188
|
+
openFileAttachmentPicker(): void;
|
|
189
|
+
/** True when a mention/slash/emoji popup that captures Enter is open. */
|
|
190
|
+
isMenuOpen(): boolean;
|
|
129
191
|
setReadOnly(readOnly: boolean): void;
|
|
130
192
|
isReadOnly(): boolean;
|
|
131
193
|
setDirection(dir: 'ltr' | 'rtl'): void;
|
|
@@ -146,18 +208,26 @@ export class RichEditor extends Editor {
|
|
|
146
208
|
static register(path: string, definition: any, suppressWarning?: boolean): void;
|
|
147
209
|
static get(path: string): any;
|
|
148
210
|
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
|
-
*/
|
|
211
|
+
/** Inherited from Editor (returns a fully-featured RichEditor). */
|
|
153
212
|
static fromTextarea(
|
|
154
213
|
textarea: HTMLTextAreaElement | string,
|
|
155
214
|
options?: EditorOptions & { format?: 'html' | 'markdown' }
|
|
156
|
-
):
|
|
215
|
+
): TextareaEditor;
|
|
157
216
|
/** The original textarea, when created via fromTextarea(). */
|
|
158
217
|
textarea?: HTMLTextAreaElement;
|
|
159
218
|
}
|
|
160
219
|
|
|
220
|
+
/** Editor returned by fromTextarea(), with a value controller. */
|
|
221
|
+
export interface TextareaEditor extends RichEditor {
|
|
222
|
+
textarea: HTMLTextAreaElement;
|
|
223
|
+
/** Current content (HTML or Markdown per the `format` option). */
|
|
224
|
+
getValue(): string;
|
|
225
|
+
/** Replace the editor content (HTML or Markdown per `format`). */
|
|
226
|
+
setValue(value: string): void;
|
|
227
|
+
/** Remove the editor and restore the textarea with its last value. */
|
|
228
|
+
destroy(): void;
|
|
229
|
+
}
|
|
230
|
+
|
|
161
231
|
/** Brand-aligned alias of {@link RichEditor}. */
|
|
162
232
|
export { RichEditor as yjd };
|
|
163
233
|
|
package/index.js
CHANGED
|
@@ -147,58 +147,9 @@ class RichEditor extends Editor {
|
|
|
147
147
|
return new RichEditor(selector, options);
|
|
148
148
|
}
|
|
149
149
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
}
|
|
150
|
+
// fromTextarea() is inherited from the base Editor (defined in core so it is
|
|
151
|
+
// also available from the tree-shakeable /core entry). RichEditor.fromTextarea
|
|
152
|
+
// therefore returns a fully-featured RichEditor (via `new this(...)`).
|
|
202
153
|
}
|
|
203
154
|
|
|
204
155
|
// Export classes for extension. `yjd` is the brand-aligned name; `RichEditor`
|
package/lib/core/editor.js
CHANGED
|
@@ -3,6 +3,7 @@ import Module from './module.js';
|
|
|
3
3
|
import { execFormat, queryFormatState } from '../utils/exec-command.js';
|
|
4
4
|
import { sanitizeHtml } from '../utils/sanitize.js';
|
|
5
5
|
import { htmlToMarkdown, markdownToHtml, domToJson, jsonToHtml } from '../serialize.js';
|
|
6
|
+
import IconUtils from '../ui/icons.js';
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Main Editor class - Inspired by Quill's architecture
|
|
@@ -396,15 +397,24 @@ export default class Editor {
|
|
|
396
397
|
this.handlePaste(e);
|
|
397
398
|
});
|
|
398
399
|
|
|
399
|
-
// Allow dropping (needed for the drop event to fire with files)
|
|
400
|
+
// Allow dropping (needed for the drop event to fire with files) and show a
|
|
401
|
+
// drop-zone highlight while files are dragged over the editor.
|
|
400
402
|
this.editor.addEventListener('dragover', (e) => {
|
|
401
403
|
if (e.dataTransfer && Array.from(e.dataTransfer.types || []).includes('Files')) {
|
|
402
404
|
e.preventDefault();
|
|
405
|
+
this.editor.classList.add('yjd-drag-over');
|
|
406
|
+
}
|
|
407
|
+
});
|
|
408
|
+
this.editor.addEventListener('dragleave', (e) => {
|
|
409
|
+
// Only clear when the pointer actually leaves the editor (not child nodes).
|
|
410
|
+
if (!e.relatedTarget || !this.editor.contains(e.relatedTarget)) {
|
|
411
|
+
this.editor.classList.remove('yjd-drag-over');
|
|
403
412
|
}
|
|
404
413
|
});
|
|
405
414
|
|
|
406
415
|
// Handle drop events (drag and drop) — insert dropped image files
|
|
407
416
|
this.editor.addEventListener('drop', (e) => {
|
|
417
|
+
this.editor.classList.remove('yjd-drag-over');
|
|
408
418
|
const dt = e.dataTransfer;
|
|
409
419
|
const files = dt && dt.files ? Array.from(dt.files) : [];
|
|
410
420
|
const imageFile = files.find(f => f.type && f.type.startsWith('image/'));
|
|
@@ -414,6 +424,14 @@ export default class Editor {
|
|
|
414
424
|
this.insertImageFile(imageFile);
|
|
415
425
|
return;
|
|
416
426
|
}
|
|
427
|
+
// Non-image files become attachments when a file hook is configured.
|
|
428
|
+
const attachment = files.find(f => f && f.name);
|
|
429
|
+
if (attachment && this.options.file) {
|
|
430
|
+
e.preventDefault();
|
|
431
|
+
this.placeCaretAtPoint(e.clientX, e.clientY);
|
|
432
|
+
this.insertFileAttachment(attachment);
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
417
435
|
// Check content after a normal drop operation
|
|
418
436
|
setTimeout(() => {
|
|
419
437
|
this.ensureEditorHasContent();
|
|
@@ -442,6 +460,20 @@ export default class Editor {
|
|
|
442
460
|
});
|
|
443
461
|
}
|
|
444
462
|
|
|
463
|
+
// Enter-to-submit (e.g. a comment box). Enter submits, Shift+Enter inserts
|
|
464
|
+
// a newline — UNLESS an autocomplete popup is open, in which case Enter is
|
|
465
|
+
// left for the popup to choose its item. Configured via options.submit.
|
|
466
|
+
if (this.options.submit && typeof this.options.submit.onEnter === 'function') {
|
|
467
|
+
const submitCfg = this.options.submit;
|
|
468
|
+
this.editor.addEventListener('keydown', (e) => {
|
|
469
|
+
if (e.key !== 'Enter' || e.isComposing) return;
|
|
470
|
+
if (e.shiftKey) return; // Shift+Enter → newline (browser default)
|
|
471
|
+
if (this.isMenuOpen()) return; // let the popup handle Enter
|
|
472
|
+
e.preventDefault();
|
|
473
|
+
submitCfg.onEnter(this.getContent(), this);
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
|
|
445
477
|
// Handle cut events
|
|
446
478
|
this.editor.addEventListener('cut', () => {
|
|
447
479
|
// Check content after cut operation
|
|
@@ -507,6 +539,19 @@ export default class Editor {
|
|
|
507
539
|
// Persist draft if autosave is enabled
|
|
508
540
|
this._scheduleAutosave(content);
|
|
509
541
|
|
|
542
|
+
// Warn when the serialized content exceeds maxContentSize (bytes). Guards
|
|
543
|
+
// against, e.g., pasting huge base64 images. Fires once per crossing.
|
|
544
|
+
if (this.options.maxContentSize) {
|
|
545
|
+
const size = content.length;
|
|
546
|
+
const over = size > this.options.maxContentSize;
|
|
547
|
+
if (over && !this._overflowed) {
|
|
548
|
+
this._overflowed = true;
|
|
549
|
+
this.emit('content:overflow', { size, max: this.options.maxContentSize });
|
|
550
|
+
} else if (!over) {
|
|
551
|
+
this._overflowed = false;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
510
555
|
// Emit change events ('change' is the documented name; 'text-change' kept
|
|
511
556
|
// for backward compatibility).
|
|
512
557
|
this.emit('change', content);
|
|
@@ -1001,24 +1046,26 @@ export default class Editor {
|
|
|
1001
1046
|
return;
|
|
1002
1047
|
}
|
|
1003
1048
|
|
|
1004
|
-
// Upload hook: insert a placeholder
|
|
1049
|
+
// Upload hook: insert a visible loading placeholder (spinner + filename),
|
|
1050
|
+
// await the URL, then replace the placeholder with the real <img>.
|
|
1005
1051
|
const placeholderId = 'rte-up-' + Math.round(performance.now()) + '-' + (this._upCounter = (this._upCounter || 0) + 1);
|
|
1006
|
-
const
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1052
|
+
const escName = (file.name || 'image')
|
|
1053
|
+
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
1054
|
+
const phHTML =
|
|
1055
|
+
`<span class="yjd-upload" id="${placeholderId}" contenteditable="false" data-state="uploading">` +
|
|
1056
|
+
`<span class="yjd-spinner" aria-hidden="true"></span>` +
|
|
1057
|
+
`<span class="yjd-upload-label">${escName}</span>` +
|
|
1058
|
+
`</span>`;
|
|
1010
1059
|
this.focus();
|
|
1011
|
-
execFormat('insertHTML',
|
|
1060
|
+
execFormat('insertHTML', phHTML);
|
|
1012
1061
|
this.emit('image:upload', { file });
|
|
1013
1062
|
|
|
1014
1063
|
Promise.resolve(cfg.upload(file)).then((url) => {
|
|
1015
1064
|
const el = this.editor.querySelector('#' + placeholderId);
|
|
1016
1065
|
if (!el) return;
|
|
1017
1066
|
if (url) {
|
|
1018
|
-
|
|
1019
|
-
el.
|
|
1020
|
-
el.removeAttribute('data-state');
|
|
1021
|
-
el.removeAttribute('id');
|
|
1067
|
+
const img = makeImg(url);
|
|
1068
|
+
if (img) { el.replaceWith(img); } else { el.remove(); }
|
|
1022
1069
|
this.emit('image:uploaded', { file, url });
|
|
1023
1070
|
} else {
|
|
1024
1071
|
el.remove();
|
|
@@ -1032,6 +1079,235 @@ export default class Editor {
|
|
|
1032
1079
|
});
|
|
1033
1080
|
}
|
|
1034
1081
|
|
|
1082
|
+
/**
|
|
1083
|
+
* Human-readable byte size, e.g. 24576 -> "24 KB".
|
|
1084
|
+
*/
|
|
1085
|
+
static formatBytes(bytes) {
|
|
1086
|
+
if (!bytes && bytes !== 0) return '';
|
|
1087
|
+
const u = ['B', 'KB', 'MB', 'GB'];
|
|
1088
|
+
let i = 0, n = bytes;
|
|
1089
|
+
while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; }
|
|
1090
|
+
return `${n >= 10 || i === 0 ? Math.round(n) : n.toFixed(1)} ${u[i]}`;
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
/**
|
|
1094
|
+
* Progressive-enhance a <textarea> into an editor with TWO-WAY sync, returning
|
|
1095
|
+
* the editor with a controller (getValue/setValue/destroy). Defined on the
|
|
1096
|
+
* base Editor so it is available from the tree-shakeable `/core` entry too
|
|
1097
|
+
* (no need to pull the all-in-one build just for fromTextarea).
|
|
1098
|
+
*
|
|
1099
|
+
* const ed = Editor.fromTextarea('#body', { format: 'markdown' });
|
|
1100
|
+
*
|
|
1101
|
+
* @param {HTMLTextAreaElement|string} textarea
|
|
1102
|
+
* @param {object} [options] Editor options + optional `format: 'html'|'markdown'`.
|
|
1103
|
+
* @returns {Editor}
|
|
1104
|
+
*/
|
|
1105
|
+
static fromTextarea(textarea, options = {}) {
|
|
1106
|
+
const ta = typeof textarea === 'string' ? document.querySelector(textarea) : textarea;
|
|
1107
|
+
if (!ta) throw new Error('Editor.fromTextarea: textarea not found');
|
|
1108
|
+
|
|
1109
|
+
const format = options.format === 'markdown' ? 'markdown' : 'html';
|
|
1110
|
+
const read = (ed) => (format === 'markdown' ? ed.getMarkdown() : ed.getContent());
|
|
1111
|
+
const writeVal = (ed, v) => (format === 'markdown' ? ed.setMarkdown(v || '') : ed.setHTML(v || ''));
|
|
1112
|
+
|
|
1113
|
+
const mount = document.createElement('div');
|
|
1114
|
+
ta.after(mount);
|
|
1115
|
+
ta.style.display = 'none';
|
|
1116
|
+
ta.setAttribute('aria-hidden', 'true');
|
|
1117
|
+
|
|
1118
|
+
const nativeDesc = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value');
|
|
1119
|
+
let raw = ta.value || '';
|
|
1120
|
+
let syncing = false;
|
|
1121
|
+
|
|
1122
|
+
const initial = raw;
|
|
1123
|
+
// `this` is the class fromTextarea was called on (Editor or a subclass).
|
|
1124
|
+
const editor = new this(mount, {
|
|
1125
|
+
width: '100%',
|
|
1126
|
+
...options,
|
|
1127
|
+
content: options.content != null ? options.content
|
|
1128
|
+
: (format === 'markdown' ? markdownToHtml(initial) : initial),
|
|
1129
|
+
});
|
|
1130
|
+
|
|
1131
|
+
Object.defineProperty(ta, 'value', {
|
|
1132
|
+
configurable: true,
|
|
1133
|
+
get() { return raw; },
|
|
1134
|
+
set(v) {
|
|
1135
|
+
raw = v == null ? '' : String(v);
|
|
1136
|
+
nativeDesc.set.call(ta, raw);
|
|
1137
|
+
if (!syncing) writeVal(editor, raw);
|
|
1138
|
+
},
|
|
1139
|
+
});
|
|
1140
|
+
|
|
1141
|
+
const onChange = () => {
|
|
1142
|
+
const next = read(editor);
|
|
1143
|
+
if (raw === next) return;
|
|
1144
|
+
raw = next;
|
|
1145
|
+
syncing = true;
|
|
1146
|
+
nativeDesc.set.call(ta, next);
|
|
1147
|
+
ta.dispatchEvent(new Event('input', { bubbles: true }));
|
|
1148
|
+
ta.dispatchEvent(new Event('change', { bubbles: true }));
|
|
1149
|
+
syncing = false;
|
|
1150
|
+
};
|
|
1151
|
+
editor.on('change', onChange);
|
|
1152
|
+
onChange();
|
|
1153
|
+
|
|
1154
|
+
editor.textarea = ta;
|
|
1155
|
+
editor.getValue = () => read(editor);
|
|
1156
|
+
editor.setValue = (v) => { writeVal(editor, v); };
|
|
1157
|
+
const baseDestroy = editor.destroy.bind(editor);
|
|
1158
|
+
editor.destroy = () => {
|
|
1159
|
+
const last = read(editor);
|
|
1160
|
+
editor.off('change', onChange);
|
|
1161
|
+
baseDestroy();
|
|
1162
|
+
mount.remove();
|
|
1163
|
+
delete ta.value;
|
|
1164
|
+
nativeDesc.set.call(ta, last);
|
|
1165
|
+
ta.style.display = '';
|
|
1166
|
+
ta.removeAttribute('aria-hidden');
|
|
1167
|
+
};
|
|
1168
|
+
return editor;
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
/**
|
|
1172
|
+
* Open the native picker for a non-image attachment, then insert it as a
|
|
1173
|
+
* file chip via the options.file.upload hook.
|
|
1174
|
+
*/
|
|
1175
|
+
openFileAttachmentPicker() {
|
|
1176
|
+
const cfg = this.options.file || {};
|
|
1177
|
+
const sel = window.getSelection();
|
|
1178
|
+
const savedRange = sel && sel.rangeCount ? sel.getRangeAt(0).cloneRange() : null;
|
|
1179
|
+
|
|
1180
|
+
const input = document.createElement('input');
|
|
1181
|
+
input.type = 'file';
|
|
1182
|
+
input.accept = cfg.accept || '*/*';
|
|
1183
|
+
input.style.display = 'none';
|
|
1184
|
+
input.addEventListener('change', () => {
|
|
1185
|
+
const file = input.files && input.files[0];
|
|
1186
|
+
if (file) {
|
|
1187
|
+
this.focus();
|
|
1188
|
+
const s = window.getSelection();
|
|
1189
|
+
if (savedRange) { s.removeAllRanges(); s.addRange(savedRange); }
|
|
1190
|
+
else if (!s.rangeCount || !this.editor.contains(s.anchorNode)) {
|
|
1191
|
+
const r = document.createRange();
|
|
1192
|
+
r.selectNodeContents(this.editor); r.collapse(false);
|
|
1193
|
+
s.removeAllRanges(); s.addRange(r);
|
|
1194
|
+
}
|
|
1195
|
+
this.insertFileAttachment(file);
|
|
1196
|
+
}
|
|
1197
|
+
input.remove();
|
|
1198
|
+
});
|
|
1199
|
+
document.body.appendChild(input);
|
|
1200
|
+
input.click();
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
/**
|
|
1204
|
+
* Insert a non-image File as a "file chip" — a contenteditable=false anchor
|
|
1205
|
+
* (icon + name + size) that serializes to a Markdown link `[name (size)](url)`.
|
|
1206
|
+
* Uploads via options.file.upload(file) -> string url | { url, name, size };
|
|
1207
|
+
* with no hook it falls back to an inline data: URL (like images do).
|
|
1208
|
+
* @param {File} file
|
|
1209
|
+
*/
|
|
1210
|
+
insertFileAttachment(file) {
|
|
1211
|
+
if (!file) return;
|
|
1212
|
+
const cfg = this.options.file || {};
|
|
1213
|
+
|
|
1214
|
+
// Validate accept / maxSize, mirroring insertImageFile.
|
|
1215
|
+
if (cfg.accept && cfg.accept !== '*/*') {
|
|
1216
|
+
const name = (file.name || '').toLowerCase();
|
|
1217
|
+
const ok = cfg.accept.split(',').some(a => {
|
|
1218
|
+
a = a.trim().toLowerCase();
|
|
1219
|
+
if (!a) return false;
|
|
1220
|
+
if (a.startsWith('.')) return name.endsWith(a);
|
|
1221
|
+
if (a.endsWith('/*')) return (file.type || '').startsWith(a.slice(0, -1));
|
|
1222
|
+
return file.type === a;
|
|
1223
|
+
});
|
|
1224
|
+
if (!ok) { this.emit('file:error', { file, reason: 'type' }); return; }
|
|
1225
|
+
}
|
|
1226
|
+
if (cfg.maxSize && file.size > cfg.maxSize) {
|
|
1227
|
+
this.emit('file:error', { file, reason: 'size' });
|
|
1228
|
+
return;
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
const ico = IconUtils && typeof IconUtils.getIcon === 'function'
|
|
1232
|
+
? (IconUtils.getIcon('file') || '') : '';
|
|
1233
|
+
const esc = (s) => String(s == null ? '' : s)
|
|
1234
|
+
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
1235
|
+
|
|
1236
|
+
// Build a chip anchor element. `meta` may override name/size from the hook.
|
|
1237
|
+
const makeChip = (url, meta = {}, state = '') => {
|
|
1238
|
+
const name = meta.name || file.name || 'file';
|
|
1239
|
+
const size = meta.size != null ? meta.size : Editor.formatBytes(file.size);
|
|
1240
|
+
const a = document.createElement('a');
|
|
1241
|
+
a.className = 'yjd-file-chip';
|
|
1242
|
+
a.setAttribute('contenteditable', 'false');
|
|
1243
|
+
a.setAttribute('href', url || '#');
|
|
1244
|
+
a.setAttribute('target', '_blank');
|
|
1245
|
+
a.setAttribute('rel', 'noopener noreferrer');
|
|
1246
|
+
a.setAttribute('data-name', name);
|
|
1247
|
+
if (size) a.setAttribute('data-size', size);
|
|
1248
|
+
if (state) a.setAttribute('data-state', state);
|
|
1249
|
+
// While uploading, the icon slot shows a spinner instead of the file glyph.
|
|
1250
|
+
const icoHTML = state === 'uploading'
|
|
1251
|
+
? '<span class="yjd-spinner" aria-hidden="true"></span>' : ico;
|
|
1252
|
+
a.innerHTML =
|
|
1253
|
+
`<span class="yjd-file-ico" contenteditable="false">${icoHTML}</span>` +
|
|
1254
|
+
`<span class="yjd-file-name">${esc(name)}</span>` +
|
|
1255
|
+
(size ? `<span class="yjd-file-size">${esc(size)}</span>` : '');
|
|
1256
|
+
return a;
|
|
1257
|
+
};
|
|
1258
|
+
|
|
1259
|
+
const insertChipHTML = (html) => {
|
|
1260
|
+
this.focus();
|
|
1261
|
+
execFormat('insertHTML', html + ' ');
|
|
1262
|
+
this.onContentChange();
|
|
1263
|
+
};
|
|
1264
|
+
|
|
1265
|
+
// No upload hook → inline data URL (works offline, persists in the HTML).
|
|
1266
|
+
if (typeof cfg.upload !== 'function') {
|
|
1267
|
+
const reader = new FileReader();
|
|
1268
|
+
reader.onload = (ev) => insertChipHTML(makeChip(ev.target.result).outerHTML);
|
|
1269
|
+
reader.onerror = () => this.emit('file:error', { file, reason: 'read' });
|
|
1270
|
+
reader.readAsDataURL(file);
|
|
1271
|
+
return;
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
// Upload hook: insert a placeholder chip, await the URL, then fill it in.
|
|
1275
|
+
const id = 'rte-file-' + Math.round(performance.now()) + '-' + (this._fileCounter = (this._fileCounter || 0) + 1);
|
|
1276
|
+
const ph = makeChip('#', {}, 'uploading');
|
|
1277
|
+
ph.id = id;
|
|
1278
|
+
ph.style.opacity = '0.6';
|
|
1279
|
+
insertChipHTML(ph.outerHTML);
|
|
1280
|
+
this.emit('file:upload', { file });
|
|
1281
|
+
|
|
1282
|
+
Promise.resolve(cfg.upload(file)).then((res) => {
|
|
1283
|
+
const el = this.editor.querySelector('#' + id);
|
|
1284
|
+
if (!el) return;
|
|
1285
|
+
const url = typeof res === 'string' ? res : (res && res.url);
|
|
1286
|
+
if (!url) { el.remove(); this.onContentChange(); return; }
|
|
1287
|
+
const name = (res && res.name) || el.getAttribute('data-name');
|
|
1288
|
+
const size = (res && res.size) || el.getAttribute('data-size');
|
|
1289
|
+
el.setAttribute('href', url);
|
|
1290
|
+
el.setAttribute('data-name', name);
|
|
1291
|
+
if (size) el.setAttribute('data-size', size);
|
|
1292
|
+
el.style.opacity = '';
|
|
1293
|
+
el.removeAttribute('data-state');
|
|
1294
|
+
el.removeAttribute('id');
|
|
1295
|
+
const icoEl = el.querySelector('.yjd-file-ico');
|
|
1296
|
+
if (icoEl) icoEl.innerHTML = ico; // swap spinner back to the file icon
|
|
1297
|
+
const nameEl = el.querySelector('.yjd-file-name');
|
|
1298
|
+
const sizeEl = el.querySelector('.yjd-file-size');
|
|
1299
|
+
if (nameEl) nameEl.textContent = name;
|
|
1300
|
+
if (sizeEl && size) sizeEl.textContent = size;
|
|
1301
|
+
this.emit('file:uploaded', { file, url, name, size });
|
|
1302
|
+
this.onContentChange();
|
|
1303
|
+
}).catch((err) => {
|
|
1304
|
+
const el = this.editor.querySelector('#' + id);
|
|
1305
|
+
if (el) el.remove();
|
|
1306
|
+
this.emit('file:error', { file, reason: 'upload', error: err });
|
|
1307
|
+
this.onContentChange();
|
|
1308
|
+
});
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1035
1311
|
/**
|
|
1036
1312
|
* Place the caret at the given viewport coordinates (used for drag-drop).
|
|
1037
1313
|
*/
|
|
@@ -1184,6 +1460,25 @@ export default class Editor {
|
|
|
1184
1460
|
try { localStorage.removeItem(cfg.key); } catch (e) { /* ignore */ }
|
|
1185
1461
|
}
|
|
1186
1462
|
|
|
1463
|
+
/**
|
|
1464
|
+
* True when an autocomplete/popup that captures Enter is open (mention, slash
|
|
1465
|
+
* command, emoji). Used by submit.onEnter so Enter chooses the item instead
|
|
1466
|
+
* of submitting. App code can call it too.
|
|
1467
|
+
* @returns {boolean}
|
|
1468
|
+
*/
|
|
1469
|
+
isMenuOpen() {
|
|
1470
|
+
const m = this.modules.get('mention');
|
|
1471
|
+
if (m && m.isOpen) return true;
|
|
1472
|
+
const s = this.modules.get('slash-menu');
|
|
1473
|
+
if (s && s.isOpen) return true;
|
|
1474
|
+
// Any visible portaled popup (emoji, link, image, table…).
|
|
1475
|
+
const sel = '.yjd-mention-menu, .yjd-slash-menu, .emoji-picker-popup.visible, .link-popup, .image-popup, .video-popup, .tag-popup';
|
|
1476
|
+
return [...document.querySelectorAll(sel)].some((el) => {
|
|
1477
|
+
if (!el.offsetParent && getComputedStyle(el).position !== 'fixed') return false;
|
|
1478
|
+
return getComputedStyle(el).display !== 'none';
|
|
1479
|
+
});
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1187
1482
|
/**
|
|
1188
1483
|
* Remove inline formatting (and links) from the current selection.
|
|
1189
1484
|
*/
|
|
@@ -1431,6 +1726,9 @@ export default class Editor {
|
|
|
1431
1726
|
case 'import':
|
|
1432
1727
|
this.toggleFormat(command);
|
|
1433
1728
|
break;
|
|
1729
|
+
case 'file':
|
|
1730
|
+
this.openFileAttachmentPicker();
|
|
1731
|
+
break;
|
|
1434
1732
|
case 'clear-format':
|
|
1435
1733
|
this.clearFormatting();
|
|
1436
1734
|
break;
|
package/lib/modules/mention.js
CHANGED
|
@@ -19,6 +19,9 @@ import Module from '../core/module.js';
|
|
|
19
19
|
* → getMarkdown() emits `@[Name](id)`. Fires editor.on('mention:select', item).
|
|
20
20
|
*/
|
|
21
21
|
export default class Mention extends Module {
|
|
22
|
+
// --rte-* theme vars copied onto the portaled menu when it opens.
|
|
23
|
+
static THEME_VARS = ['--rte-accent', '--rte-accent-ink', '--rte-accent-weak', '--rte-ink', '--rte-muted', '--rte-border', '--rte-bg', '--rte-radius-md', '--rte-shadow'];
|
|
24
|
+
|
|
22
25
|
constructor(editor, options = {}) {
|
|
23
26
|
super(editor, options);
|
|
24
27
|
this.isOpen = false;
|
|
@@ -62,10 +65,14 @@ export default class Mention extends Module {
|
|
|
62
65
|
|
|
63
66
|
this._onKeydown = (e) => {
|
|
64
67
|
if (!this.isOpen) return;
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
68
|
+
// Stop here (capture phase) so an outer Enter-to-submit handler doesn't
|
|
69
|
+
// also fire once choose() closes the menu.
|
|
70
|
+
const handled = ['ArrowDown', 'ArrowUp', 'Enter', 'Tab', 'Escape'].includes(e.key);
|
|
71
|
+
if (handled) { e.preventDefault(); e.stopPropagation(); }
|
|
72
|
+
if (e.key === 'ArrowDown') this.move(1);
|
|
73
|
+
else if (e.key === 'ArrowUp') this.move(-1);
|
|
74
|
+
else if (e.key === 'Enter' || e.key === 'Tab') this.choose(this.activeIndex);
|
|
75
|
+
else if (e.key === 'Escape') this.close();
|
|
69
76
|
};
|
|
70
77
|
this.editor.editor.addEventListener('keydown', this._onKeydown, true);
|
|
71
78
|
|
|
@@ -113,6 +120,7 @@ export default class Mention extends Module {
|
|
|
113
120
|
open(range) {
|
|
114
121
|
this.isOpen = true;
|
|
115
122
|
this.menu.style.display = 'block';
|
|
123
|
+
this._applyTheme();
|
|
116
124
|
const rect = range.getBoundingClientRect();
|
|
117
125
|
const x = rect.left || (range.startContainer.parentElement || this.editor.editor).getBoundingClientRect().left;
|
|
118
126
|
const y = rect.bottom || rect.top;
|
|
@@ -130,6 +138,21 @@ export default class Mention extends Module {
|
|
|
130
138
|
this.menu.style.display = 'none';
|
|
131
139
|
}
|
|
132
140
|
|
|
141
|
+
/**
|
|
142
|
+
* The menu is portaled to <body>, so it can't inherit the editor's --rte-*
|
|
143
|
+
* theme vars. Copy them across when opening so a themed editor themes its
|
|
144
|
+
* mention menu too (no need to re-declare the vars on .yjd-mention-menu).
|
|
145
|
+
*/
|
|
146
|
+
_applyTheme() {
|
|
147
|
+
const root = this.editor.wrapper || this.editor.root;
|
|
148
|
+
if (!root) return;
|
|
149
|
+
const cs = getComputedStyle(root);
|
|
150
|
+
Mention.THEME_VARS.forEach((v) => {
|
|
151
|
+
const val = cs.getPropertyValue(v);
|
|
152
|
+
if (val) this.menu.style.setProperty(v, val.trim());
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
133
156
|
move(d) {
|
|
134
157
|
this.activeIndex = (this.activeIndex + d + this.items.length) % this.items.length;
|
|
135
158
|
[...this.menu.children].forEach((el, i) => {
|
|
@@ -147,9 +170,14 @@ export default class Mention extends Module {
|
|
|
147
170
|
el.setAttribute('role', 'option');
|
|
148
171
|
el.setAttribute('aria-selected', i === this.activeIndex ? 'true' : 'false');
|
|
149
172
|
const label = item.name || item.label || item.id || '';
|
|
173
|
+
// Default row: avatar (or an item.icon for special entries like "@all"),
|
|
174
|
+
// then the name. Apps only need a custom renderItem for richer layouts.
|
|
175
|
+
const media = item.avatar_url
|
|
176
|
+
? `<img class="yjd-mention-avatar" src="${item.avatar_url}" alt="">`
|
|
177
|
+
: (item.icon ? `<span class="yjd-mention-ico">${item.icon}</span>` : '');
|
|
150
178
|
el.innerHTML = typeof renderItem === 'function'
|
|
151
179
|
? renderItem(item)
|
|
152
|
-
: `${
|
|
180
|
+
: `${media}<span class="yjd-mention-name">${this.char}${label}</span>`;
|
|
153
181
|
el.addEventListener('pointerdown', (e) => { e.preventDefault(); this.choose(i); });
|
|
154
182
|
this.menu.appendChild(el);
|
|
155
183
|
});
|
|
@@ -60,10 +60,12 @@ export default class SlashMenu extends Module {
|
|
|
60
60
|
// Keyboard interaction while open (capture so we beat other handlers).
|
|
61
61
|
this._onKeydown = (e) => {
|
|
62
62
|
if (!this.isOpen) return;
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
else if (e.key === '
|
|
63
|
+
const handled = ['ArrowDown', 'ArrowUp', 'Enter', 'Escape'].includes(e.key);
|
|
64
|
+
if (handled) { e.preventDefault(); e.stopPropagation(); }
|
|
65
|
+
if (e.key === 'ArrowDown') this.move(1);
|
|
66
|
+
else if (e.key === 'ArrowUp') this.move(-1);
|
|
67
|
+
else if (e.key === 'Enter') this.choose(this.activeIndex);
|
|
68
|
+
else if (e.key === 'Escape') this.close();
|
|
67
69
|
};
|
|
68
70
|
this.editor.editor.addEventListener('keydown', this._onKeydown, true);
|
|
69
71
|
|