@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/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, keeping textarea.value
151
- * in sync (and dispatching native input/change events) on every edit.
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
- ): RichEditor;
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`). On every change the textarea is updated and a native 'input'
161
- * event is dispatched, so framework bindings / validation keep firing.
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 readEditor = (ed) => (format === 'markdown' ? ed.getMarkdown() : ed.getContent());
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
- const initial = ta.value || '';
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
- const sync = () => {
190
- const next = readEditor(editor);
191
- if (ta.value === next) return;
192
- ta.value = next;
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', sync);
197
- sync(); // normalise the textarea to the editor's serialization up front.
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
  }
@@ -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, await the URL, then swap the src.
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 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';
1052
+ const escName = (file.name || 'image')
1053
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
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', ph.outerHTML);
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
- el.src = url;
1019
- el.style.opacity = '';
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
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 + '&nbsp;');
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;
@@ -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
- 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(); }
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
- : `${item.avatar_url ? `<img class="yjd-mention-avatar" src="${item.avatar_url}" alt="">` : ''}<span class="yjd-mention-name">${this.char}${label}</span>`;
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
- if (e.key === 'ArrowDown') { e.preventDefault(); this.move(1); }
64
- else if (e.key === 'ArrowUp') { e.preventDefault(); this.move(-1); }
65
- else if (e.key === 'Enter') { e.preventDefault(); this.choose(this.activeIndex); }
66
- else if (e.key === 'Escape') { e.preventDefault(); this.close(); }
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
 
@@ -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',