djm-ui 0.67.4 → 0.72.0

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.
Files changed (39) hide show
  1. package/CHANGELOG.md +103 -0
  2. package/README.md +38 -2
  3. package/dist/djm-ui.cjs +1032 -347
  4. package/dist/djm-ui.cjs.map +4 -4
  5. package/dist/djm-ui.css +752 -17
  6. package/dist/djm-ui.esm.js +1105 -420
  7. package/dist/djm-ui.esm.js.map +4 -4
  8. package/dist/djm-ui.esm.min.js +373 -227
  9. package/dist/djm-ui.min.cjs +373 -227
  10. package/dist/djm-ui.min.css +2 -2
  11. package/dist/djm-ui.umd.js +944 -259
  12. package/dist/djm-ui.umd.min.js +373 -227
  13. package/dist/mcp/manifest.json +12195 -0
  14. package/dist/mcp/server.js +90 -0
  15. package/package.json +12 -4
  16. package/src/components/icon/index.js +13 -1
  17. package/src/components/rich-editor/index.js +724 -61
  18. package/src/index.js +1 -1
  19. package/src/locale/en-US.js +33 -1
  20. package/src/locale/zh-CN.js +33 -1
  21. package/src/styles/base.css +2 -3
  22. package/src/styles/components/button.css +2 -1
  23. package/src/styles/components/console.css +5 -0
  24. package/src/styles/components/content.css +517 -0
  25. package/src/styles/components/form.css +5 -0
  26. package/src/styles/components/layout.css +326 -0
  27. package/src/styles/components/media-picker.css +3 -3
  28. package/src/styles/components/menu.css +2 -1
  29. package/src/styles/components/org-picker.css +1 -1
  30. package/src/styles/components/responsive.css +86 -0
  31. package/src/styles/components/rich-editor.css +206 -7
  32. package/src/styles/components/skin.css +54 -0
  33. package/src/styles/components/template.css +5 -0
  34. package/src/styles/index.css +1 -0
  35. package/src/styles/theme-dark.css +2 -0
  36. package/src/styles/tokens.css +8 -0
  37. package/src/theme/index.js +40 -0
  38. package/src/utils/sanitize-html.js +44 -6
  39. package/types/index.d.ts +8 -0
@@ -1,86 +1,553 @@
1
- import { ref, watch, onMounted, onBeforeUnmount } from 'vue'
1
+ import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
2
2
  import Icon from '../icon/index.js'
3
+ import MediaPicker from '../media-picker/index.js'
3
4
  import { sanitizeHtml } from '../../utils/sanitize-html.js'
4
5
  import { t } from '../../locale/index.js'
5
6
 
