vome-core 0.1.30 → 0.1.31
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/dist/admin/config/plugin-dev.js +1 -1
- package/dist/admin/crud/components/vm-richtext-extensions.d.ts +12 -0
- package/dist/admin/crud/components/vm-richtext-extensions.js +1 -0
- package/dist/admin/crud/components/vm-richtext.vue +272 -348
- package/dist/admin/crud/config.js +1 -1
- package/dist/admin/crud/confirm.js +1 -1
- package/dist/admin/crud/dict.js +1 -1
- package/dist/admin/crud/key.js +1 -1
- package/dist/admin/crud/mitt.js +1 -1
- package/dist/admin/crud/plugins.js +1 -1
- package/dist/admin/crud/span.js +1 -1
- package/dist/admin/crud/style.js +1 -1
- package/dist/admin/crud/validate.js +1 -1
- package/dist/admin/directives/perm.js +1 -1
- package/dist/admin/hooks/useUpload.js +1 -1
- package/dist/admin/lib/browser.js +1 -1
- package/dist/admin/lib/cn.js +1 -1
- package/dist/admin/lib/dialog-float.js +1 -1
- package/dist/admin/lib/export-excel.js +1 -1
- package/dist/admin/lib/import-excel.js +1 -1
- package/dist/admin/lib/json.js +1 -1
- package/dist/admin/lib/menu.js +1 -1
- package/dist/admin/lib/tree.js +1 -1
- package/dist/admin/lib/upload.js +1 -1
- package/dist/admin/lib/video-frame.js +1 -1
- package/dist/index.js +1 -1
- package/dist/server/index.js +1 -1
- package/dist/shared/excel.js +1 -1
- package/dist/shared/index.js +1 -1
- package/dist/shared/locale-json.js +1 -1
- package/dist/shared/tree.js +1 -1
- package/package.json +49 -1
|
@@ -21,148 +21,60 @@
|
|
|
21
21
|
v-if="editor"
|
|
22
22
|
class="vm-richtext__toolbar"
|
|
23
23
|
>
|
|
24
|
-
<button
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
@click="editor.chain().focus().toggleBold().run()"
|
|
30
|
-
>
|
|
31
|
-
<i class="ri-bold" />
|
|
32
|
-
</button>
|
|
33
|
-
<button
|
|
34
|
-
type="button"
|
|
35
|
-
class="vm-richtext__btn"
|
|
36
|
-
:class="{ 'is-active': editor.isActive('italic') }"
|
|
37
|
-
title="斜体"
|
|
38
|
-
@click="editor.chain().focus().toggleItalic().run()"
|
|
39
|
-
>
|
|
40
|
-
<i class="ri-italic" />
|
|
41
|
-
</button>
|
|
42
|
-
<button
|
|
43
|
-
type="button"
|
|
44
|
-
class="vm-richtext__btn"
|
|
45
|
-
:class="{ 'is-active': editor.isActive('underline') }"
|
|
46
|
-
title="下划线"
|
|
47
|
-
@click="editor.chain().focus().toggleUnderline().run()"
|
|
48
|
-
>
|
|
49
|
-
<i class="ri-underline" />
|
|
50
|
-
</button>
|
|
51
|
-
<button
|
|
52
|
-
type="button"
|
|
53
|
-
class="vm-richtext__btn"
|
|
54
|
-
:class="{ 'is-active': editor.isActive('strike') }"
|
|
55
|
-
title="删除线"
|
|
56
|
-
@click="editor.chain().focus().toggleStrike().run()"
|
|
57
|
-
>
|
|
58
|
-
<i class="ri-strikethrough" />
|
|
59
|
-
</button>
|
|
24
|
+
<button type="button" class="vm-richtext__btn" :class="{ 'is-active': editor.isActive('bold') }" title="加粗" @click="editor.chain().focus().toggleBold().run()"><i class="ri-bold" /></button>
|
|
25
|
+
<button type="button" class="vm-richtext__btn" :class="{ 'is-active': editor.isActive('italic') }" title="斜体" @click="editor.chain().focus().toggleItalic().run()"><i class="ri-italic" /></button>
|
|
26
|
+
<button type="button" class="vm-richtext__btn" :class="{ 'is-active': editor.isActive('underline') }" title="下划线" @click="editor.chain().focus().toggleUnderline().run()"><i class="ri-underline" /></button>
|
|
27
|
+
<button type="button" class="vm-richtext__btn" :class="{ 'is-active': editor.isActive('strike') }" title="删除线" @click="editor.chain().focus().toggleStrike().run()"><i class="ri-strikethrough" /></button>
|
|
28
|
+
<button type="button" class="vm-richtext__btn" :class="{ 'is-active': editor.isActive('highlight') }" title="高亮" @click="editor.chain().focus().toggleHighlight().run()"><i class="ri-mark-pen-line" /></button>
|
|
60
29
|
<span class="vm-richtext__sep" />
|
|
61
|
-
<button
|
|
62
|
-
type="button"
|
|
63
|
-
class="vm-richtext__btn"
|
|
64
|
-
:class="{ 'is-active': editor.isActive('heading', { level: 2 }) }"
|
|
65
|
-
title="标题 2"
|
|
66
|
-
@click="editor.chain().focus().toggleHeading({ level: 2 }).run()"
|
|
67
|
-
>
|
|
68
|
-
H2
|
|
69
|
-
</button>
|
|
70
|
-
<button
|
|
71
|
-
type="button"
|
|
72
|
-
class="vm-richtext__btn"
|
|
73
|
-
:class="{ 'is-active': editor.isActive('heading', { level: 3 }) }"
|
|
74
|
-
title="标题 3"
|
|
75
|
-
@click="editor.chain().focus().toggleHeading({ level: 3 }).run()"
|
|
76
|
-
>
|
|
77
|
-
H3
|
|
78
|
-
</button>
|
|
79
|
-
<button
|
|
80
|
-
type="button"
|
|
81
|
-
class="vm-richtext__btn"
|
|
82
|
-
:class="{ 'is-active': editor.isActive('paragraph') }"
|
|
83
|
-
title="正文"
|
|
84
|
-
@click="editor.chain().focus().setParagraph().run()"
|
|
85
|
-
>
|
|
86
|
-
P
|
|
87
|
-
</button>
|
|
30
|
+
<button type="button" class="vm-richtext__btn" title="文字颜色" @click="pickTextColor"><i class="ri-font-color" /></button>
|
|
88
31
|
<span class="vm-richtext__sep" />
|
|
89
|
-
<button
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
:class="{ 'is-active': editor.isActive('bulletList') }"
|
|
93
|
-
title="无序列表"
|
|
94
|
-
@click="editor.chain().focus().toggleBulletList().run()"
|
|
95
|
-
>
|
|
96
|
-
<i class="ri-list-unordered" />
|
|
97
|
-
</button>
|
|
98
|
-
<button
|
|
99
|
-
type="button"
|
|
100
|
-
class="vm-richtext__btn"
|
|
101
|
-
:class="{ 'is-active': editor.isActive('orderedList') }"
|
|
102
|
-
title="有序列表"
|
|
103
|
-
@click="editor.chain().focus().toggleOrderedList().run()"
|
|
104
|
-
>
|
|
105
|
-
<i class="ri-list-ordered" />
|
|
106
|
-
</button>
|
|
107
|
-
<button
|
|
108
|
-
type="button"
|
|
109
|
-
class="vm-richtext__btn"
|
|
110
|
-
:class="{ 'is-active': editor.isActive('blockquote') }"
|
|
111
|
-
title="引用"
|
|
112
|
-
@click="editor.chain().focus().toggleBlockquote().run()"
|
|
113
|
-
>
|
|
114
|
-
<i class="ri-double-quotes-l" />
|
|
115
|
-
</button>
|
|
32
|
+
<button type="button" class="vm-richtext__btn" :class="{ 'is-active': editor.isActive('heading', { level: 2 }) }" title="标题 2" @click="editor.chain().focus().toggleHeading({ level: 2 }).run()">H2</button>
|
|
33
|
+
<button type="button" class="vm-richtext__btn" :class="{ 'is-active': editor.isActive('heading', { level: 3 }) }" title="标题 3" @click="editor.chain().focus().toggleHeading({ level: 3 }).run()">H3</button>
|
|
34
|
+
<button type="button" class="vm-richtext__btn" :class="{ 'is-active': editor.isActive('paragraph') }" title="正文" @click="editor.chain().focus().setParagraph().run()">P</button>
|
|
116
35
|
<span class="vm-richtext__sep" />
|
|
117
|
-
<button
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
:class="{ 'is-active': editor.isActive('link') }"
|
|
121
|
-
title="链接"
|
|
122
|
-
@click="toggleLink"
|
|
123
|
-
>
|
|
124
|
-
<i class="ri-link" />
|
|
125
|
-
</button>
|
|
126
|
-
<button
|
|
127
|
-
v-if="uploadEnabled"
|
|
128
|
-
type="button"
|
|
129
|
-
class="vm-richtext__btn"
|
|
130
|
-
title="图片"
|
|
131
|
-
@click="pickImage"
|
|
132
|
-
>
|
|
133
|
-
<i class="ri-image-line" />
|
|
134
|
-
</button>
|
|
36
|
+
<button type="button" class="vm-richtext__btn" :class="{ 'is-active': editor.isActive({ textAlign: 'left' }) }" title="左对齐" @click="editor.chain().focus().setTextAlign('left').run()"><i class="ri-align-left" /></button>
|
|
37
|
+
<button type="button" class="vm-richtext__btn" :class="{ 'is-active': editor.isActive({ textAlign: 'center' }) }" title="居中" @click="editor.chain().focus().setTextAlign('center').run()"><i class="ri-align-center" /></button>
|
|
38
|
+
<button type="button" class="vm-richtext__btn" :class="{ 'is-active': editor.isActive({ textAlign: 'right' }) }" title="右对齐" @click="editor.chain().focus().setTextAlign('right').run()"><i class="ri-align-right" /></button>
|
|
135
39
|
<span class="vm-richtext__sep" />
|
|
136
|
-
<button
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
<button
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
title="
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
40
|
+
<button type="button" class="vm-richtext__btn" :class="{ 'is-active': editor.isActive('bulletList') }" title="无序列表" @click="editor.chain().focus().toggleBulletList().run()"><i class="ri-list-unordered" /></button>
|
|
41
|
+
<button type="button" class="vm-richtext__btn" :class="{ 'is-active': editor.isActive('orderedList') }" title="有序列表" @click="editor.chain().focus().toggleOrderedList().run()"><i class="ri-list-ordered" /></button>
|
|
42
|
+
<button type="button" class="vm-richtext__btn" :class="{ 'is-active': editor.isActive('taskList') }" title="任务列表" @click="editor.chain().focus().toggleTaskList().run()"><i class="ri-checkbox-line" /></button>
|
|
43
|
+
<button type="button" class="vm-richtext__btn" :class="{ 'is-active': editor.isActive('blockquote') }" title="引用" @click="editor.chain().focus().toggleBlockquote().run()"><i class="ri-double-quotes-l" /></button>
|
|
44
|
+
<button type="button" class="vm-richtext__btn" :class="{ 'is-active': editor.isActive('codeBlock') }" title="代码块" @click="editor.chain().focus().toggleCodeBlock().run()"><i class="ri-code-box-line" /></button>
|
|
45
|
+
<button type="button" class="vm-richtext__btn" title="分割线" @click="editor.chain().focus().setHorizontalRule().run()"><i class="ri-separator" /></button>
|
|
46
|
+
<span class="vm-richtext__sep" />
|
|
47
|
+
<button type="button" class="vm-richtext__btn" :class="{ 'is-active': editor.isActive('link') }" title="链接" @click="toggleLink"><i class="ri-link" /></button>
|
|
48
|
+
<button v-if="uploadEnabled" type="button" class="vm-richtext__btn" title="图片" @click="pickImage"><i class="ri-image-line" /></button>
|
|
49
|
+
<button v-if="uploadEnabled" type="button" class="vm-richtext__btn" title="上传视频" @click="pickVideo"><i class="ri-video-line" /></button>
|
|
50
|
+
<button type="button" class="vm-richtext__btn" title="YouTube 嵌入" @click="insertYoutube"><i class="ri-youtube-line" /></button>
|
|
51
|
+
<div ref="emojiWrapRef" class="vm-richtext__emoji-wrap">
|
|
52
|
+
<button type="button" class="vm-richtext__btn" :class="{ 'is-active': emojiOpen }" title="表情" @click="emojiOpen = !emojiOpen"><i class="ri-emotion-line" /></button>
|
|
53
|
+
<div v-if="emojiOpen" class="vm-richtext__emoji-panel">
|
|
54
|
+
<button
|
|
55
|
+
v-for="(e, i) in VM_RICHTEXT_EMOJIS"
|
|
56
|
+
:key="i"
|
|
57
|
+
type="button"
|
|
58
|
+
class="vm-richtext__emoji-item"
|
|
59
|
+
@click="insertEmoji(e)"
|
|
60
|
+
>
|
|
61
|
+
{{ e }}
|
|
62
|
+
</button>
|
|
63
|
+
</div>
|
|
64
|
+
</div>
|
|
65
|
+
<span class="vm-richtext__sep" />
|
|
66
|
+
<button type="button" class="vm-richtext__btn" title="插入表格" @click="insertTable"><i class="ri-table-line" /></button>
|
|
67
|
+
<button type="button" class="vm-richtext__btn" title="撤销" :disabled="!editor.can().undo()" @click="editor.chain().focus().undo().run()"><i class="ri-arrow-go-back-line" /></button>
|
|
68
|
+
<button type="button" class="vm-richtext__btn" title="重做" :disabled="!editor.can().redo()" @click="editor.chain().focus().redo().run()"><i class="ri-arrow-go-forward-line" /></button>
|
|
154
69
|
</div>
|
|
155
70
|
|
|
156
|
-
<EditorContent
|
|
157
|
-
class="vm-richtext__content"
|
|
158
|
-
:editor="editor"
|
|
159
|
-
/>
|
|
71
|
+
<EditorContent class="vm-richtext__content" :editor="editor" />
|
|
160
72
|
</div>
|
|
161
73
|
</div>
|
|
162
74
|
</template>
|
|
163
75
|
|
|
164
76
|
<script setup lang="ts">
|
|
165
|
-
import { computed, onBeforeUnmount, shallowRef, watch } from 'vue'
|
|
77
|
+
import { computed, onBeforeUnmount, onMounted, shallowRef, watch } from 'vue'
|
|
166
78
|
import DOMPurify from 'dompurify'
|
|
167
79
|
import { EditorContent, useEditor } from '@tiptap/vue-3'
|
|
168
80
|
import StarterKit from '@tiptap/starter-kit'
|
|
@@ -170,46 +82,27 @@ import Image from '@tiptap/extension-image'
|
|
|
170
82
|
import Link from '@tiptap/extension-link'
|
|
171
83
|
import Placeholder from '@tiptap/extension-placeholder'
|
|
172
84
|
import Underline from '@tiptap/extension-underline'
|
|
85
|
+
import TextAlign from '@tiptap/extension-text-align'
|
|
86
|
+
import Highlight from '@tiptap/extension-highlight'
|
|
87
|
+
import TextStyle from '@tiptap/extension-text-style'
|
|
88
|
+
import Color from '@tiptap/extension-color'
|
|
89
|
+
import Youtube from '@tiptap/extension-youtube'
|
|
90
|
+
import TaskList from '@tiptap/extension-task-list'
|
|
91
|
+
import TaskItem from '@tiptap/extension-task-item'
|
|
92
|
+
import Table from '@tiptap/extension-table'
|
|
93
|
+
import TableRow from '@tiptap/extension-table-row'
|
|
94
|
+
import TableHeader from '@tiptap/extension-table-header'
|
|
95
|
+
import TableCell from '@tiptap/extension-table-cell'
|
|
173
96
|
import { useUpload } from '../../hooks/useUpload'
|
|
97
|
+
import { VM_RICHTEXT_EMOJIS, VmRichtextVideo } from './vm-richtext-extensions'
|
|
174
98
|
|
|
175
99
|
defineOptions({ name: 'vm-richtext' })
|
|
176
100
|
|
|
177
101
|
const RICH_TAGS = [
|
|
178
|
-
'p',
|
|
179
|
-
'
|
|
180
|
-
'
|
|
181
|
-
'
|
|
182
|
-
'strong',
|
|
183
|
-
'b',
|
|
184
|
-
'em',
|
|
185
|
-
'i',
|
|
186
|
-
'u',
|
|
187
|
-
's',
|
|
188
|
-
'del',
|
|
189
|
-
'strike',
|
|
190
|
-
'a',
|
|
191
|
-
'ul',
|
|
192
|
-
'ol',
|
|
193
|
-
'li',
|
|
194
|
-
'h1',
|
|
195
|
-
'h2',
|
|
196
|
-
'h3',
|
|
197
|
-
'h4',
|
|
198
|
-
'h5',
|
|
199
|
-
'h6',
|
|
200
|
-
'blockquote',
|
|
201
|
-
'code',
|
|
202
|
-
'pre',
|
|
203
|
-
'img',
|
|
204
|
-
'table',
|
|
205
|
-
'thead',
|
|
206
|
-
'tbody',
|
|
207
|
-
'tr',
|
|
208
|
-
'th',
|
|
209
|
-
'td',
|
|
210
|
-
'hr',
|
|
211
|
-
'sup',
|
|
212
|
-
'sub',
|
|
102
|
+
'p', 'br', 'div', 'span', 'strong', 'b', 'em', 'i', 'u', 's', 'del', 'strike', 'a',
|
|
103
|
+
'ul', 'ol', 'li', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'code', 'pre',
|
|
104
|
+
'img', 'video', 'source', 'iframe', 'table', 'thead', 'tbody', 'tr', 'th', 'td', 'hr', 'sup', 'sub',
|
|
105
|
+
'mark',
|
|
213
106
|
]
|
|
214
107
|
|
|
215
108
|
const props = withDefaults(
|
|
@@ -219,11 +112,8 @@ const props = withDefaults(
|
|
|
219
112
|
disabled?: boolean
|
|
220
113
|
placeholder?: string
|
|
221
114
|
height?: number | string
|
|
222
|
-
/** 自定义上传;未传且 autoUpload=true 时走 useUpload 直传 OSS */
|
|
223
115
|
uploadImage?: (file: File) => Promise<string>
|
|
224
|
-
/** autoUpload 默认前缀(须 app/public/**) */
|
|
225
116
|
uploadPrefixPath?: string
|
|
226
|
-
/** false 时禁用插图/粘贴上传(不自动 useUpload) */
|
|
227
117
|
autoUpload?: boolean
|
|
228
118
|
}>(),
|
|
229
119
|
{
|
|
@@ -235,43 +125,29 @@ const props = withDefaults(
|
|
|
235
125
|
},
|
|
236
126
|
)
|
|
237
127
|
|
|
238
|
-
const emit = defineEmits<{
|
|
239
|
-
'update:modelValue': [v: string]
|
|
240
|
-
}>()
|
|
128
|
+
const emit = defineEmits<{ 'update:modelValue': [v: string] }>()
|
|
241
129
|
|
|
242
130
|
const { toUpload } = useUpload()
|
|
243
131
|
const syncing = shallowRef(false)
|
|
244
132
|
const uploading = shallowRef(false)
|
|
133
|
+
const emojiOpen = shallowRef(false)
|
|
134
|
+
const emojiWrapRef = shallowRef<HTMLElement>()
|
|
245
135
|
|
|
246
136
|
const readOnlyMode = computed(() => props.readonly)
|
|
247
|
-
const uploadEnabled = computed(
|
|
248
|
-
() => Boolean(props.uploadImage) || props.autoUpload !== false,
|
|
249
|
-
)
|
|
137
|
+
const uploadEnabled = computed(() => Boolean(props.uploadImage) || props.autoUpload !== false)
|
|
250
138
|
|
|
251
139
|
const shellStyle = computed(() => {
|
|
252
|
-
const h =
|
|
253
|
-
|
|
254
|
-
return {
|
|
255
|
-
'--vm-richtext-height': h,
|
|
256
|
-
} as Record<string, string>
|
|
140
|
+
const h = typeof props.height === 'number' ? `${props.height}px` : String(props.height || '320px')
|
|
141
|
+
return { '--vm-richtext-height': h } as Record<string, string>
|
|
257
142
|
})
|
|
258
143
|
|
|
259
144
|
function sanitize(html: string) {
|
|
260
145
|
return DOMPurify.sanitize(html, {
|
|
261
146
|
ALLOWED_TAGS: RICH_TAGS,
|
|
262
147
|
ALLOWED_ATTR: [
|
|
263
|
-
'href',
|
|
264
|
-
'
|
|
265
|
-
'
|
|
266
|
-
'class',
|
|
267
|
-
'src',
|
|
268
|
-
'alt',
|
|
269
|
-
'title',
|
|
270
|
-
'width',
|
|
271
|
-
'height',
|
|
272
|
-
'colspan',
|
|
273
|
-
'rowspan',
|
|
274
|
-
'align',
|
|
148
|
+
'href', 'target', 'rel', 'class', 'src', 'alt', 'title', 'width', 'height',
|
|
149
|
+
'colspan', 'rowspan', 'align', 'controls', 'style', 'data-youtube-video',
|
|
150
|
+
'frameborder', 'allowfullscreen', 'allow', 'referrerpolicy',
|
|
275
151
|
],
|
|
276
152
|
})
|
|
277
153
|
}
|
|
@@ -302,17 +178,73 @@ async function insertImage(file: File) {
|
|
|
302
178
|
}
|
|
303
179
|
}
|
|
304
180
|
|
|
305
|
-
function
|
|
181
|
+
async function insertVideo(file: File) {
|
|
182
|
+
if (!editor.value || uploading.value) return
|
|
183
|
+
uploading.value = true
|
|
184
|
+
try {
|
|
185
|
+
const url = await resolveUpload(file)
|
|
186
|
+
editor.value.chain().focus().setVideo({ src: url }).run()
|
|
187
|
+
} finally {
|
|
188
|
+
uploading.value = false
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function pickFile(accept: string, handler: (file: File) => void) {
|
|
306
193
|
const input = document.createElement('input')
|
|
307
194
|
input.type = 'file'
|
|
308
|
-
input.accept =
|
|
195
|
+
input.accept = accept
|
|
309
196
|
input.onchange = () => {
|
|
310
197
|
const file = input.files?.[0]
|
|
311
|
-
if (file)
|
|
198
|
+
if (file) handler(file)
|
|
312
199
|
}
|
|
313
200
|
input.click()
|
|
314
201
|
}
|
|
315
202
|
|
|
203
|
+
function pickImage() {
|
|
204
|
+
pickFile('image/*', (file) => void insertImage(file))
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function pickVideo() {
|
|
208
|
+
pickFile('video/*', (file) => void insertVideo(file))
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function insertEmoji(emoji: string) {
|
|
212
|
+
editor.value?.chain().focus().insertContent(emoji).run()
|
|
213
|
+
emojiOpen.value = false
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function insertYoutube() {
|
|
217
|
+
const ed = editor.value
|
|
218
|
+
if (!ed) return
|
|
219
|
+
const url = window.prompt('YouTube 链接', 'https://www.youtube.com/watch?v=')
|
|
220
|
+
if (url == null) return
|
|
221
|
+
const src = url.trim()
|
|
222
|
+
if (!src) return
|
|
223
|
+
ed.chain().focus().setYoutubeVideo({ src }).run()
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function insertTable() {
|
|
227
|
+
editor.value
|
|
228
|
+
?.chain()
|
|
229
|
+
.focus()
|
|
230
|
+
.insertTable({ rows: 3, cols: 3, withHeaderRow: true })
|
|
231
|
+
.run()
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function pickTextColor() {
|
|
235
|
+
const ed = editor.value
|
|
236
|
+
if (!ed) return
|
|
237
|
+
const current = (ed.getAttributes('textStyle').color as string | undefined) || '#111827'
|
|
238
|
+
const color = window.prompt('文字颜色(#RRGGBB)', current)
|
|
239
|
+
if (color == null) return
|
|
240
|
+
const v = color.trim()
|
|
241
|
+
if (!v) {
|
|
242
|
+
ed.chain().focus().unsetColor().run()
|
|
243
|
+
return
|
|
244
|
+
}
|
|
245
|
+
ed.chain().focus().setColor(v).run()
|
|
246
|
+
}
|
|
247
|
+
|
|
316
248
|
function toggleLink() {
|
|
317
249
|
const ed = editor.value
|
|
318
250
|
if (!ed) return
|
|
@@ -331,51 +263,75 @@ function toggleLink() {
|
|
|
331
263
|
ed.chain().focus().extendMarkRange('link').setLink({ href, target: '_blank', rel: 'noopener noreferrer' }).run()
|
|
332
264
|
}
|
|
333
265
|
|
|
266
|
+
function handleMediaPaste(event: ClipboardEvent) {
|
|
267
|
+
if (!uploadEnabled.value) return false
|
|
268
|
+
const items = Array.from(event.clipboardData?.items || [])
|
|
269
|
+
const imageItem = items.find((item) => item.type.startsWith('image/'))
|
|
270
|
+
if (imageItem) {
|
|
271
|
+
const file = imageItem.getAsFile()
|
|
272
|
+
if (!file) return false
|
|
273
|
+
event.preventDefault()
|
|
274
|
+
void insertImage(file)
|
|
275
|
+
return true
|
|
276
|
+
}
|
|
277
|
+
const videoItem = items.find((item) => item.type.startsWith('video/'))
|
|
278
|
+
if (videoItem) {
|
|
279
|
+
const file = videoItem.getAsFile()
|
|
280
|
+
if (!file) return false
|
|
281
|
+
event.preventDefault()
|
|
282
|
+
void insertVideo(file)
|
|
283
|
+
return true
|
|
284
|
+
}
|
|
285
|
+
return false
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function handleMediaDrop(event: DragEvent, moved: boolean) {
|
|
289
|
+
if (moved || !uploadEnabled.value) return false
|
|
290
|
+
const files = Array.from(event.dataTransfer?.files || [])
|
|
291
|
+
const image = files.find((f) => f.type.startsWith('image/'))
|
|
292
|
+
if (image) {
|
|
293
|
+
event.preventDefault()
|
|
294
|
+
void insertImage(image)
|
|
295
|
+
return true
|
|
296
|
+
}
|
|
297
|
+
const video = files.find((f) => f.type.startsWith('video/'))
|
|
298
|
+
if (video) {
|
|
299
|
+
event.preventDefault()
|
|
300
|
+
void insertVideo(video)
|
|
301
|
+
return true
|
|
302
|
+
}
|
|
303
|
+
return false
|
|
304
|
+
}
|
|
305
|
+
|
|
334
306
|
const editor = useEditor({
|
|
335
307
|
content: normalizeHtml(String(props.modelValue || '')),
|
|
336
308
|
editable: !props.disabled,
|
|
337
309
|
extensions: [
|
|
338
|
-
StarterKit.configure({
|
|
339
|
-
heading: { levels: [2, 3] },
|
|
340
|
-
}),
|
|
310
|
+
StarterKit.configure({ heading: { levels: [2, 3] } }),
|
|
341
311
|
Underline,
|
|
312
|
+
TextStyle,
|
|
313
|
+
Color,
|
|
314
|
+
Highlight.configure({ multicolor: false }),
|
|
315
|
+
TextAlign.configure({ types: ['heading', 'paragraph'] }),
|
|
316
|
+
TaskList,
|
|
317
|
+
TaskItem.configure({ nested: true }),
|
|
342
318
|
Link.configure({
|
|
343
319
|
openOnClick: false,
|
|
344
|
-
HTMLAttributes: {
|
|
345
|
-
rel: 'noopener noreferrer',
|
|
346
|
-
target: '_blank',
|
|
347
|
-
},
|
|
320
|
+
HTMLAttributes: { rel: 'noopener noreferrer', target: '_blank' },
|
|
348
321
|
}),
|
|
349
322
|
Image.configure({ allowBase64: false }),
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
}),
|
|
323
|
+
VmRichtextVideo,
|
|
324
|
+
Youtube.configure({ width: 640, height: 360, nocookie: true }),
|
|
325
|
+
Table.configure({ resizable: true }),
|
|
326
|
+
TableRow,
|
|
327
|
+
TableHeader,
|
|
328
|
+
TableCell,
|
|
329
|
+
Placeholder.configure({ placeholder: props.placeholder }),
|
|
353
330
|
],
|
|
354
331
|
editorProps: {
|
|
355
|
-
attributes: {
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
handlePaste: (_view, event) => {
|
|
359
|
-
if (!uploadEnabled.value) return false
|
|
360
|
-
const items = Array.from(event.clipboardData?.items || [])
|
|
361
|
-
const imageItem = items.find((item) => item.type.startsWith('image/'))
|
|
362
|
-
if (!imageItem) return false
|
|
363
|
-
const file = imageItem.getAsFile()
|
|
364
|
-
if (!file) return false
|
|
365
|
-
event.preventDefault()
|
|
366
|
-
void insertImage(file)
|
|
367
|
-
return true
|
|
368
|
-
},
|
|
369
|
-
handleDrop: (_view, event, _slice, moved) => {
|
|
370
|
-
if (moved || !uploadEnabled.value) return false
|
|
371
|
-
const file = Array.from(event.dataTransfer?.files || []).find((f) =>
|
|
372
|
-
f.type.startsWith('image/'),
|
|
373
|
-
)
|
|
374
|
-
if (!file) return false
|
|
375
|
-
event.preventDefault()
|
|
376
|
-
void insertImage(file)
|
|
377
|
-
return true
|
|
378
|
-
},
|
|
332
|
+
attributes: { class: 'vm-richtext__prose' },
|
|
333
|
+
handlePaste: (_view, event) => handleMediaPaste(event),
|
|
334
|
+
handleDrop: (_view, event, _slice, moved) => handleMediaDrop(event, moved),
|
|
379
335
|
},
|
|
380
336
|
onUpdate: ({ editor: ed }) => {
|
|
381
337
|
if (syncing.value) return
|
|
@@ -383,28 +339,30 @@ const editor = useEditor({
|
|
|
383
339
|
},
|
|
384
340
|
})
|
|
385
341
|
|
|
386
|
-
watch(
|
|
387
|
-
|
|
388
|
-
(
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
syncing.value = false
|
|
397
|
-
},
|
|
398
|
-
)
|
|
342
|
+
watch(() => props.modelValue, (next) => {
|
|
343
|
+
const ed = editor.value
|
|
344
|
+
if (!ed || readOnlyMode.value) return
|
|
345
|
+
const html = normalizeHtml(String(next || ''))
|
|
346
|
+
const current = normalizeHtml(ed.getHTML())
|
|
347
|
+
if (current === html) return
|
|
348
|
+
syncing.value = true
|
|
349
|
+
ed.commands.setContent(html || '<p></p>', false)
|
|
350
|
+
syncing.value = false
|
|
351
|
+
})
|
|
399
352
|
|
|
400
|
-
watch(
|
|
401
|
-
()
|
|
402
|
-
|
|
403
|
-
editor.value?.setEditable(!v)
|
|
404
|
-
},
|
|
405
|
-
)
|
|
353
|
+
watch(() => props.disabled, (v) => {
|
|
354
|
+
editor.value?.setEditable(!v)
|
|
355
|
+
})
|
|
406
356
|
|
|
357
|
+
function onDocClick(e: MouseEvent) {
|
|
358
|
+
if (!emojiOpen.value) return
|
|
359
|
+
const el = emojiWrapRef.value
|
|
360
|
+
if (el && !el.contains(e.target as Node)) emojiOpen.value = false
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
onMounted(() => document.addEventListener('click', onDocClick))
|
|
407
364
|
onBeforeUnmount(() => {
|
|
365
|
+
document.removeEventListener('click', onDocClick)
|
|
408
366
|
editor.value?.destroy()
|
|
409
367
|
})
|
|
410
368
|
</script>
|
|
@@ -459,10 +417,7 @@ onBeforeUnmount(() => {
|
|
|
459
417
|
cursor: pointer;
|
|
460
418
|
transition: background 0.15s ease, color 0.15s ease;
|
|
461
419
|
|
|
462
|
-
i {
|
|
463
|
-
font-size: 15px;
|
|
464
|
-
line-height: 1;
|
|
465
|
-
}
|
|
420
|
+
i { font-size: 15px; line-height: 1; }
|
|
466
421
|
|
|
467
422
|
&:hover:not(:disabled) {
|
|
468
423
|
background: color-mix(in srgb, var(--brand) 10%, transparent);
|
|
@@ -474,10 +429,7 @@ onBeforeUnmount(() => {
|
|
|
474
429
|
color: var(--brand);
|
|
475
430
|
}
|
|
476
431
|
|
|
477
|
-
&:disabled {
|
|
478
|
-
opacity: 0.35;
|
|
479
|
-
cursor: not-allowed;
|
|
480
|
-
}
|
|
432
|
+
&:disabled { opacity: 0.35; cursor: not-allowed; }
|
|
481
433
|
}
|
|
482
434
|
|
|
483
435
|
.vm-richtext__sep {
|
|
@@ -487,6 +439,43 @@ onBeforeUnmount(() => {
|
|
|
487
439
|
background: var(--border);
|
|
488
440
|
}
|
|
489
441
|
|
|
442
|
+
.vm-richtext__emoji-wrap {
|
|
443
|
+
position: relative;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
.vm-richtext__emoji-panel {
|
|
447
|
+
position: absolute;
|
|
448
|
+
top: calc(100% + 4px);
|
|
449
|
+
left: 0;
|
|
450
|
+
z-index: 20;
|
|
451
|
+
display: grid;
|
|
452
|
+
grid-template-columns: repeat(8, 28px);
|
|
453
|
+
gap: 2px;
|
|
454
|
+
width: max-content;
|
|
455
|
+
max-width: 240px;
|
|
456
|
+
padding: 6px;
|
|
457
|
+
border: 1px solid var(--border);
|
|
458
|
+
border-radius: 8px;
|
|
459
|
+
background: var(--card);
|
|
460
|
+
box-shadow: var(--shadow-soft);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
.vm-richtext__emoji-item {
|
|
464
|
+
display: flex;
|
|
465
|
+
align-items: center;
|
|
466
|
+
justify-content: center;
|
|
467
|
+
width: 28px;
|
|
468
|
+
height: 28px;
|
|
469
|
+
border: none;
|
|
470
|
+
border-radius: 6px;
|
|
471
|
+
background: transparent;
|
|
472
|
+
font-size: 18px;
|
|
473
|
+
line-height: 1;
|
|
474
|
+
cursor: pointer;
|
|
475
|
+
|
|
476
|
+
&:hover { background: var(--muted); }
|
|
477
|
+
}
|
|
478
|
+
|
|
490
479
|
.vm-richtext__content {
|
|
491
480
|
height: var(--vm-richtext-height, 320px) !important;
|
|
492
481
|
min-height: var(--vm-richtext-height, 320px) !important;
|
|
@@ -502,43 +491,15 @@ onBeforeUnmount(() => {
|
|
|
502
491
|
line-height: 1.65;
|
|
503
492
|
word-break: break-word;
|
|
504
493
|
|
|
505
|
-
:deep(p) {
|
|
506
|
-
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
:deep(
|
|
510
|
-
:deep(
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
:deep(a) {
|
|
516
|
-
color: var(--brand);
|
|
517
|
-
text-decoration: underline;
|
|
518
|
-
}
|
|
519
|
-
|
|
520
|
-
:deep(img) {
|
|
521
|
-
max-width: 100%;
|
|
522
|
-
height: auto;
|
|
523
|
-
margin: 8px 0;
|
|
524
|
-
border-radius: 8px;
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
:deep(blockquote) {
|
|
528
|
-
margin: 0.5em 0;
|
|
529
|
-
padding-left: 12px;
|
|
530
|
-
border-left: 3px solid var(--border);
|
|
531
|
-
color: var(--muted-foreground);
|
|
532
|
-
}
|
|
533
|
-
|
|
534
|
-
:deep(h1),
|
|
535
|
-
:deep(h2),
|
|
536
|
-
:deep(h3),
|
|
537
|
-
:deep(h4) {
|
|
538
|
-
margin: 0.65em 0 0.35em;
|
|
539
|
-
font-weight: 650;
|
|
540
|
-
line-height: 1.35;
|
|
541
|
-
}
|
|
494
|
+
:deep(p) { margin: 0.5em 0; }
|
|
495
|
+
:deep(ul), :deep(ol) { margin: 0.5em 0; padding-left: 1.25em; }
|
|
496
|
+
:deep(a) { color: var(--brand); text-decoration: underline; }
|
|
497
|
+
:deep(img), :deep(video) { max-width: 100%; height: auto; margin: 8px 0; border-radius: 8px; }
|
|
498
|
+
:deep(iframe) { max-width: 100%; margin: 8px 0; border: none; border-radius: 8px; }
|
|
499
|
+
:deep(table) { width: 100%; border-collapse: collapse; margin: 8px 0; }
|
|
500
|
+
:deep(th), :deep(td) { border: 1px solid var(--border); padding: 6px 8px; }
|
|
501
|
+
:deep(blockquote) { margin: 0.5em 0; padding-left: 12px; border-left: 3px solid var(--border); color: var(--muted-foreground); }
|
|
502
|
+
:deep(h1), :deep(h2), :deep(h3), :deep(h4) { margin: 0.65em 0 0.35em; font-weight: 650; line-height: 1.35; }
|
|
542
503
|
}
|
|
543
504
|
|
|
544
505
|
.vm-richtext.is-readonly .vm-richtext__html {
|
|
@@ -557,9 +518,7 @@ onBeforeUnmount(() => {
|
|
|
557
518
|
font-size: 14px;
|
|
558
519
|
line-height: 1.65;
|
|
559
520
|
|
|
560
|
-
p {
|
|
561
|
-
margin: 0.45em 0;
|
|
562
|
-
}
|
|
521
|
+
p { margin: 0.45em 0; }
|
|
563
522
|
|
|
564
523
|
p.is-editor-empty:first-child::before {
|
|
565
524
|
content: attr(data-placeholder);
|
|
@@ -569,58 +528,23 @@ onBeforeUnmount(() => {
|
|
|
569
528
|
color: var(--muted-foreground);
|
|
570
529
|
}
|
|
571
530
|
|
|
572
|
-
h2,
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
}
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
blockquote {
|
|
586
|
-
margin: 0.45em 0;
|
|
587
|
-
padding-left: 12px;
|
|
588
|
-
border-left: 3px solid var(--border);
|
|
589
|
-
color: var(--muted-foreground);
|
|
590
|
-
}
|
|
591
|
-
|
|
592
|
-
a {
|
|
593
|
-
color: var(--brand);
|
|
594
|
-
text-decoration: underline;
|
|
595
|
-
cursor: pointer;
|
|
596
|
-
}
|
|
597
|
-
|
|
598
|
-
img {
|
|
599
|
-
display: block;
|
|
600
|
-
max-width: 100%;
|
|
601
|
-
height: auto;
|
|
602
|
-
margin: 8px 0;
|
|
603
|
-
border-radius: 8px;
|
|
604
|
-
}
|
|
605
|
-
|
|
606
|
-
code {
|
|
607
|
-
padding: 0.1em 0.35em;
|
|
608
|
-
border-radius: 4px;
|
|
609
|
-
background: var(--muted);
|
|
610
|
-
font-size: 0.92em;
|
|
611
|
-
}
|
|
612
|
-
|
|
613
|
-
pre {
|
|
614
|
-
margin: 0.5em 0;
|
|
615
|
-
padding: 10px 12px;
|
|
616
|
-
border-radius: 8px;
|
|
617
|
-
background: var(--muted);
|
|
618
|
-
overflow-x: auto;
|
|
619
|
-
|
|
620
|
-
code {
|
|
621
|
-
padding: 0;
|
|
622
|
-
background: transparent;
|
|
623
|
-
}
|
|
531
|
+
h2, h3 { margin: 0.75em 0 0.35em; font-weight: 650; line-height: 1.35; }
|
|
532
|
+
ul, ol { margin: 0.45em 0; padding-left: 1.25em; }
|
|
533
|
+
ul[data-type='taskList'] { list-style: none; padding-left: 0.25em; }
|
|
534
|
+
ul[data-type='taskList'] li { display: flex; gap: 8px; align-items: flex-start; }
|
|
535
|
+
ul[data-type='taskList'] li label { margin-top: 2px; }
|
|
536
|
+
blockquote { margin: 0.45em 0; padding-left: 12px; border-left: 3px solid var(--border); color: var(--muted-foreground); }
|
|
537
|
+
a { color: var(--brand); text-decoration: underline; cursor: pointer; }
|
|
538
|
+
img, video { display: block; max-width: 100%; height: auto; margin: 8px 0; border-radius: 8px; }
|
|
539
|
+
iframe { display: block; max-width: 100%; margin: 8px 0; border: none; border-radius: 8px; }
|
|
540
|
+
mark { background: color-mix(in srgb, var(--brand) 22%, transparent); border-radius: 2px; padding: 0 2px; }
|
|
541
|
+
code { padding: 0.1em 0.35em; border-radius: 4px; background: var(--muted); font-size: 0.92em; }
|
|
542
|
+
pre { margin: 0.5em 0; padding: 10px 12px; border-radius: 8px; background: var(--muted); overflow-x: auto;
|
|
543
|
+
code { padding: 0; background: transparent; }
|
|
624
544
|
}
|
|
545
|
+
table { width: 100%; border-collapse: collapse; margin: 8px 0; }
|
|
546
|
+
th, td { border: 1px solid var(--border); padding: 6px 8px; vertical-align: top; }
|
|
547
|
+
th { background: var(--muted); font-weight: 600; }
|
|
548
|
+
hr { margin: 12px 0; border: none; border-top: 1px solid var(--border); }
|
|
625
549
|
}
|
|
626
550
|
</style>
|