@oix1987/yjd 2.1.0 → 2.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.
- package/README.md +61 -4
- 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 +70 -3
- package/index.js +53 -11
- package/lib/core/editor.js +231 -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,10 @@ export interface EditorOptions {
|
|
|
104
149
|
|
|
105
150
|
export class Editor {
|
|
106
151
|
constructor(selector: string | Element, options?: EditorOptions);
|
|
152
|
+
/** The contentEditable element (public — apps may attach listeners to it). */
|
|
153
|
+
editor: HTMLElement;
|
|
107
154
|
on(event: string, handler: (data: any) => void): void;
|
|
155
|
+
/** Remove a previously-added listener (symmetric with on()). */
|
|
108
156
|
off(event: string, handler: (data: any) => void): void;
|
|
109
157
|
emit(event: string, data: any): void;
|
|
110
158
|
getContent(): string;
|
|
@@ -126,6 +174,12 @@ export class Editor {
|
|
|
126
174
|
clearFormatting(): void;
|
|
127
175
|
insertHorizontalRule(): void;
|
|
128
176
|
insertImageFile(file: File): void;
|
|
177
|
+
/** Insert a non-image File as a file chip (uses options.file.upload). */
|
|
178
|
+
insertFileAttachment(file: File): void;
|
|
179
|
+
/** Open the native picker for a file attachment. */
|
|
180
|
+
openFileAttachmentPicker(): void;
|
|
181
|
+
/** True when a mention/slash/emoji popup that captures Enter is open. */
|
|
182
|
+
isMenuOpen(): boolean;
|
|
129
183
|
setReadOnly(readOnly: boolean): void;
|
|
130
184
|
isReadOnly(): boolean;
|
|
131
185
|
setDirection(dir: 'ltr' | 'rtl'): void;
|
|
@@ -147,17 +201,30 @@ export class RichEditor extends Editor {
|
|
|
147
201
|
static get(path: string): any;
|
|
148
202
|
static create(selector: string | Element, options?: EditorOptions): RichEditor;
|
|
149
203
|
/**
|
|
150
|
-
* Progressive-enhance a <textarea> into an editor
|
|
151
|
-
*
|
|
204
|
+
* Progressive-enhance a <textarea> into an editor with TWO-WAY sync (editor
|
|
205
|
+
* edits update textarea.value + fire native events; writing textarea.value
|
|
206
|
+
* updates the editor). The returned editor also exposes a controller:
|
|
207
|
+
* getValue()/setValue()/destroy() (destroy restores the textarea).
|
|
152
208
|
*/
|
|
153
209
|
static fromTextarea(
|
|
154
210
|
textarea: HTMLTextAreaElement | string,
|
|
155
211
|
options?: EditorOptions & { format?: 'html' | 'markdown' }
|
|
156
|
-
):
|
|
212
|
+
): TextareaEditor;
|
|
157
213
|
/** The original textarea, when created via fromTextarea(). */
|
|
158
214
|
textarea?: HTMLTextAreaElement;
|
|
159
215
|
}
|
|
160
216
|
|
|
217
|
+
/** Editor returned by fromTextarea(), with a value controller. */
|
|
218
|
+
export interface TextareaEditor extends RichEditor {
|
|
219
|
+
textarea: HTMLTextAreaElement;
|
|
220
|
+
/** Current content (HTML or Markdown per the `format` option). */
|
|
221
|
+
getValue(): string;
|
|
222
|
+
/** Replace the editor content (HTML or Markdown per `format`). */
|
|
223
|
+
setValue(value: string): void;
|
|
224
|
+
/** Remove the editor and restore the textarea with its last value. */
|
|
225
|
+
destroy(): void;
|
|
226
|
+
}
|
|
227
|
+
|
|
161
228
|
/** Brand-aligned alias of {@link RichEditor}. */
|
|
162
229
|
export { RichEditor as yjd };
|
|
163
230
|
|
package/index.js
CHANGED
|
@@ -157,8 +157,15 @@ class RichEditor extends Editor {
|
|
|
157
157
|
* });
|
|
158
158
|
*
|
|
159
159
|
* The textarea's current value seeds the editor (parsed as HTML or Markdown
|
|
160
|
-
* per `format`).
|
|
161
|
-
*
|
|
160
|
+
* per `format`). Binding is TWO-WAY: editor edits update textarea.value (and
|
|
161
|
+
* fire native input/change events), and writing `textarea.value = …` from app
|
|
162
|
+
* code (e.g. resetting a form) updates the editor. The returned editor also
|
|
163
|
+
* carries a small controller:
|
|
164
|
+
*
|
|
165
|
+
* const ed = RichEditor.fromTextarea('#body', { format: 'markdown' });
|
|
166
|
+
* ed.setValue(md); // load new content into the editor
|
|
167
|
+
* ed.getValue(); // current content (html or markdown per `format`)
|
|
168
|
+
* ed.destroy(); // remove the editor, restore the textarea + last value
|
|
162
169
|
*
|
|
163
170
|
* @param {HTMLTextAreaElement|string} textarea Element or selector.
|
|
164
171
|
* @param {object} [options] Editor options + optional `format`.
|
|
@@ -169,7 +176,8 @@ class RichEditor extends Editor {
|
|
|
169
176
|
if (!ta) throw new Error('RichEditor.fromTextarea: textarea not found');
|
|
170
177
|
|
|
171
178
|
const format = options.format === 'markdown' ? 'markdown' : 'html';
|
|
172
|
-
const
|
|
179
|
+
const read = (ed) => (format === 'markdown' ? ed.getMarkdown() : ed.getContent());
|
|
180
|
+
const write = (ed, v) => (format === 'markdown' ? ed.setMarkdown(v || '') : ed.setHTML(v || ''));
|
|
173
181
|
|
|
174
182
|
// Mount point right after the textarea; hide the original.
|
|
175
183
|
const mount = document.createElement('div');
|
|
@@ -177,26 +185,60 @@ class RichEditor extends Editor {
|
|
|
177
185
|
ta.style.display = 'none';
|
|
178
186
|
ta.setAttribute('aria-hidden', 'true');
|
|
179
187
|
|
|
180
|
-
|
|
188
|
+
// Take over textarea.value so app writes flow into the editor and reads
|
|
189
|
+
// reflect it. Keep the native descriptor to restore on destroy + to set the
|
|
190
|
+
// real value (for form submission) without re-triggering our setter.
|
|
191
|
+
const nativeDesc = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value');
|
|
192
|
+
let raw = ta.value || '';
|
|
193
|
+
let syncing = false; // guards editor→textarea writes from re-entering setValue
|
|
194
|
+
|
|
195
|
+
const initial = raw;
|
|
181
196
|
const editor = new RichEditor(mount, {
|
|
182
197
|
width: '100%',
|
|
183
198
|
...options,
|
|
184
|
-
// Seed content from the textarea (skip if caller passed explicit content).
|
|
185
199
|
content: options.content != null ? options.content
|
|
186
200
|
: (format === 'markdown' ? markdownToHtml(initial) : initial),
|
|
187
201
|
});
|
|
188
202
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
203
|
+
Object.defineProperty(ta, 'value', {
|
|
204
|
+
configurable: true,
|
|
205
|
+
get() { return raw; },
|
|
206
|
+
set(v) {
|
|
207
|
+
raw = v == null ? '' : String(v);
|
|
208
|
+
nativeDesc.set.call(ta, raw); // keep the real textarea value for submits
|
|
209
|
+
if (!syncing) write(editor, raw); // app write → update editor
|
|
210
|
+
},
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
// editor edit → push to textarea + fire native events for app bindings.
|
|
214
|
+
const onChange = () => {
|
|
215
|
+
const next = read(editor);
|
|
216
|
+
if (raw === next) return;
|
|
217
|
+
raw = next;
|
|
218
|
+
syncing = true;
|
|
219
|
+
nativeDesc.set.call(ta, next);
|
|
193
220
|
ta.dispatchEvent(new Event('input', { bubbles: true }));
|
|
194
221
|
ta.dispatchEvent(new Event('change', { bubbles: true }));
|
|
222
|
+
syncing = false;
|
|
195
223
|
};
|
|
196
|
-
editor.on('change',
|
|
197
|
-
|
|
224
|
+
editor.on('change', onChange);
|
|
225
|
+
onChange(); // normalise textarea to the editor's serialization up front.
|
|
198
226
|
|
|
227
|
+
// Controller surface on the editor instance.
|
|
199
228
|
editor.textarea = ta;
|
|
229
|
+
editor.getValue = () => read(editor);
|
|
230
|
+
editor.setValue = (v) => { write(editor, v); };
|
|
231
|
+
const baseDestroy = editor.destroy.bind(editor);
|
|
232
|
+
editor.destroy = () => {
|
|
233
|
+
const last = read(editor);
|
|
234
|
+
editor.off('change', onChange);
|
|
235
|
+
baseDestroy();
|
|
236
|
+
mount.remove();
|
|
237
|
+
delete ta.value; // restore the prototype's value accessor
|
|
238
|
+
nativeDesc.set.call(ta, last);
|
|
239
|
+
ta.style.display = '';
|
|
240
|
+
ta.removeAttribute('aria-hidden');
|
|
241
|
+
};
|
|
200
242
|
return editor;
|
|
201
243
|
}
|
|
202
244
|
}
|
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,157 @@ 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
|
+
* Open the native picker for a non-image attachment, then insert it as a
|
|
1095
|
+
* file chip via the options.file.upload hook.
|
|
1096
|
+
*/
|
|
1097
|
+
openFileAttachmentPicker() {
|
|
1098
|
+
const cfg = this.options.file || {};
|
|
1099
|
+
const sel = window.getSelection();
|
|
1100
|
+
const savedRange = sel && sel.rangeCount ? sel.getRangeAt(0).cloneRange() : null;
|
|
1101
|
+
|
|
1102
|
+
const input = document.createElement('input');
|
|
1103
|
+
input.type = 'file';
|
|
1104
|
+
input.accept = cfg.accept || '*/*';
|
|
1105
|
+
input.style.display = 'none';
|
|
1106
|
+
input.addEventListener('change', () => {
|
|
1107
|
+
const file = input.files && input.files[0];
|
|
1108
|
+
if (file) {
|
|
1109
|
+
this.focus();
|
|
1110
|
+
const s = window.getSelection();
|
|
1111
|
+
if (savedRange) { s.removeAllRanges(); s.addRange(savedRange); }
|
|
1112
|
+
else if (!s.rangeCount || !this.editor.contains(s.anchorNode)) {
|
|
1113
|
+
const r = document.createRange();
|
|
1114
|
+
r.selectNodeContents(this.editor); r.collapse(false);
|
|
1115
|
+
s.removeAllRanges(); s.addRange(r);
|
|
1116
|
+
}
|
|
1117
|
+
this.insertFileAttachment(file);
|
|
1118
|
+
}
|
|
1119
|
+
input.remove();
|
|
1120
|
+
});
|
|
1121
|
+
document.body.appendChild(input);
|
|
1122
|
+
input.click();
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
/**
|
|
1126
|
+
* Insert a non-image File as a "file chip" — a contenteditable=false anchor
|
|
1127
|
+
* (icon + name + size) that serializes to a Markdown link `[name (size)](url)`.
|
|
1128
|
+
* Uploads via options.file.upload(file) -> string url | { url, name, size };
|
|
1129
|
+
* with no hook it falls back to an inline data: URL (like images do).
|
|
1130
|
+
* @param {File} file
|
|
1131
|
+
*/
|
|
1132
|
+
insertFileAttachment(file) {
|
|
1133
|
+
if (!file) return;
|
|
1134
|
+
const cfg = this.options.file || {};
|
|
1135
|
+
|
|
1136
|
+
// Validate accept / maxSize, mirroring insertImageFile.
|
|
1137
|
+
if (cfg.accept && cfg.accept !== '*/*') {
|
|
1138
|
+
const name = (file.name || '').toLowerCase();
|
|
1139
|
+
const ok = cfg.accept.split(',').some(a => {
|
|
1140
|
+
a = a.trim().toLowerCase();
|
|
1141
|
+
if (!a) return false;
|
|
1142
|
+
if (a.startsWith('.')) return name.endsWith(a);
|
|
1143
|
+
if (a.endsWith('/*')) return (file.type || '').startsWith(a.slice(0, -1));
|
|
1144
|
+
return file.type === a;
|
|
1145
|
+
});
|
|
1146
|
+
if (!ok) { this.emit('file:error', { file, reason: 'type' }); return; }
|
|
1147
|
+
}
|
|
1148
|
+
if (cfg.maxSize && file.size > cfg.maxSize) {
|
|
1149
|
+
this.emit('file:error', { file, reason: 'size' });
|
|
1150
|
+
return;
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
const ico = IconUtils && typeof IconUtils.getIcon === 'function'
|
|
1154
|
+
? (IconUtils.getIcon('file') || '') : '';
|
|
1155
|
+
const esc = (s) => String(s == null ? '' : s)
|
|
1156
|
+
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
1157
|
+
|
|
1158
|
+
// Build a chip anchor element. `meta` may override name/size from the hook.
|
|
1159
|
+
const makeChip = (url, meta = {}, state = '') => {
|
|
1160
|
+
const name = meta.name || file.name || 'file';
|
|
1161
|
+
const size = meta.size != null ? meta.size : Editor.formatBytes(file.size);
|
|
1162
|
+
const a = document.createElement('a');
|
|
1163
|
+
a.className = 'yjd-file-chip';
|
|
1164
|
+
a.setAttribute('contenteditable', 'false');
|
|
1165
|
+
a.setAttribute('href', url || '#');
|
|
1166
|
+
a.setAttribute('target', '_blank');
|
|
1167
|
+
a.setAttribute('rel', 'noopener noreferrer');
|
|
1168
|
+
a.setAttribute('data-name', name);
|
|
1169
|
+
if (size) a.setAttribute('data-size', size);
|
|
1170
|
+
if (state) a.setAttribute('data-state', state);
|
|
1171
|
+
// While uploading, the icon slot shows a spinner instead of the file glyph.
|
|
1172
|
+
const icoHTML = state === 'uploading'
|
|
1173
|
+
? '<span class="yjd-spinner" aria-hidden="true"></span>' : ico;
|
|
1174
|
+
a.innerHTML =
|
|
1175
|
+
`<span class="yjd-file-ico" contenteditable="false">${icoHTML}</span>` +
|
|
1176
|
+
`<span class="yjd-file-name">${esc(name)}</span>` +
|
|
1177
|
+
(size ? `<span class="yjd-file-size">${esc(size)}</span>` : '');
|
|
1178
|
+
return a;
|
|
1179
|
+
};
|
|
1180
|
+
|
|
1181
|
+
const insertChipHTML = (html) => {
|
|
1182
|
+
this.focus();
|
|
1183
|
+
execFormat('insertHTML', html + ' ');
|
|
1184
|
+
this.onContentChange();
|
|
1185
|
+
};
|
|
1186
|
+
|
|
1187
|
+
// No upload hook → inline data URL (works offline, persists in the HTML).
|
|
1188
|
+
if (typeof cfg.upload !== 'function') {
|
|
1189
|
+
const reader = new FileReader();
|
|
1190
|
+
reader.onload = (ev) => insertChipHTML(makeChip(ev.target.result).outerHTML);
|
|
1191
|
+
reader.onerror = () => this.emit('file:error', { file, reason: 'read' });
|
|
1192
|
+
reader.readAsDataURL(file);
|
|
1193
|
+
return;
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
// Upload hook: insert a placeholder chip, await the URL, then fill it in.
|
|
1197
|
+
const id = 'rte-file-' + Math.round(performance.now()) + '-' + (this._fileCounter = (this._fileCounter || 0) + 1);
|
|
1198
|
+
const ph = makeChip('#', {}, 'uploading');
|
|
1199
|
+
ph.id = id;
|
|
1200
|
+
ph.style.opacity = '0.6';
|
|
1201
|
+
insertChipHTML(ph.outerHTML);
|
|
1202
|
+
this.emit('file:upload', { file });
|
|
1203
|
+
|
|
1204
|
+
Promise.resolve(cfg.upload(file)).then((res) => {
|
|
1205
|
+
const el = this.editor.querySelector('#' + id);
|
|
1206
|
+
if (!el) return;
|
|
1207
|
+
const url = typeof res === 'string' ? res : (res && res.url);
|
|
1208
|
+
if (!url) { el.remove(); this.onContentChange(); return; }
|
|
1209
|
+
const name = (res && res.name) || el.getAttribute('data-name');
|
|
1210
|
+
const size = (res && res.size) || el.getAttribute('data-size');
|
|
1211
|
+
el.setAttribute('href', url);
|
|
1212
|
+
el.setAttribute('data-name', name);
|
|
1213
|
+
if (size) el.setAttribute('data-size', size);
|
|
1214
|
+
el.style.opacity = '';
|
|
1215
|
+
el.removeAttribute('data-state');
|
|
1216
|
+
el.removeAttribute('id');
|
|
1217
|
+
const icoEl = el.querySelector('.yjd-file-ico');
|
|
1218
|
+
if (icoEl) icoEl.innerHTML = ico; // swap spinner back to the file icon
|
|
1219
|
+
const nameEl = el.querySelector('.yjd-file-name');
|
|
1220
|
+
const sizeEl = el.querySelector('.yjd-file-size');
|
|
1221
|
+
if (nameEl) nameEl.textContent = name;
|
|
1222
|
+
if (sizeEl && size) sizeEl.textContent = size;
|
|
1223
|
+
this.emit('file:uploaded', { file, url, name, size });
|
|
1224
|
+
this.onContentChange();
|
|
1225
|
+
}).catch((err) => {
|
|
1226
|
+
const el = this.editor.querySelector('#' + id);
|
|
1227
|
+
if (el) el.remove();
|
|
1228
|
+
this.emit('file:error', { file, reason: 'upload', error: err });
|
|
1229
|
+
this.onContentChange();
|
|
1230
|
+
});
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1035
1233
|
/**
|
|
1036
1234
|
* Place the caret at the given viewport coordinates (used for drag-drop).
|
|
1037
1235
|
*/
|
|
@@ -1184,6 +1382,25 @@ export default class Editor {
|
|
|
1184
1382
|
try { localStorage.removeItem(cfg.key); } catch (e) { /* ignore */ }
|
|
1185
1383
|
}
|
|
1186
1384
|
|
|
1385
|
+
/**
|
|
1386
|
+
* True when an autocomplete/popup that captures Enter is open (mention, slash
|
|
1387
|
+
* command, emoji). Used by submit.onEnter so Enter chooses the item instead
|
|
1388
|
+
* of submitting. App code can call it too.
|
|
1389
|
+
* @returns {boolean}
|
|
1390
|
+
*/
|
|
1391
|
+
isMenuOpen() {
|
|
1392
|
+
const m = this.modules.get('mention');
|
|
1393
|
+
if (m && m.isOpen) return true;
|
|
1394
|
+
const s = this.modules.get('slash-menu');
|
|
1395
|
+
if (s && s.isOpen) return true;
|
|
1396
|
+
// Any visible portaled popup (emoji, link, image, table…).
|
|
1397
|
+
const sel = '.yjd-mention-menu, .yjd-slash-menu, .emoji-picker-popup.visible, .link-popup, .image-popup, .video-popup, .tag-popup';
|
|
1398
|
+
return [...document.querySelectorAll(sel)].some((el) => {
|
|
1399
|
+
if (!el.offsetParent && getComputedStyle(el).position !== 'fixed') return false;
|
|
1400
|
+
return getComputedStyle(el).display !== 'none';
|
|
1401
|
+
});
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1187
1404
|
/**
|
|
1188
1405
|
* Remove inline formatting (and links) from the current selection.
|
|
1189
1406
|
*/
|
|
@@ -1431,6 +1648,9 @@ export default class Editor {
|
|
|
1431
1648
|
case 'import':
|
|
1432
1649
|
this.toggleFormat(command);
|
|
1433
1650
|
break;
|
|
1651
|
+
case 'file':
|
|
1652
|
+
this.openFileAttachmentPicker();
|
|
1653
|
+
break;
|
|
1434
1654
|
case 'clear-format':
|
|
1435
1655
|
this.clearFormatting();
|
|
1436
1656
|
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
|
|
package/lib/modules/toolbar.js
CHANGED
|
@@ -49,6 +49,33 @@ class Toolbar extends Module {
|
|
|
49
49
|
],
|
|
50
50
|
toolbar2: []
|
|
51
51
|
};
|
|
52
|
+
} else if (options.toolbar === 'full') {
|
|
53
|
+
// Explicit full preset == the defaults.
|
|
54
|
+
this.options = { ...Toolbar.DEFAULTS, ...options };
|
|
55
|
+
} else if (options.toolbar === 'compact') {
|
|
56
|
+
// One tidy row of the essentials — good for comment boxes.
|
|
57
|
+
this.options = {
|
|
58
|
+
container: null,
|
|
59
|
+
toolbar1: [
|
|
60
|
+
{ group: 'text-format', items: ['bold', 'italic', 'underline'] },
|
|
61
|
+
{ group: 'link', items: ['link'] },
|
|
62
|
+
{ group: 'paragraph-ops', items: ['list'] },
|
|
63
|
+
{ group: 'insert', items: ['image', 'emoji'] },
|
|
64
|
+
{ group: 'more', items: ['more'] }
|
|
65
|
+
],
|
|
66
|
+
toolbar2: []
|
|
67
|
+
};
|
|
68
|
+
} else if (options.toolbar && typeof options.toolbar === 'object' && Array.isArray(options.toolbar.exclude)) {
|
|
69
|
+
// Start from the defaults and drop the named items (and any group left empty).
|
|
70
|
+
const drop = new Set(options.toolbar.exclude);
|
|
71
|
+
const prune = (rows) => (rows || [])
|
|
72
|
+
.map(g => ({ ...g, items: g.items.filter(it => !drop.has(it)) }))
|
|
73
|
+
.filter(g => g.items.length && !(g.items.length === 1 && g.items[0] === 'more' && false));
|
|
74
|
+
this.options = {
|
|
75
|
+
container: null,
|
|
76
|
+
toolbar1: prune(Toolbar.DEFAULTS.toolbar1),
|
|
77
|
+
toolbar2: prune(Toolbar.DEFAULTS.toolbar2)
|
|
78
|
+
};
|
|
52
79
|
} else if (options.toolbar1 || options.toolbar2) {
|
|
53
80
|
// If specific toolbar1/toolbar2 config is provided, use it - COMPLETELY OVERRIDE DEFAULTS
|
|
54
81
|
this.options = {
|
|
@@ -415,6 +442,7 @@ class Toolbar extends Module {
|
|
|
415
442
|
'indent-decrease': 'Decrease Indent',
|
|
416
443
|
'emoji': 'Insert Emoji',
|
|
417
444
|
'image': 'Insert Image',
|
|
445
|
+
'file': 'Attach File',
|
|
418
446
|
'video': 'Insert Video',
|
|
419
447
|
'tag': 'Insert Tag',
|
|
420
448
|
'horizontal-rule': 'Insert Horizontal Rule',
|