7
+ // 段落格式:formatBlock 的目标标签
8
+ const BLOCKS = [
9
+ { value: 'p', label: 'paragraph' },
10
+ { value: 'h1', label: 'heading1' },
11
+ { value: 'h2', label: 'heading2' },
12
+ { value: 'h3', label: 'heading3' },
13
+ { value: 'blockquote', label: 'quote' },
14
+ { value: 'pre', label: 'codeBlock' }
15
+ ]
16
+
17
+ // 字号直接写内联 px,避免 execCommand('fontSize') 输出的关键字尺寸不可控
18
+ const SIZES = [
19
+ { value: '', label: 'defaultSize' },
20
+ { value: '12px', label: '12px' },
21
+ { value: '14px', label: '14px' },
22
+ { value: '16px', label: '16px' },
23
+ { value: '18px', label: '18px' },
24
+ { value: '24px', label: '24px' }
25
+ ]
26
+
27
+ const COLORS = ['#1f2d3d', '#5a6b7d', '#2b86d9', '#2fa36b', '#d97706', '#dc2626', '#7c3aed', '#ffffff']
28
+
29
+ // 工具条预设:simple 精简、basic 常用、full 全部;也可以直接传分组数组自定义
30
+ const TOOLBAR_PRESETS = {
31
+ simple: ['history', 'inline', 'list', 'insert'],
32
+ basic: ['history', 'format', 'inline', 'align', 'list', 'insert', 'view'],
33
+ full: ['history', 'format', 'inline', 'color', 'align', 'list', 'insert', 'view']
34
+ }
35
+
36
+ // 只接受明确的图片地址,避免上传接口返回奇怪协议后被写进 src
37
+ function isSafeImageUrl (url) {
38
+ const value = String(url || '').replace(/[\u0000-\u0020]+/g, '')
39
+ if (!value) return false
40
+ const scheme = value.match(/^([a-z][a-z0-9+.-]*):/i)
41
+ if (!scheme) return true
42
+ const name = scheme[1].toLowerCase()
43
+ if (name === 'http' || name === 'https' || name === 'blob') return true
44
+ // data: 只放行图片,其它类型(如 data:text/html)一律拒绝
45
+ return name === 'data' && /^data:image\//i.test(value)
46
+ }
47
+
48
+ // 链接只允许常见协议与站内相对地址,挡掉 javascript: 这类注入
49
+ function isSafeLink (url) {
50
+ const value = String(url || '').trim()
51
+ if (!value) return false
52
+ const scheme = value.replace(/[\u0000-\u0020]+/g, '').match(/^([a-z][a-z0-9+.-]*):/i)
53
+ if (!scheme) return true
54
+ return ['http', 'https', 'mailto', 'tel'].indexOf(scheme[1].toLowerCase()) > -1
55
+ }
56
+
6
57
  export default {
7
58
  name: 'DjmRichEditor',
8
- components: { Icon },
59
+ components: { Icon, MediaPicker },
9
60
  props: {
10
61
  modelValue: { type: String, default: '' },
11
62
  placeholder: { type: String, default: '' },
12
63
  minHeight: { type: Number, default: 200 },
13
- // 开启后对外输出的 HTML 会走白名单清洗
14
- sanitize: { type: Boolean, default: false },
15
- disabled: { type: Boolean, default: false }
64
+ // 默认开启:对外输出的 HTML 走白名单清洗,内容里不会带出脚本
65
+ sanitize: { type: Boolean, default: true },
66
+ disabled: { type: Boolean, default: false },
67
+ // 工具条:simple / basic / full,或传分组数组(history / format / inline / color / align / list / insert / view)
68
+ toolbar: { type: [String, Array], default: 'full' },
69
+ // 自定义上传:接收 File,返回图片地址(或 { url }),不传则转成 dataURL 本地预览
70
+ uploadImage: { type: Function, default: null },
71
+ // 已上传图片,提供后工具栏出现「从素材库选择」:字符串或 { url, name }
72
+ media: { type: Array, default: () => [] },
73
+ accept: { type: String, default: 'image/*' },
74
+ // 单张图片大小上限(MB)
75
+ maxImageSize: { type: Number, default: 5 },
76
+ // 是否允许一次选多张
77
+ multiple: { type: Boolean, default: true },
78
+ showCount: { type: Boolean, default: true }
16
79
  },
17
- emits: ['update:modelValue'],
80
+ emits: ['update:modelValue', 'upload-error'],
18
81
  setup (props, { emit }) {
19
82
  const editor = ref(null)
83
+ const fileInput = ref(null)
20
84
  const sourceMode = ref(false)
21
85
  const source = ref(props.modelValue)
22
- // 工具栏的按下态跟随当前选区
23
- const formats = ref({ bold: false, italic: false, underline: false, strike: false })
86
+ const fullscreen = ref(false)
87
+ const panel = ref('')
88
+ const uploadCount = ref(0)
89
+ const textColor = ref(COLORS[0])
90
+ const bgColor = ref('#fdf3c4')
91
+ const fontSize = ref('')
92
+ const count = ref(0)
93
+
94
+ // 工具栏按下态,跟随当前选区
95
+ const formats = ref({
96
+ bold: false,
97
+ italic: false,
98
+ underline: false,
99
+ strike: false,
100
+ code: false,
101
+ block: 'p',
102
+ align: '',
103
+ list: ''
104
+ })
105
+
106
+ // 打开文件选择框前先记住选区,上传完成后还要插回原位
107
+ let savedRange = null
108
+
109
+ const ready = computed(() => !props.disabled && !sourceMode.value)
110
+
111
+ // 当前启用的工具分组
112
+ const toolbarGroups = computed(() => {
113
+ const list = Array.isArray(props.toolbar)
114
+ ? props.toolbar
115
+ : TOOLBAR_PRESETS[props.toolbar] || TOOLBAR_PRESETS.full
116
+ return new Set(list)
117
+ })
118
+
119
+ function has (group) {
120
+ return toolbarGroups.value.has(group)
121
+ }
122
+
123
+ // 素材库列表:统一成 { id, url, name, type },交给媒体库选择器
124
+ const mediaLibrary = computed(() =>
125
+ (props.media || [])
126
+ .map((item, index) => {
127
+ if (typeof item === 'string') {
128
+ return { id: 'media-' + index, url: item, name: '', type: 'image' }
129
+ }
130
+ return {
131
+ id: item && item.id !== undefined ? String(item.id) : 'media-' + index,
132
+ url: (item && (item.url || item.src)) || '',
133
+ name: (item && (item.name || item.alt)) || '',
134
+ type: 'image'
135
+ }
136
+ })
137
+ .filter((item) => !!item.url)
138
+ )
24
139
 
25
140
  function clean (html) {
26
141
  return props.sanitize ? sanitizeHtml(html) : html
27
142
  }
28
143
 
144
+ function plainText (html) {
145
+ return String(html || '')
146
+ .replace(/<[^>]*>/g, '')
147
+ .replace(/&nbsp;/g, ' ')
148
+ .replace(/&[a-z]+;/gi, ' ')
149
+ }
150
+
29
151
  function syncFromDom () {
30
152
  if (!editor.value) return
31
- emit('update:modelValue', clean(editor.value.innerHTML))
153
+ const html = clean(editor.value.innerHTML)
154
+ count.value = plainText(html).trim().length
155
+ emit('update:modelValue', html)
32
156
  }
33
157
 
34
- // 粘贴进来的外部 HTML 一律清洗,避免带入脚本或事件属性
35
- function onPaste (event) {
36
- const data = event.clipboardData
37
- const html = data && data.getData('text/html')
38
- if (!html) return
39
- event.preventDefault()
40
- document.execCommand('insertHTML', false, sanitizeHtml(html))
158
+ /* ---------- 选区与插入 ---------- */
159
+
160
+ function currentRange () {
161
+ const selection = window.getSelection()
162
+ if (!selection || !selection.rangeCount || !editor.value) return null
163
+ const range = selection.getRangeAt(0)
164
+ return editor.value.contains(range.commonAncestorContainer) ? range : null
165
+ }
166
+
167
+ function rememberRange () {
168
+ const range = currentRange()
169
+ savedRange = range ? range.cloneRange() : null
170
+ }
171
+
172
+ function focusAt (range) {
173
+ if (!editor.value) return
174
+ editor.value.focus()
175
+ const selection = window.getSelection()
176
+ selection.removeAllRanges()
177
+ if (range) {
178
+ selection.addRange(range)
179
+ return
180
+ }
181
+ // 没有记忆选区就放到内容末尾
182
+ const end = document.createRange()
183
+ end.selectNodeContents(editor.value)
184
+ end.collapse(false)
185
+ selection.addRange(end)
186
+ }
187
+
188
+ // 用 DOM 节点插入,避免把用户数据拼进 HTML 字符串造成注入
189
+ function insertNodeAt (range, node) {
190
+ if (!editor.value) return
191
+ focusAt(range)
192
+ const selection = window.getSelection()
193
+ if (selection.rangeCount) selection.getRangeAt(0).insertNode(node)
194
+ else editor.value.appendChild(node)
41
195
  syncFromDom()
196
+ syncFormats()
197
+ }
198
+
199
+ // 点工具栏时焦点会离开内容区,用记住的选区兜底
200
+ function ensureRange () {
201
+ const range = currentRange()
202
+ if (range) return range
203
+ if (!savedRange) return null
204
+ focusAt(savedRange)
205
+ return currentRange()
42
206
  }
43
207
 
44
208
  function exec (command, value = null) {
45
- if (props.disabled || sourceMode.value) return
209
+ if (!ready.value) return
210
+ if (typeof document.execCommand !== 'function') return
211
+ ensureRange()
212
+ // 统一输出内联样式,避免 <font> 之类的过时标签
213
+ document.execCommand('styleWithCSS', false, true)
46
214
  document.execCommand(command, false, value)
47
215
  if (editor.value) editor.value.focus()
48
216
  syncFromDom()
49
217
  syncFormats()
50
218
  }
51
219
 
52
- function formatBlock (tag) {
53
- exec('formatBlock', tag)
220
+ function setBlock (value) {
221
+ panel.value = ''
222
+ exec('formatBlock', '<' + value + '>')
223
+ }
224
+
225
+ // 用 px 精确设置字号;选“默认”时清掉选区内的字号声明
226
+ function setFontSize (size) {
227
+ panel.value = ''
228
+ if (!ready.value || !editor.value) return
229
+ fontSize.value = size
230
+ const range = ensureRange()
231
+ if (!range || range.collapsed) return
232
+
233
+ if (!size) {
234
+ const walker = document.createTreeWalker(editor.value, NodeFilter.SHOW_ELEMENT)
235
+ const targets = []
236
+ while (walker.nextNode()) {
237
+ const el = walker.currentNode
238
+ if (el.style && el.style.fontSize && range.intersectsNode(el)) targets.push(el)
239
+ }
240
+ targets.forEach((el) => { el.style.fontSize = '' })
241
+ syncFromDom()
242
+ return
243
+ }
244
+
245
+ const span = document.createElement('span')
246
+ span.style.fontSize = size
247
+ span.appendChild(range.extractContents())
248
+ range.insertNode(span)
249
+
250
+ const selection = window.getSelection()
251
+ const after = document.createRange()
252
+ after.setStartAfter(span)
253
+ after.collapse(true)
254
+ selection.removeAllRanges()
255
+ selection.addRange(after)
256
+ syncFromDom()
257
+ syncFormats()
258
+ }
259
+
260
+ function applyColor (kind, color) {
261
+ panel.value = ''
262
+ if (kind === 'text') textColor.value = color
263
+ else bgColor.value = color
264
+ if (!ready.value) return
265
+ if (typeof document.execCommand !== 'function') return
266
+ document.execCommand('styleWithCSS', false, true)
267
+ const command = kind === 'text' ? 'foreColor' : 'hiliteColor'
268
+ // Firefox 只认 backColor
269
+ if (!document.execCommand(command, false, color) && kind === 'bg') {
270
+ document.execCommand('backColor', false, color)
271
+ }
272
+ if (editor.value) editor.value.focus()
273
+ syncFromDom()
274
+ syncFormats()
275
+ }
276
+
277
+ function togglePanel (name) {
278
+ if (!ready.value) return
279
+ panel.value = panel.value === name ? '' : name
280
+ }
281
+
282
+ // 行内代码:选中文字包一层 <code>,已在代码里则去掉
283
+ function toggleInlineCode () {
284
+ if (!ready.value || !editor.value) return
285
+ const range = currentRange()
286
+ if (!range) return
287
+ const anchor = range.commonAncestorContainer
288
+ const node = anchor.nodeType === 1 ? anchor : anchor.parentElement
289
+ const code = node && node.closest ? node.closest('code') : null
290
+
291
+ if (code && editor.value.contains(code)) {
292
+ const parent = code.parentNode
293
+ while (code.firstChild) parent.insertBefore(code.firstChild, code)
294
+ parent.removeChild(code)
295
+ syncFromDom()
296
+ return
297
+ }
298
+ if (range.collapsed) return
299
+ const wrapper = document.createElement('code')
300
+ wrapper.appendChild(range.extractContents())
301
+ range.insertNode(wrapper)
302
+ const selection = window.getSelection()
303
+ const after = document.createRange()
304
+ after.setStartAfter(wrapper)
305
+ after.collapse(true)
306
+ selection.removeAllRanges()
307
+ selection.addRange(after)
308
+ syncFromDom()
309
+ syncFormats()
54
310
  }
55
311
 
56
312
  function addLink () {
313
+ if (!ready.value) return
57
314
  const url = window.prompt(t('richEditor.linkPrompt'), 'https://')
58
- if (url) exec('createLink', url)
315
+ if (!url) return
316
+ if (!isSafeLink(url)) {
317
+ window.alert(t('richEditor.linkInvalid'))
318
+ return
319
+ }
320
+ const range = currentRange()
321
+ if (range && range.collapsed) {
322
+ const link = document.createElement('a')
323
+ link.href = url
324
+ link.target = '_blank'
325
+ link.rel = 'noopener noreferrer'
326
+ link.textContent = url
327
+ insertNodeAt(range, link)
328
+ return
329
+ }
330
+ exec('createLink', url)
331
+ // 外链统一补 rel,避免 window.opener 泄露
332
+ if (editor.value) {
333
+ editor.value.querySelectorAll('a[href]').forEach((link) => {
334
+ if (!link.getAttribute('target')) link.setAttribute('target', '_blank')
335
+ link.setAttribute('rel', 'noopener noreferrer')
336
+ })
337
+ }
338
+ syncFromDom()
339
+ }
340
+
341
+ /* ---------- 图片上传 ---------- */
342
+
343
+ function readAsDataUrl (file) {
344
+ return new Promise((resolve, reject) => {
345
+ const reader = new FileReader()
346
+ reader.onload = () => resolve(reader.result)
347
+ reader.onerror = () => reject(reader.error || new Error('read failed'))
348
+ reader.readAsDataURL(file)
349
+ })
350
+ }
351
+
352
+ async function uploadOne (file) {
353
+ if (props.uploadImage) {
354
+ const result = await props.uploadImage(file)
355
+ const url = typeof result === 'string' ? result : result && result.url
356
+ if (!url) throw new Error(t('richEditor.imageUrlInvalid'))
357
+ if (!isSafeImageUrl(url)) throw new Error(t('richEditor.imageUrlInvalid'))
358
+ return String(url).trim()
359
+ }
360
+ // 没有上传接口时退化为本地预览,接后端时请传 uploadImage
361
+ return readAsDataUrl(file)
362
+ }
363
+
364
+ function isImage (file) {
365
+ return file && /^image\//.test(file.type)
366
+ }
367
+
368
+ function pickImage (allowMultiple) {
369
+ if (!ready.value || !fileInput.value) return
370
+ rememberRange()
371
+ // 单图 / 多图共用一个选择框,点按钮前改 multiple
372
+ fileInput.value.multiple = !!allowMultiple && props.multiple
373
+ fileInput.value.click()
374
+ }
375
+
376
+ // 从素材库插入选中的图片,地址同样过协议校验
377
+ function insertMedia (ids) {
378
+ if (!ready.value) return
379
+ const picked = (Array.isArray(ids) ? ids : [ids])
380
+ .map((id) => mediaLibrary.value.find((item) => item.id === String(id)))
381
+ .filter(Boolean)
382
+ const safe = picked.filter((item) => {
383
+ if (isSafeImageUrl(item.url)) return true
384
+ emit('upload-error', { file: null, message: t('richEditor.imageUrlInvalid') })
385
+ return false
386
+ })
387
+ if (!safe.length) return
388
+
389
+ focusAt(savedRange)
390
+ const fragment = document.createDocumentFragment()
391
+ safe.forEach((item) => {
392
+ const img = document.createElement('img')
393
+ img.src = item.url
394
+ img.alt = item.name || 'image'
395
+ img.className = 'djm-rich-editor-img'
396
+ fragment.appendChild(img)
397
+ })
398
+ const selection = window.getSelection()
399
+ if (selection.rangeCount) selection.getRangeAt(0).insertNode(fragment)
400
+ else editor.value.appendChild(fragment)
401
+ syncFromDom()
402
+ syncFormats()
403
+ }
404
+
405
+ function insertImages (files) {
406
+ if (!files.length || !editor.value) return
407
+ uploadCount.value = files.length
408
+ // 用真实节点做占位,避免依赖 data-* 属性(sanitize 会把它过滤掉)
409
+ const holder = document.createElement('span')
410
+ holder.className = 'djm-rich-editor-uploading'
411
+ holder.textContent = t('richEditor.uploading', { count: files.length })
412
+
413
+ const anchor = savedRange && editor.value.contains(savedRange.commonAncestorContainer) ? savedRange : null
414
+ focusAt(anchor)
415
+ const selection = window.getSelection()
416
+ if (selection.rangeCount) selection.getRangeAt(0).insertNode(holder)
417
+ else editor.value.appendChild(holder)
418
+
419
+ Promise.all(files.map(async (file) => {
420
+ if (!isImage(file)) return { file, error: t('richEditor.imageTypeError') }
421
+ if (props.maxImageSize > 0 && file.size > props.maxImageSize * 1024 * 1024) {
422
+ return { file, error: t('richEditor.imageSizeError', { size: props.maxImageSize }) }
423
+ }
424
+ try {
425
+ return { file, url: await uploadOne(file) }
426
+ } catch (error) {
427
+ return { file, error: (error && error.message) || t('richEditor.uploadFailed') }
428
+ }
429
+ })).then((results) => {
430
+ uploadCount.value = 0
431
+ const done = results.filter((item) => item.url)
432
+ const failed = results.filter((item) => item.error)
433
+ const alive = editor.value && editor.value.contains(holder)
434
+
435
+ if (done.length && editor.value) {
436
+ // 图片节点用 DOM 创建,alt / src 不会拼进 HTML 字符串,文件名带引号也不会注入
437
+ done.forEach((item) => {
438
+ const img = document.createElement('img')
439
+ img.src = item.url
440
+ img.alt = item.file.name || 'image'
441
+ img.className = 'djm-rich-editor-img'
442
+ if (alive) holder.parentNode.insertBefore(img, holder)
443
+ else editor.value.appendChild(img)
444
+ })
445
+ }
446
+ if (alive) holder.remove()
447
+
448
+ failed.forEach((item) => emit('upload-error', { file: item.file, message: item.error }))
449
+ syncFromDom()
450
+ })
451
+ }
452
+
453
+ function onFilesChange (event) {
454
+ const files = Array.from(event.target.files || [])
455
+ event.target.value = ''
456
+ insertImages(files)
457
+ }
458
+
459
+ // 粘贴:图片走上传,其余 HTML 清洗后插入
460
+ function onPaste (event) {
461
+ const data = event.clipboardData
462
+ if (!data) return
463
+ const images = Array.from(data.files || []).filter(isImage)
464
+ if (images.length) {
465
+ event.preventDefault()
466
+ insertImages(images)
467
+ return
468
+ }
469
+ const html = data.getData('text/html')
470
+ if (!html) return
471
+ event.preventDefault()
472
+ document.execCommand('insertHTML', false, sanitizeHtml(html))
473
+ syncFromDom()
474
+ }
475
+
476
+ function onDragOver (event) {
477
+ if (!ready.value) return
478
+ if (event.dataTransfer && Array.from(event.dataTransfer.types || []).includes('Files')) {
479
+ event.preventDefault()
480
+ }
481
+ }
482
+
483
+ function onDrop (event) {
484
+ if (!ready.value) return
485
+ const files = Array.from((event.dataTransfer && event.dataTransfer.files) || []).filter(isImage)
486
+ if (!files.length) return
487
+ event.preventDefault()
488
+ rememberRange()
489
+ insertImages(files)
490
+ }
491
+
492
+ /* ---------- 工具栏状态 ---------- */
493
+
494
+ function closestTag (node, tag) {
495
+ const el = node && (node.nodeType === 1 ? node : node.parentElement)
496
+ return el && el.closest ? el.closest(tag) : null
497
+ }
498
+
499
+ function detectBlock () {
500
+ const selection = window.getSelection()
501
+ if (!selection || !selection.anchorNode) return 'p'
502
+ let node = selection.anchorNode
503
+ if (node.nodeType !== 1) node = node.parentElement
504
+ while (node && node !== editor.value) {
505
+ const tag = node.tagName.toLowerCase()
506
+ if (['p', 'h1', 'h2', 'h3', 'blockquote', 'pre'].includes(tag)) return tag
507
+ node = node.parentElement
508
+ }
509
+ return 'p'
59
510
  }
60
511
 
61
512
  function syncFormats () {
62
- if (props.disabled || sourceMode.value) return
513
+ if (props.disabled || sourceMode.value || !editor.value) return
63
514
  // 光标不在编辑器里就不改工具栏状态
64
- if (!editor.value || !editor.value.contains(document.activeElement)) return
515
+ if (!editor.value.contains(document.activeElement)) return
516
+ const selection = window.getSelection()
65
517
  formats.value = {
66
518
  bold: document.queryCommandState('bold'),
67
519
  italic: document.queryCommandState('italic'),
68
520
  underline: document.queryCommandState('underline'),
69
- strike: document.queryCommandState('strikeThrough')
521
+ strike: document.queryCommandState('strikeThrough'),
522
+ code: !!closestTag(selection.anchorNode, 'code'),
523
+ block: detectBlock(),
524
+ align: document.queryCommandState('justifyCenter')
525
+ ? 'center'
526
+ : document.queryCommandState('justifyRight')
527
+ ? 'right'
528
+ : document.queryCommandState('justifyFull') ? 'justify' : 'left',
529
+ list: document.queryCommandState('insertOrderedList')
530
+ ? 'ol'
531
+ : document.queryCommandState('insertUnorderedList') ? 'ul' : ''
70
532
  }
71
533
  }
72
534
 
535
+ /* ---------- 源码 / 全屏 ---------- */
536
+
73
537
  function onSourceInput () {
538
+ count.value = plainText(source.value).trim().length
74
539
  emit('update:modelValue', clean(source.value))
75
540
  }
76
541
 
77
542
  function toggleSource () {
78
543
  if (props.disabled) return
544
+ panel.value = ''
79
545
  if (sourceMode.value) {
80
546
  emit('update:modelValue', clean(source.value))
81
547
  sourceMode.value = false
82
548
  requestAnimationFrame(() => {
83
549
  if (editor.value) editor.value.innerHTML = clean(source.value)
550
+ syncFromDom()
84
551
  })
85
552
  } else {
86
553
  source.value = editor.value ? editor.value.innerHTML : props.modelValue
@@ -88,6 +555,24 @@ export default {
88
555
  }
89
556
  }
90
557
 
558
+ function toggleFullscreen () {
559
+ if (props.disabled) return
560
+ fullscreen.value = !fullscreen.value
561
+ panel.value = ''
562
+ }
563
+
564
+ function onDocumentClick (event) {
565
+ if (!panel.value) return
566
+ if (!(event.target.closest && event.target.closest('.djm-rich-editor-color'))) panel.value = ''
567
+ }
568
+
569
+ function onKeydown (event) {
570
+ if (event.key === 'Escape' && fullscreen.value) {
571
+ fullscreen.value = false
572
+ panel.value = ''
573
+ }
574
+ }
575
+
91
576
  watch(
92
577
  () => props.modelValue,
93
578
  (value) => {
@@ -97,43 +582,120 @@ export default {
97
582
  }
98
583
  const next = clean(value)
99
584
  if (editor.value && editor.value.innerHTML !== next) editor.value.innerHTML = next
585
+ count.value = plainText(next).trim().length
100
586
  }
101
587
  )
102
588
 
103
589
  onMounted(() => {
104
590
  if (editor.value) editor.value.innerHTML = clean(props.modelValue)
591
+ count.value = plainText(props.modelValue).trim().length
105
592
  document.addEventListener('selectionchange', syncFormats)
593
+ document.addEventListener('click', onDocumentClick)
594
+ document.addEventListener('keydown', onKeydown)
106
595
  })
107
596
 
108
- onBeforeUnmount(() => document.removeEventListener('selectionchange', syncFormats))
597
+ onBeforeUnmount(() => {
598
+ document.removeEventListener('selectionchange', syncFormats)
599
+ document.removeEventListener('click', onDocumentClick)
600
+ document.removeEventListener('keydown', onKeydown)
601
+ })
109
602
 
110
603
  return {
111
604
  editor,
605
+ fileInput,
112
606
  sourceMode,
113
607
  source,
608
+ fullscreen,
609
+ panel,
610
+ uploadCount,
611
+ textColor,
612
+ bgColor,
613
+ fontSize,
614
+ count,
114
615
  formats,
616
+ blocks: BLOCKS,
617
+ sizes: SIZES,
618
+ colors: COLORS,
619
+ mediaLibrary,
620
+ has,
115
621
  exec,
116
- formatBlock,
622
+ setBlock,
623
+ setFontSize,
624
+ applyColor,
625
+ togglePanel,
626
+ toggleInlineCode,
117
627
  addLink,
118
- onSourceInput,
628
+ pickImage,
629
+ insertMedia,
630
+ onFilesChange,
119
631
  onPaste,
632
+ onDragOver,
633
+ onDrop,
634
+ rememberRange,
635
+ onSourceInput,
120
636
  toggleSource,
637
+ toggleFullscreen,
121
638
  syncFromDom,
122
639
  syncFormats,
123
640
  t
124
641
  }
125
642
  },
126
643
  template: `
127
- <div class="djm-rich-editor" :class="{ 'djm-is-disabled': disabled }">
128
- <div class="djm-rich-editor-toolbar">
129
- <div class="djm-rich-editor-group">
644
+ <div
645
+ class="djm-rich-editor"
646
+ :class="{ 'djm-is-disabled': disabled, 'djm-is-fullscreen': fullscreen }"
647
+ >
648
+ <div class="djm-rich-editor-toolbar" role="toolbar" :aria-label="t('richEditor.toolbar')" @mousedown="rememberRange">
649
+ <div class="djm-rich-editor-group" v-if="has('history')">
650
+ <button
651
+ class="djm-rich-editor-btn"
652
+ type="button"
653
+ :title="t('richEditor.undo')"
654
+ :aria-label="t('richEditor.undo')"
655
+ :disabled="disabled || sourceMode"
656
+ @mousedown.prevent
657
+ @click="exec('undo')"
658
+ ><Icon name="undo" :size="13" /></button>
659
+ <button
660
+ class="djm-rich-editor-btn"
661
+ type="button"
662
+ :title="t('richEditor.redo')"
663
+ :aria-label="t('richEditor.redo')"
664
+ :disabled="disabled || sourceMode"
665
+ @mousedown.prevent
666
+ @click="exec('redo')"
667
+ ><Icon name="redo" :size="13" /></button>
668
+ </div>
669
+
670
+ <div class="djm-rich-editor-group" v-if="has('format')">
671
+ <select
672
+ class="djm-rich-editor-select"
673
+ :value="formats.block"
674
+ :aria-label="t('richEditor.block')"
675
+ :disabled="disabled || sourceMode"
676
+ @change="setBlock($event.target.value)"
677
+ >
678
+ <option v-for="item in blocks" :key="item.value" :value="item.value">{{ t('richEditor.' + item.label) }}</option>
679
+ </select>
680
+ <select
681
+ class="djm-rich-editor-select djm-rich-editor-select-size"
682
+ :value="fontSize"
683
+ :aria-label="t('richEditor.fontSize')"
684
+ :disabled="disabled || sourceMode"
685
+ @change="setFontSize($event.target.value)"
686
+ >
687
+ <option v-for="item in sizes" :key="item.value" :value="item.value">{{ t('richEditor.' + item.label) }}</option>
688
+ </select>
689
+ </div>
690
+
691
+ <div class="djm-rich-editor-group" v-if="has('inline')">
130
692
  <button
131
693
  class="djm-rich-editor-btn djm-rich-editor-btn-bold"
132
694
  type="button"
133
695
  :title="t('richEditor.bold')"
134
696
  :aria-label="t('richEditor.bold')"
135
697
  :aria-pressed="String(formats.bold)"
136
- :disabled="disabled"
698
+ :disabled="disabled || sourceMode"
137
699
  @mousedown.prevent
138
700
  @click="exec('bold')"
139
701
  >B</button>
@@ -143,7 +705,7 @@ export default {
143
705
  :title="t('richEditor.italic')"
144
706
  :aria-label="t('richEditor.italic')"
145
707
  :aria-pressed="String(formats.italic)"
146
- :disabled="disabled"
708
+ :disabled="disabled || sourceMode"
147
709
  @mousedown.prevent
148
710
  @click="exec('italic')"
149
711
  >I</button>
@@ -153,7 +715,7 @@ export default {
153
715
  :title="t('richEditor.underline')"
154
716
  :aria-label="t('richEditor.underline')"
155
717
  :aria-pressed="String(formats.underline)"
156
- :disabled="disabled"
718
+ :disabled="disabled || sourceMode"
157
719
  @mousedown.prevent
158
720
  @click="exec('underline')"
159
721
  >U</button>
@@ -163,46 +725,130 @@ export default {
163
725
  :title="t('richEditor.strike')"
164
726
  :aria-label="t('richEditor.strike')"
165
727
  :aria-pressed="String(formats.strike)"
166
- :disabled="disabled"
728
+ :disabled="disabled || sourceMode"
167
729
  @mousedown.prevent
168
730
  @click="exec('strikeThrough')"
169
731
  >S</button>
732
+ <button
733
+ class="djm-rich-editor-btn"
734
+ type="button"
735
+ :title="t('richEditor.inlineCode')"
736
+ :aria-label="t('richEditor.inlineCode')"
737
+ :aria-pressed="String(formats.code)"
738
+ :disabled="disabled || sourceMode"
739
+ @mousedown.prevent
740
+ @click="toggleInlineCode"
741
+ ><Icon name="code" :size="13" /></button>
170
742
  </div>
171
743
 
172
- <div class="djm-rich-editor-group">
173
- <button class="djm-rich-editor-btn" type="button" :title="t('richEditor.heading')" :disabled="disabled" @mousedown.prevent @click="formatBlock('h2')">H2</button>
174
- <button class="djm-rich-editor-btn" type="button" :title="t('richEditor.paragraph')" :disabled="disabled" @mousedown.prevent @click="formatBlock('p')">{{ t('richEditor.paragraph') }}</button>
175
- </div>
176
-
177
- <div class="djm-rich-editor-group">
178
- <button class="djm-rich-editor-btn" type="button" :title="t('richEditor.bulletList')" :disabled="disabled" @mousedown.prevent @click="exec('insertUnorderedList')">
179
- <span class="djm-rich-editor-glyph">• ≡</span>
180
- </button>
181
- <button class="djm-rich-editor-btn" type="button" :title="t('richEditor.numberList')" :disabled="disabled" @mousedown.prevent @click="exec('insertOrderedList')">
182
- <span class="djm-rich-editor-glyph">1. ≡</span>
744
+ <div class="djm-rich-editor-group djm-rich-editor-color" v-if="has('color')">
745
+ <button
746
+ class="djm-rich-editor-btn"
747
+ type="button"
748
+ :title="t('richEditor.textColor')"
749
+ :aria-label="t('richEditor.textColor')"
750
+ :aria-expanded="String(panel === 'text')"
751
+ :disabled="disabled || sourceMode"
752
+ @mousedown.prevent
753
+ @click.stop="togglePanel('text')"
754
+ >
755
+ <span class="djm-rich-editor-letter" :style="{ borderBottomColor: textColor }">A</span>
183
756
  </button>
184
- <button class="djm-rich-editor-btn" type="button" :title="t('richEditor.link')" :aria-label="t('richEditor.link')" :disabled="disabled" @mousedown.prevent @click="addLink">
185
- <Icon name="link" :size="13" />
757
+ <button
758
+ class="djm-rich-editor-btn"
759
+ type="button"
760
+ :title="t('richEditor.bgColor')"
761
+ :aria-label="t('richEditor.bgColor')"
762
+ :aria-expanded="String(panel === 'bg')"
763
+ :disabled="disabled || sourceMode"
764
+ @mousedown.prevent
765
+ @click.stop="togglePanel('bg')"
766
+ >
767
+ <span class="djm-rich-editor-letter djm-rich-editor-letter-bg" :style="{ background: bgColor }">A</span>
186
768
  </button>
769
+ <div v-if="panel === 'text' || panel === 'bg'" class="djm-rich-editor-panel" role="group" :aria-label="t('richEditor.textColor')">
770
+ <button
771
+ v-for="color in colors"
772
+ :key="color"
773
+ class="djm-rich-editor-swatch"
774
+ type="button"
775
+ :title="color"
776
+ :aria-label="color"
777
+ :style="{ background: color }"
778
+ @mousedown.prevent
779
+ @click="applyColor(panel, color)"
780
+ ></button>
781
+ </div>
782
+ </div>
783
+
784
+ <div class="djm-rich-editor-group" v-if="has('align')">
785
+ <button class="djm-rich-editor-btn" type="button" :title="t('richEditor.alignLeft')" :aria-label="t('richEditor.alignLeft')" :aria-pressed="String(formats.align === 'left')" :disabled="disabled || sourceMode" @mousedown.prevent @click="exec('justifyLeft')"><Icon name="align-left" :size="13" /></button>
786
+ <button class="djm-rich-editor-btn" type="button" :title="t('richEditor.alignCenter')" :aria-label="t('richEditor.alignCenter')" :aria-pressed="String(formats.align === 'center')" :disabled="disabled || sourceMode" @mousedown.prevent @click="exec('justifyCenter')"><Icon name="align-center" :size="13" /></button>
787
+ <button class="djm-rich-editor-btn" type="button" :title="t('richEditor.alignRight')" :aria-label="t('richEditor.alignRight')" :aria-pressed="String(formats.align === 'right')" :disabled="disabled || sourceMode" @mousedown.prevent @click="exec('justifyRight')"><Icon name="align-right" :size="13" /></button>
788
+ <button class="djm-rich-editor-btn" type="button" :title="t('richEditor.alignJustify')" :aria-label="t('richEditor.alignJustify')" :aria-pressed="String(formats.align === 'justify')" :disabled="disabled || sourceMode" @mousedown.prevent @click="exec('justifyFull')"><Icon name="align-justify" :size="13" /></button>
789
+ </div>
790
+
791
+ <div class="djm-rich-editor-group" v-if="has('list')">
792
+ <button class="djm-rich-editor-btn" type="button" :title="t('richEditor.bulletList')" :aria-label="t('richEditor.bulletList')" :aria-pressed="String(formats.list === 'ul')" :disabled="disabled || sourceMode" @mousedown.prevent @click="exec('insertUnorderedList')"><Icon name="list" :size="13" /></button>
793
+ <button class="djm-rich-editor-btn" type="button" :title="t('richEditor.numberList')" :aria-label="t('richEditor.numberList')" :aria-pressed="String(formats.list === 'ol')" :disabled="disabled || sourceMode" @mousedown.prevent @click="exec('insertOrderedList')"><Icon name="list-ordered" :size="13" /></button>
794
+ <button class="djm-rich-editor-btn" type="button" :title="t('richEditor.outdent')" :aria-label="t('richEditor.outdent')" :disabled="disabled || sourceMode" @mousedown.prevent @click="exec('outdent')"><Icon name="outdent" :size="13" /></button>
795
+ <button class="djm-rich-editor-btn" type="button" :title="t('richEditor.indent')" :aria-label="t('richEditor.indent')" :disabled="disabled || sourceMode" @mousedown.prevent @click="exec('indent')"><Icon name="indent" :size="13" /></button>
187
796
  </div>
188
797
 
189
- <div class="djm-rich-editor-group">
190
- <button class="djm-rich-editor-btn" type="button" :title="t('richEditor.clearFormat')" :disabled="disabled" @mousedown.prevent @click="exec('removeFormat')">{{ t('richEditor.clearFormat') }}</button>
191
- <button class="djm-rich-editor-btn" type="button" :title="t('richEditor.undo')" :aria-label="t('richEditor.undo')" :disabled="disabled" @mousedown.prevent @click="exec('undo')">↶</button>
192
- <button class="djm-rich-editor-btn" type="button" :title="t('richEditor.redo')" :aria-label="t('richEditor.redo')" :disabled="disabled" @mousedown.prevent @click="exec('redo')">↷</button>
798
+ <div class="djm-rich-editor-group" v-if="has('insert')">
799
+ <button class="djm-rich-editor-btn" type="button" :title="t('richEditor.link')" :aria-label="t('richEditor.link')" :disabled="disabled || sourceMode" @mousedown.prevent @click="addLink"><Icon name="link" :size="13" /></button>
800
+ <button class="djm-rich-editor-btn" type="button" :title="t('richEditor.unlink')" :aria-label="t('richEditor.unlink')" :disabled="disabled || sourceMode" @mousedown.prevent @click="exec('unlink')"><Icon name="unlink" :size="13" /></button>
801
+ <button class="djm-rich-editor-btn" type="button" :title="t('richEditor.singleImage')" :aria-label="t('richEditor.singleImage')" :disabled="disabled || sourceMode" @mousedown.prevent @click="pickImage(false)"><Icon name="image" :size="13" /></button>
802
+ <button v-if="multiple" class="djm-rich-editor-btn" type="button" :title="t('richEditor.multiImage')" :aria-label="t('richEditor.multiImage')" :disabled="disabled || sourceMode" @mousedown.prevent @click="pickImage(true)"><Icon name="images" :size="13" /></button>
803
+ <MediaPicker
804
+ v-if="mediaLibrary.length"
805
+ class="djm-rich-editor-picker"
806
+ multiple
807
+ accept="image"
808
+ :items="mediaLibrary"
809
+ :title="t('richEditor.mediaLibrary')"
810
+ :disabled="disabled || sourceMode"
811
+ @change="insertMedia"
812
+ >
813
+ <template #trigger="{ open }">
814
+ <button
815
+ class="djm-rich-editor-btn"
816
+ type="button"
817
+ :title="t('richEditor.mediaLibrary')"
818
+ :aria-label="t('richEditor.mediaLibrary')"
819
+ :disabled="disabled || sourceMode"
820
+ @mousedown.prevent="rememberRange"
821
+ @click="open"
822
+ ><Icon name="grid" :size="13" /></button>
823
+ </template>
824
+ </MediaPicker>
825
+ <button class="djm-rich-editor-btn" type="button" :title="t('richEditor.hr')" :aria-label="t('richEditor.hr')" :disabled="disabled || sourceMode" @mousedown.prevent @click="exec('insertHorizontalRule')">—</button>
826
+ <button class="djm-rich-editor-btn" type="button" :title="t('richEditor.clearFormat')" :aria-label="t('richEditor.clearFormat')" :disabled="disabled || sourceMode" @mousedown.prevent @click="exec('removeFormat')"><span class="djm-rich-editor-glyph">T×</span></button>
193
827
  </div>
194
828
 
195
- <div class="djm-rich-editor-spacer"></div>
196
- <button
197
- class="djm-rich-editor-btn"
198
- :class="{ 'djm-is-active': sourceMode }"
199
- type="button"
200
- :aria-pressed="String(sourceMode)"
201
- :disabled="disabled"
202
- @click="toggleSource"
203
- >
204
- <Icon name="code" :size="13" />{{ sourceMode ? t('richEditor.visual') : t('richEditor.source') }}
205
- </button>
829
+ <div class="djm-rich-editor-spacer" v-if="has('view')"></div>
830
+ <div class="djm-rich-editor-group djm-rich-editor-group-end" v-if="has('view')">
831
+ <button
832
+ class="djm-rich-editor-btn"
833
+ :class="{ 'djm-is-active': sourceMode }"
834
+ type="button"
835
+ :title="sourceMode ? t('richEditor.visual') : t('richEditor.source')"
836
+ :aria-label="sourceMode ? t('richEditor.visual') : t('richEditor.source')"
837
+ :aria-pressed="String(sourceMode)"
838
+ :disabled="disabled"
839
+ @click="toggleSource"
840
+ ><Icon name="code" :size="13" /></button>
841
+ <button
842
+ class="djm-rich-editor-btn"
843
+ :class="{ 'djm-is-active': fullscreen }"
844
+ type="button"
845
+ :title="fullscreen ? t('richEditor.exitFullscreen') : t('richEditor.fullscreen')"
846
+ :aria-label="fullscreen ? t('richEditor.exitFullscreen') : t('richEditor.fullscreen')"
847
+ :aria-pressed="String(fullscreen)"
848
+ :disabled="disabled"
849
+ @click="toggleFullscreen"
850
+ ><Icon :name="fullscreen ? 'minimize' : 'maximize'" :size="13" /></button>
851
+ </div>
206
852
  </div>
207
853
 
208
854
  <div
@@ -218,6 +864,8 @@ export default {
218
864
  :style="{ minHeight: minHeight + 'px' }"
219
865
  @input="syncFromDom"
220
866
  @paste="onPaste"
867
+ @dragover="onDragOver"
868
+ @drop="onDrop"
221
869
  ></div>
222
870
 
223
871
  <textarea
@@ -230,6 +878,21 @@ export default {
230
878
  spellcheck="false"
231
879
  @input="onSourceInput"
232
880
  ></textarea>
881
+
882
+ <div v-if="showCount" class="djm-rich-editor-foot">
883
+ <span v-if="uploadCount" class="djm-rich-editor-foot-item">{{ t('richEditor.uploading', { count: uploadCount }) }}</span>
884
+ <span class="djm-rich-editor-foot-item">{{ t('richEditor.wordCount', { count }) }}</span>
885
+ </div>
886
+
887
+ <input
888
+ ref="fileInput"
889
+ class="djm-rich-editor-file"
890
+ type="file"
891
+ :accept="accept"
892
+ tabindex="-1"
893
+ aria-hidden="true"
894
+ @change="onFilesChange"
895
+ />
233
896
  </div>
234
897
  `
235
898
  }