djm-ui 0.67.3 → 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.
- package/CHANGELOG.md +108 -0
- package/README.md +38 -2
- package/dist/djm-ui.cjs +1051 -356
- package/dist/djm-ui.cjs.map +4 -4
- package/dist/djm-ui.css +752 -17
- package/dist/djm-ui.esm.js +1127 -432
- package/dist/djm-ui.esm.js.map +4 -4
- package/dist/djm-ui.esm.min.js +375 -229
- package/dist/djm-ui.min.cjs +375 -229
- package/dist/djm-ui.min.css +2 -2
- package/dist/djm-ui.umd.js +967 -272
- package/dist/djm-ui.umd.min.js +375 -229
- package/dist/mcp/manifest.json +12195 -0
- package/dist/mcp/server.js +90 -0
- package/package.json +12 -4
- package/src/components/icon/index.js +13 -1
- package/src/components/input/index.js +17 -6
- package/src/components/rich-editor/index.js +724 -61
- package/src/index.js +1 -1
- package/src/locale/en-US.js +33 -1
- package/src/locale/zh-CN.js +33 -1
- package/src/styles/base.css +2 -3
- package/src/styles/components/button.css +2 -1
- package/src/styles/components/console.css +5 -0
- package/src/styles/components/content.css +517 -0
- package/src/styles/components/form.css +5 -0
- package/src/styles/components/layout.css +327 -1
- package/src/styles/components/media-picker.css +3 -3
- package/src/styles/components/menu.css +2 -1
- package/src/styles/components/org-picker.css +1 -1
- package/src/styles/components/responsive.css +86 -0
- package/src/styles/components/rich-editor.css +206 -7
- package/src/styles/components/skin.css +54 -0
- package/src/styles/components/template.css +5 -0
- package/src/styles/index.css +1 -0
- package/src/styles/theme-dark.css +2 -0
- package/src/styles/tokens.css +8 -0
- package/src/theme/index.js +40 -0
- package/src/utils/sanitize-html.js +44 -6
- 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
|
-
//
|
|
14
|
-
sanitize: { type: Boolean, default:
|
|
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
|
|
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(/ /g, ' ')
|
|
148
|
+
.replace(/&[a-z]+;/gi, ' ')
|
|
149
|
+
}
|
|
150
|
+
|
|
29
151
|
function syncFromDom () {
|
|
30
152
|
if (!editor.value) return
|
|
31
|
-
|
|
153
|
+
const html = clean(editor.value.innerHTML)
|
|
154
|
+
count.value = plainText(html).trim().length
|
|
155
|
+
emit('update:modelValue', html)
|
|
32
156
|
}
|
|
33
157
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
const
|
|
38
|
-
if (!
|
|
39
|
-
|
|
40
|
-
|
|
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 (
|
|
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
|
|
53
|
-
|
|
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)
|
|
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
|
|
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(() =>
|
|
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
|
-
|
|
622
|
+
setBlock,
|
|
623
|
+
setFontSize,
|
|
624
|
+
applyColor,
|
|
625
|
+
togglePanel,
|
|
626
|
+
toggleInlineCode,
|
|
117
627
|
addLink,
|
|
118
|
-
|
|
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
|
|
128
|
-
|
|
129
|
-
|
|
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
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
|
185
|
-
|
|
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.
|
|
191
|
-
<button class="djm-rich-editor-btn" type="button" :title="t('richEditor.
|
|
192
|
-
<button class="djm-rich-editor-btn" type="button" :title="t('richEditor.
|
|
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
|
-
<
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
}
|