@oix1987/yjd 2.0.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.
@@ -2,6 +2,8 @@ import registry from './registry.js';
2
2
  import Module from './module.js';
3
3
  import { execFormat, queryFormatState } from '../utils/exec-command.js';
4
4
  import { sanitizeHtml } from '../utils/sanitize.js';
5
+ import { htmlToMarkdown, markdownToHtml, domToJson, jsonToHtml } from '../serialize.js';
6
+ import IconUtils from '../ui/icons.js';
5
7
 
6
8
  /**
7
9
  * Main Editor class - Inspired by Quill's architecture
@@ -253,9 +255,15 @@ export default class Editor {
253
255
  modulesToLoad = this.options.modules || ['toolbar', 'history'];
254
256
  } else {
255
257
  // No toolbar config - load full feature set
256
- modulesToLoad = this.options.modules || ['toolbar', 'history', 'block-toolbar', 'table-toolbar', 'code-view', 'theme-switcher', 'resize-handles', 'find-replace', 'slash-menu'];
258
+ modulesToLoad = this.options.modules || ['toolbar', 'history', 'block-toolbar', 'table-toolbar', 'code-view', 'theme-switcher', 'resize-handles', 'find-replace', 'slash-menu', 'mention'];
257
259
  }
258
-
260
+
261
+ // @mention is inert without a source, so load it whenever configured —
262
+ // even alongside a custom toolbar that otherwise loads only basics.
263
+ if (this.options.mention && !modulesToLoad.includes('mention')) {
264
+ modulesToLoad.push('mention');
265
+ }
266
+
259
267
 
260
268
  modulesToLoad.forEach(moduleName => {
261
269
  const ModuleClass = this.registry.get(`modules/${moduleName}`);
@@ -389,15 +397,24 @@ export default class Editor {
389
397
  this.handlePaste(e);
390
398
  });
391
399
 
392
- // 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.
393
402
  this.editor.addEventListener('dragover', (e) => {
394
403
  if (e.dataTransfer && Array.from(e.dataTransfer.types || []).includes('Files')) {
395
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');
396
412
  }
397
413
  });
398
414
 
399
415
  // Handle drop events (drag and drop) — insert dropped image files
400
416
  this.editor.addEventListener('drop', (e) => {
417
+ this.editor.classList.remove('yjd-drag-over');
401
418
  const dt = e.dataTransfer;
402
419
  const files = dt && dt.files ? Array.from(dt.files) : [];
403
420
  const imageFile = files.find(f => f.type && f.type.startsWith('image/'));
@@ -407,6 +424,14 @@ export default class Editor {
407
424
  this.insertImageFile(imageFile);
408
425
  return;
409
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
+ }
410
435
  // Check content after a normal drop operation
411
436
  setTimeout(() => {
412
437
  this.ensureEditorHasContent();
@@ -435,6 +460,20 @@ export default class Editor {
435
460
  });
436
461
  }
437
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
+
438
477
  // Handle cut events
439
478
  this.editor.addEventListener('cut', () => {
440
479
  // Check content after cut operation
@@ -500,7 +539,22 @@ export default class Editor {
500
539
  // Persist draft if autosave is enabled
501
540
  this._scheduleAutosave(content);
502
541
 
503
- // Emit text-change event
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
+
555
+ // Emit change events ('change' is the documented name; 'text-change' kept
556
+ // for backward compatibility).
557
+ this.emit('change', content);
504
558
  this.emit('text-change', content);
505
559
  }
506
560
 
@@ -823,6 +877,23 @@ export default class Editor {
823
877
  this.onContentChange();
824
878
  }
825
879
 
880
+ /* ----- Export / import in common formats (HTML · JSON · Markdown) ----- */
881
+
882
+ /** HTML string (alias of getContent). */
883
+ getHTML() { return this.getContent(); }
884
+ /** Set content from an HTML string (sanitised). */
885
+ setHTML(html) { this.setContent(sanitizeHtml(html || '')); }
886
+
887
+ /** Structured JSON document `{ type:'doc', content:[…] }`. */
888
+ getJSON() { return domToJson(this.getContent()); }
889
+ /** Set content from a JSON document (produced by getJSON). */
890
+ setJSON(json) { this.setContent(sanitizeHtml(jsonToHtml(json))); }
891
+
892
+ /** Markdown string. */
893
+ getMarkdown() { return htmlToMarkdown(this.getContent()); }
894
+ /** Set content from a Markdown string. */
895
+ setMarkdown(md) { this.setContent(sanitizeHtml(markdownToHtml(md || ''))); }
896
+
826
897
  /**
827
898
  * Get the plain text content of the editor (no markup).
828
899
  * @returns {string}
@@ -934,23 +1005,229 @@ export default class Editor {
934
1005
  */
