@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.
- package/README.md +140 -6
- package/core.js +5 -0
- 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 +163 -0
- package/index.js +113 -1
- package/lib/core/editor.js +313 -14
- package/lib/formats/image.js +16 -22
- package/lib/modules/mention.js +228 -0
- package/lib/modules/slash-menu.js +6 -4
- package/lib/modules/toolbar.js +28 -0
- package/lib/serialize.js +248 -0
- package/lib/static.js +28 -0
- package/lib/styles.css +182 -0
- 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/umd-entry.js +6 -5
package/lib/core/editor.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
|
|
938
|
-
|
|
939
|
-
|
|
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(
|
|
944
|
-
if (!img) return;
|
|
945
|
-
|
|
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, '&').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>`;
|
|
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
|
-
|
|
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, '&').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) => {
|
|
949
1182
|
this.focus();
|
|
950
|
-
execFormat('insertHTML',
|
|
1183
|
+
execFormat('insertHTML', html + ' ');
|
|
951
1184
|
this.onContentChange();
|
|
952
1185
|
};
|
|
953
|
-
|
|
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;
|
package/lib/formats/image.js
CHANGED
|
@@ -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',
|
|
170
|
+
input.addEventListener('change', () => {
|
|
171
171
|
const file = input.files && input.files[0];
|
|
172
172
|
if (file) {
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
});
|