@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/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
- ): RichEditor;
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
- * Progressive-enhance a <textarea>: hide it, mount an editor in its place,
152
- * and keep the textarea's value in sync so existing form submits keep working.
153
- *
154
- * const ed = RichEditor.fromTextarea(document.querySelector('#body'), {
155
- * // any editor option; `format` chooses how the textarea is read/written:
156
- * format: 'html' | 'markdown', // default 'html'
157
- * });
158
- *
159
- * The textarea's current value seeds the editor (parsed as HTML or Markdown
160
- * per `format`). On every change the textarea is updated and a native 'input'
161
- * event is dispatched, so framework bindings / validation keep firing.
162
- *
163
- * @param {HTMLTextAreaElement|string} textarea Element or selector.
164
- * @param {object} [options] Editor options + optional `format`.
165
- * @returns {RichEditor}
166
- */
167
- static fromTextarea(textarea, options = {}) {
168
- const ta = typeof textarea === 'string' ? document.querySelector(textarea) : textarea;
169
- if (!ta) throw new Error('RichEditor.fromTextarea: textarea not found');
170
-
171
- const format = options.format === 'markdown' ? 'markdown' : 'html';
172
- const readEditor = (ed) => (format === 'markdown' ? ed.getMarkdown() : ed.getContent());
173
-
174
- // Mount point right after the textarea; hide the original.
175
- const mount = document.createElement('div');
176
- ta.after(mount);
177
- ta.style.display = 'none';
178
- ta.setAttribute('aria-hidden', 'true');
179
-
180
- const initial = ta.value || '';
181
- const editor = new RichEditor(mount, {
182
- width: '100%',
183
- ...options,
184
- // Seed content from the textarea (skip if caller passed explicit content).
185
- content: options.content != null ? options.content
186
- : (format === 'markdown' ? markdownToHtml(initial) : initial),
187
- });
188
-
189
- const sync = () => {
190
- const next = readEditor(editor);
191
- if (ta.value === next) return;
192
- ta.value = next;
193
- ta.dispatchEvent(new Event('input', { bubbles: true }));
194
- ta.dispatchEvent(new Event('change', { bubbles: true }));
195
- };
196
- editor.on('change', sync);
197
- sync(); // normalise the textarea to the editor's serialization up front.
198
-
199
- editor.textarea = ta;
200
- return editor;
201
- }
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`
@@ -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,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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
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 + '&nbsp;');
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;
@@ -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