935
1006
  insertImageFile(file) {
936
1007
  if (!file || !file.type || !file.type.startsWith('image/')) return;
937
- const reader = new FileReader();
938
- reader.onload = (ev) => {
939
- const dataUrl = ev.target.result;
1008
+
1009
+ const cfg = this.options.image || {};
1010
+ // Validate against optional accept / maxSize before doing anything.
1011
+ if (cfg.accept && cfg.accept !== 'image/*') {
1012
+ const ok = cfg.accept.split(',').some(a => {
1013
+ a = a.trim();
1014
+ return a.endsWith('/*') ? file.type.startsWith(a.slice(0, -1)) : file.type === a;
1015
+ });
1016
+ if (!ok) { this.emit('image:error', { file, reason: 'type' }); return; }
1017
+ }
1018
+ if (cfg.maxSize && file.size > cfg.maxSize) {
1019
+ this.emit('image:error', { file, reason: 'size' });
1020
+ return;
1021
+ }
1022
+
1023
+ // Build a real <img> from a src (validates via the Image format).
1024
+ const makeImg = (src, extra = '') => {
940
1025
  const ImageClass = this.registry.get('formats/image');
941
- let imgHtml;
942
1026
  if (ImageClass && typeof ImageClass.create === 'function') {
943
- const img = ImageClass.create(dataUrl); // validates the data: URL
944
- if (!img) return;
945
- imgHtml = img.outerHTML;
1027
+ const img = ImageClass.create(src);
1028
+ if (!img) return null;
1029
+ if (extra) img.setAttribute('data-state', extra);
1030
+ return img;
1031
+ }
1032
+ return null;
1033
+ };
1034
+
1035
+ // No upload hook → embed as base64 (backward-compatible default).
1036
+ if (typeof cfg.upload !== 'function') {
1037
+ const reader = new FileReader();
1038
+ reader.onload = (ev) => {
1039
+ const html = (makeImg(ev.target.result) || {}).outerHTML
1040
+ || `<img src="${ev.target.result}" class="inserted-image" style="max-width:100%" contenteditable="false">`;
1041
+ this.focus();
1042
+ execFormat('insertHTML', html);
1043
+ this.onContentChange();
1044
+ };
1045
+ reader.readAsDataURL(file);
1046
+ return;
1047
+ }
1048
+
1049
+ // Upload hook: insert a visible loading placeholder (spinner + filename),
1050
+ // await the URL, then replace the placeholder with the real <img>.
1051
+ const placeholderId = 'rte-up-' + Math.round(performance.now()) + '-' + (this._upCounter = (this._upCounter || 0) + 1);
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>`;
1059
+ this.focus();
1060
+ execFormat('insertHTML', phHTML);
1061
+ this.emit('image:upload', { file });
1062
+
1063
+ Promise.resolve(cfg.upload(file)).then((url) => {
1064
+ const el = this.editor.querySelector('#' + placeholderId);
1065
+ if (!el) return;
1066
+ if (url) {
1067
+ const img = makeImg(url);
1068
+ if (img) { el.replaceWith(img); } else { el.remove(); }
1069
+ this.emit('image:uploaded', { file, url });
946
1070
  } else {
947
- imgHtml = `<img src="${dataUrl}" class="inserted-image" style="max-width:100%" contenteditable="false">`;
1071
+ el.remove();
1072
+ }
1073
+ this.onContentChange();
1074
+ }).catch((err) => {
1075
+ const el = this.editor.querySelector('#' + placeholderId);
1076
+ if (el) el.remove();
1077
+ this.emit('image:error', { file, reason: 'upload', error: err });
1078
+ this.onContentChange();
1079
+ });
1080
+ }
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);
948
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) => {
949
1182
  this.focus();
950
- execFormat('insertHTML', imgHtml);
1183
+ execFormat('insertHTML', html + '&nbsp;');
951
1184
  this.onContentChange();
952
1185
  };
953
- reader.readAsDataURL(file);
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
+ });
954
1231
  }
955
1232
 
956
1233
  /**
@@ -1105,6 +1382,25 @@ export default class Editor {
1105
1382
  try { localStorage.removeItem(cfg.key); } catch (e) { /* ignore */ }
1106
1383
  }
1107
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
+
1108
1404
  /**
1109
1405
  * Remove inline formatting (and links) from the current selection.
1110
1406
  */
@@ -1352,6 +1648,9 @@ export default class Editor {
1352
1648
  case 'import':
1353
1649
  this.toggleFormat(command);
1354
1650
  break;
1651
+ case 'file':
1652
+ this.openFileAttachmentPicker();
1653
+ break;
1355
1654
  case 'clear-format':
1356
1655
  this.clearFormatting();
1357
1656
  break;
@@ -165,32 +165,26 @@ class Image extends InlineFormat {
165
165
 
166
166
  const input = document.createElement('input');
167
167
  input.type = 'file';
168
- input.accept = 'image/*';
168
+ input.accept = (editor.options.image && editor.options.image.accept) || 'image/*';
169
169
  input.style.display = 'none';
170
- input.addEventListener('change', async () => {
170
+ input.addEventListener('change', () => {
171
171
  const file = input.files && input.files[0];
172
172
  if (file) {
173
- try {
174
- const src = await Image.handleFileUpload(file);
175
- editor.focus();
176
- const sel = window.getSelection();
177
- // Restore the caret we had before the file dialog stole focus. If we
178
- // never had one (clicking the toolbar can collapse the selection),
179
- // drop the caret at the end of the editor so insertion still works.
180
- if (savedRange) {
181
- sel.removeAllRanges();
182
- sel.addRange(savedRange);
183
- } else if (!sel.rangeCount || !editor.editor.contains(sel.anchorNode)) {
184
- const r = document.createRange();
185
- r.selectNodeContents(editor.editor);
186
- r.collapse(false);
187
- sel.removeAllRanges();
188
- sel.addRange(r);
189
- }
190
- Image.insertImageAtCurrentPosition(src, file.name || '', this.editorId);
191
- } catch (err) {
192
- console.warn('Image upload failed:', err.message);
173
+ // Restore the caret captured before the file dialog stole focus.
174
+ editor.focus();
175
+ const sel = window.getSelection();
176
+ if (savedRange) {
177
+ sel.removeAllRanges();
178
+ sel.addRange(savedRange);
179
+ } else if (!sel.rangeCount || !editor.editor.contains(sel.anchorNode)) {
180
+ const r = document.createRange();
181
+ r.selectNodeContents(editor.editor);
182
+ r.collapse(false);
183
+ sel.removeAllRanges();
184
+ sel.addRange(r);
193
185
  }
186
+ // Single insertion path → honours the image.upload hook + validation.
187
+ editor.insertImageFile(file);
194
188
  }
195
189
  input.remove();
196
190
  });