vome-core 0.1.30 → 0.1.32
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 +14 -0
- package/dist/admin/crud/components/vm-richtext-extensions.js +1 -0
- package/dist/admin/crud/components/vm-richtext.vue +307 -343
- 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 +18 -25
|
@@ -21,148 +21,74 @@
|
|
|
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" />
|
|
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>
|
|
136
50
|
<button
|
|
51
|
+
ref="emojiBtnRef"
|
|
137
52
|
type="button"
|
|
138
53
|
class="vm-richtext__btn"
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
@click="
|
|
142
|
-
>
|
|
143
|
-
<i class="ri-arrow-go-back-line" />
|
|
144
|
-
</button>
|
|
145
|
-
<button
|
|
146
|
-
type="button"
|
|
147
|
-
class="vm-richtext__btn"
|
|
148
|
-
title="重做"
|
|
149
|
-
:disabled="!editor.can().redo()"
|
|
150
|
-
@click="editor.chain().focus().redo().run()"
|
|
54
|
+
:class="{ 'is-active': emojiOpen }"
|
|
55
|
+
title="表情"
|
|
56
|
+
@click="toggleEmoji"
|
|
151
57
|
>
|
|
152
|
-
<i class="ri-
|
|
58
|
+
<i class="ri-emotion-line" />
|
|
153
59
|
</button>
|
|
60
|
+
<Teleport to="body">
|
|
61
|
+
<div
|
|
62
|
+
v-if="emojiOpen"
|
|
63
|
+
ref="emojiPanelRef"
|
|
64
|
+
class="vm-richtext__emoji-panel"
|
|
65
|
+
:style="emojiPanelStyle"
|
|
66
|
+
@mousedown.prevent
|
|
67
|
+
>
|
|
68
|
+
<button
|
|
69
|
+
v-for="(e, i) in VM_RICHTEXT_EMOJIS"
|
|
70
|
+
:key="i"
|
|
71
|
+
type="button"
|
|
72
|
+
class="vm-richtext__emoji-item"
|
|
73
|
+
@click="insertEmoji(e)"
|
|
74
|
+
>
|
|
75
|
+
{{ e }}
|
|
76
|
+
</button>
|
|
77
|
+
</div>
|
|
78
|
+
</Teleport>
|
|
79
|
+
<span class="vm-richtext__sep" />
|
|
80
|
+
<button type="button" class="vm-richtext__btn" title="插入表格" @click="insertTable"><i class="ri-table-line" /></button>
|
|
81
|
+
<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>
|
|
82
|
+
<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
83
|
</div>
|
|
155
84
|
|
|
156
|
-
<EditorContent
|
|
157
|
-
class="vm-richtext__content"
|
|
158
|
-
:editor="editor"
|
|
159
|
-
/>
|
|
85
|
+
<EditorContent class="vm-richtext__content" :editor="editor" />
|
|
160
86
|
</div>
|
|
161
87
|
</div>
|
|
162
88
|
</template>
|
|
163
89
|
|
|
164
90
|
<script setup lang="ts">
|
|
165
|
-
import { computed, onBeforeUnmount, shallowRef, watch } from 'vue'
|
|
91
|
+
import { computed, nextTick, onBeforeUnmount, onMounted, shallowRef, watch } from 'vue'
|
|
166
92
|
import DOMPurify from 'dompurify'
|
|
167
93
|
import { EditorContent, useEditor } from '@tiptap/vue-3'
|
|
168
94
|
import StarterKit from '@tiptap/starter-kit'
|
|
@@ -170,46 +96,26 @@ import Image from '@tiptap/extension-image'
|
|
|
170
96
|
import Link from '@tiptap/extension-link'
|
|
171
97
|
import Placeholder from '@tiptap/extension-placeholder'
|
|
172
98
|
import Underline from '@tiptap/extension-underline'
|
|
99
|
+
import TextAlign from '@tiptap/extension-text-align'
|
|
100
|
+
import Highlight from '@tiptap/extension-highlight'
|
|
101
|
+
import TextStyle from '@tiptap/extension-text-style'
|
|
102
|
+
import Color from '@tiptap/extension-color'
|
|
103
|
+
import TaskList from '@tiptap/extension-task-list'
|
|
104
|
+
import TaskItem from '@tiptap/extension-task-item'
|
|
105
|
+
import Table from '@tiptap/extension-table'
|
|
106
|
+
import TableRow from '@tiptap/extension-table-row'
|
|
107
|
+
import TableHeader from '@tiptap/extension-table-header'
|
|
108
|
+
import TableCell from '@tiptap/extension-table-cell'
|
|
173
109
|
import { useUpload } from '../../hooks/useUpload'
|
|
110
|
+
import { VM_RICHTEXT_EMOJIS, VmRichtextVideo } from './vm-richtext-extensions'
|
|
174
111
|
|
|
175
112
|
defineOptions({ name: 'vm-richtext' })
|
|
176
113
|
|
|
177
114
|
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',
|
|
115
|
+
'p', 'br', 'div', 'span', 'strong', 'b', 'em', 'i', 'u', 's', 'del', 'strike', 'a',
|
|
116
|
+
'ul', 'ol', 'li', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'code', 'pre',
|
|
117
|
+
'img', 'video', 'source', 'table', 'thead', 'tbody', 'tr', 'th', 'td', 'hr', 'sup', 'sub',
|
|
118
|
+
'mark',
|
|
213
119
|
]
|
|
214
120
|
|
|
215
121
|
const props = withDefaults(
|
|
@@ -219,11 +125,8 @@ const props = withDefaults(
|
|
|
219
125
|
disabled?: boolean
|
|
220
126
|
placeholder?: string
|
|
221
127
|
height?: number | string
|
|
222
|
-
/** 自定义上传;未传且 autoUpload=true 时走 useUpload 直传 OSS */
|
|
223
128
|
uploadImage?: (file: File) => Promise<string>
|
|
224
|
-
/** autoUpload 默认前缀(须 app/public/**) */
|
|
225
129
|
uploadPrefixPath?: string
|
|
226
|
-
/** false 时禁用插图/粘贴上传(不自动 useUpload) */
|
|
227
130
|
autoUpload?: boolean
|
|
228
131
|
}>(),
|
|
229
132
|
{
|
|
@@ -235,43 +138,30 @@ const props = withDefaults(
|
|
|
235
138
|
},
|
|
236
139
|
)
|
|
237
140
|
|
|
238
|
-
const emit = defineEmits<{
|
|
239
|
-
'update:modelValue': [v: string]
|
|
240
|
-
}>()
|
|
141
|
+
const emit = defineEmits<{ 'update:modelValue': [v: string] }>()
|
|
241
142
|
|
|
242
143
|
const { toUpload } = useUpload()
|
|
243
144
|
const syncing = shallowRef(false)
|
|
244
145
|
const uploading = shallowRef(false)
|
|
146
|
+
const emojiOpen = shallowRef(false)
|
|
147
|
+
const emojiBtnRef = shallowRef<HTMLElement>()
|
|
148
|
+
const emojiPanelRef = shallowRef<HTMLElement>()
|
|
149
|
+
const emojiPanelStyle = shallowRef<Record<string, string>>({})
|
|
245
150
|
|
|
246
151
|
const readOnlyMode = computed(() => props.readonly)
|
|
247
|
-
const uploadEnabled = computed(
|
|
248
|
-
() => Boolean(props.uploadImage) || props.autoUpload !== false,
|
|
249
|
-
)
|
|
152
|
+
const uploadEnabled = computed(() => Boolean(props.uploadImage) || props.autoUpload !== false)
|
|
250
153
|
|
|
251
154
|
const shellStyle = computed(() => {
|
|
252
|
-
const h =
|
|
253
|
-
|
|
254
|
-
return {
|
|
255
|
-
'--vm-richtext-height': h,
|
|
256
|
-
} as Record<string, string>
|
|
155
|
+
const h = typeof props.height === 'number' ? `${props.height}px` : String(props.height || '320px')
|
|
156
|
+
return { '--vm-richtext-height': h } as Record<string, string>
|
|
257
157
|
})
|
|
258
158
|
|
|
259
159
|
function sanitize(html: string) {
|
|
260
160
|
return DOMPurify.sanitize(html, {
|
|
261
161
|
ALLOWED_TAGS: RICH_TAGS,
|
|
262
162
|
ALLOWED_ATTR: [
|
|
263
|
-
'href',
|
|
264
|
-
'
|
|
265
|
-
'rel',
|
|
266
|
-
'class',
|
|
267
|
-
'src',
|
|
268
|
-
'alt',
|
|
269
|
-
'title',
|
|
270
|
-
'width',
|
|
271
|
-
'height',
|
|
272
|
-
'colspan',
|
|
273
|
-
'rowspan',
|
|
274
|
-
'align',
|
|
163
|
+
'href', 'target', 'rel', 'class', 'src', 'alt', 'title', 'width', 'height',
|
|
164
|
+
'colspan', 'rowspan', 'align', 'controls', 'style',
|
|
275
165
|
],
|
|
276
166
|
})
|
|
277
167
|
}
|
|
@@ -302,17 +192,94 @@ async function insertImage(file: File) {
|
|
|
302
192
|
}
|
|
303
193
|
}
|
|
304
194
|
|
|
305
|
-
function
|
|
195
|
+
async function insertVideo(file: File) {
|
|
196
|
+
if (!editor.value || uploading.value) return
|
|
197
|
+
uploading.value = true
|
|
198
|
+
try {
|
|
199
|
+
const url = await resolveUpload(file)
|
|
200
|
+
editor.value.chain().focus().setVideo({ src: url }).run()
|
|
201
|
+
} finally {
|
|
202
|
+
uploading.value = false
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function pickFile(accept: string, handler: (file: File) => void) {
|
|
306
207
|
const input = document.createElement('input')
|
|
307
208
|
input.type = 'file'
|
|
308
|
-
input.accept =
|
|
209
|
+
input.accept = accept
|
|
309
210
|
input.onchange = () => {
|
|
310
211
|
const file = input.files?.[0]
|
|
311
|
-
if (file)
|
|
212
|
+
if (file) handler(file)
|
|
312
213
|
}
|
|
313
214
|
input.click()
|
|
314
215
|
}
|
|
315
216
|
|
|
217
|
+
function pickImage() {
|
|
218
|
+
pickFile('image/*', (file) => void insertImage(file))
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function pickVideo() {
|
|
222
|
+
pickFile('video/*', (file) => void insertVideo(file))
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function placeEmojiPanel() {
|
|
226
|
+
const btn = emojiBtnRef.value
|
|
227
|
+
if (!btn) return
|
|
228
|
+
const rect = btn.getBoundingClientRect()
|
|
229
|
+
const panelW = 320
|
|
230
|
+
const panelH = 280
|
|
231
|
+
const gap = 6
|
|
232
|
+
let left = rect.left
|
|
233
|
+
let top = rect.bottom + gap
|
|
234
|
+
if (left + panelW > window.innerWidth - 8) {
|
|
235
|
+
left = Math.max(8, window.innerWidth - panelW - 8)
|
|
236
|
+
}
|
|
237
|
+
if (top + panelH > window.innerHeight - 8) {
|
|
238
|
+
top = Math.max(8, rect.top - panelH - gap)
|
|
239
|
+
}
|
|
240
|
+
emojiPanelStyle.value = {
|
|
241
|
+
position: 'fixed',
|
|
242
|
+
left: `${left}px`,
|
|
243
|
+
top: `${top}px`,
|
|
244
|
+
zIndex: '10050',
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async function toggleEmoji() {
|
|
249
|
+
emojiOpen.value = !emojiOpen.value
|
|
250
|
+
if (emojiOpen.value) {
|
|
251
|
+
await nextTick()
|
|
252
|
+
placeEmojiPanel()
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function insertEmoji(emoji: string) {
|
|
257
|
+
editor.value?.chain().focus().insertContent(emoji).run()
|
|
258
|
+
emojiOpen.value = false
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function insertTable() {
|
|
262
|
+
editor.value
|
|
263
|
+
?.chain()
|
|
264
|
+
.focus()
|
|
265
|
+
.insertTable({ rows: 3, cols: 3, withHeaderRow: true })
|
|
266
|
+
.run()
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function pickTextColor() {
|
|
270
|
+
const ed = editor.value
|
|
271
|
+
if (!ed) return
|
|
272
|
+
const current = (ed.getAttributes('textStyle').color as string | undefined) || '#111827'
|
|
273
|
+
const color = window.prompt('文字颜色(#RRGGBB)', current)
|
|
274
|
+
if (color == null) return
|
|
275
|
+
const v = color.trim()
|
|
276
|
+
if (!v) {
|
|
277
|
+
ed.chain().focus().unsetColor().run()
|
|
278
|
+
return
|
|
279
|
+
}
|
|
280
|
+
ed.chain().focus().setColor(v).run()
|
|
281
|
+
}
|
|
282
|
+
|
|
316
283
|
function toggleLink() {
|
|
317
284
|
const ed = editor.value
|
|
318
285
|
if (!ed) return
|
|
@@ -331,51 +298,74 @@ function toggleLink() {
|
|
|
331
298
|
ed.chain().focus().extendMarkRange('link').setLink({ href, target: '_blank', rel: 'noopener noreferrer' }).run()
|
|
332
299
|
}
|
|
333
300
|
|
|
301
|
+
function handleMediaPaste(event: ClipboardEvent) {
|
|
302
|
+
if (!uploadEnabled.value) return false
|
|
303
|
+
const items = Array.from(event.clipboardData?.items || [])
|
|
304
|
+
const imageItem = items.find((item) => item.type.startsWith('image/'))
|
|
305
|
+
if (imageItem) {
|
|
306
|
+
const file = imageItem.getAsFile()
|
|
307
|
+
if (!file) return false
|
|
308
|
+
event.preventDefault()
|
|
309
|
+
void insertImage(file)
|
|
310
|
+
return true
|
|
311
|
+
}
|
|
312
|
+
const videoItem = items.find((item) => item.type.startsWith('video/'))
|
|
313
|
+
if (videoItem) {
|
|
314
|
+
const file = videoItem.getAsFile()
|
|
315
|
+
if (!file) return false
|
|
316
|
+
event.preventDefault()
|
|
317
|
+
void insertVideo(file)
|
|
318
|
+
return true
|
|
319
|
+
}
|
|
320
|
+
return false
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function handleMediaDrop(event: DragEvent, moved: boolean) {
|
|
324
|
+
if (moved || !uploadEnabled.value) return false
|
|
325
|
+
const files = Array.from(event.dataTransfer?.files || [])
|
|
326
|
+
const image = files.find((f) => f.type.startsWith('image/'))
|
|
327
|
+
if (image) {
|
|
328
|
+
event.preventDefault()
|
|
329
|
+
void insertImage(image)
|
|
330
|
+
return true
|
|
331
|
+
}
|
|
332
|
+
const video = files.find((f) => f.type.startsWith('video/'))
|
|
333
|
+
if (video) {
|
|
334
|
+
event.preventDefault()
|
|
335
|
+
void insertVideo(video)
|
|
336
|
+
return true
|
|
337
|
+
}
|
|
338
|
+
return false
|
|
339
|
+
}
|
|
340
|
+
|
|
334
341
|
const editor = useEditor({
|
|
335
342
|
content: normalizeHtml(String(props.modelValue || '')),
|
|
336
343
|
editable: !props.disabled,
|
|
337
344
|
extensions: [
|
|
338
|
-
StarterKit.configure({
|
|
339
|
-
heading: { levels: [2, 3] },
|
|
340
|
-
}),
|
|
345
|
+
StarterKit.configure({ heading: { levels: [2, 3] } }),
|
|
341
346
|
Underline,
|
|
347
|
+
TextStyle,
|
|
348
|
+
Color,
|
|
349
|
+
Highlight.configure({ multicolor: false }),
|
|
350
|
+
TextAlign.configure({ types: ['heading', 'paragraph'] }),
|
|
351
|
+
TaskList,
|
|
352
|
+
TaskItem.configure({ nested: true }),
|
|
342
353
|
Link.configure({
|
|
343
354
|
openOnClick: false,
|
|
344
|
-
HTMLAttributes: {
|
|
345
|
-
rel: 'noopener noreferrer',
|
|
346
|
-
target: '_blank',
|
|
347
|
-
},
|
|
355
|
+
HTMLAttributes: { rel: 'noopener noreferrer', target: '_blank' },
|
|
348
356
|
}),
|
|
349
357
|
Image.configure({ allowBase64: false }),
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
358
|
+
VmRichtextVideo,
|
|
359
|
+
Table.configure({ resizable: true }),
|
|
360
|
+
TableRow,
|
|
361
|
+
TableHeader,
|
|
362
|
+
TableCell,
|
|
363
|
+
Placeholder.configure({ placeholder: props.placeholder }),
|
|
353
364
|
],
|
|
354
365
|
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
|
-
},
|
|
366
|
+
attributes: { class: 'vm-richtext__prose' },
|
|
367
|
+
handlePaste: (_view, event) => handleMediaPaste(event),
|
|
368
|
+
handleDrop: (_view, event, _slice, moved) => handleMediaDrop(event, moved),
|
|
379
369
|
},
|
|
380
370
|
onUpdate: ({ editor: ed }) => {
|
|
381
371
|
if (syncing.value) return
|
|
@@ -383,28 +373,42 @@ const editor = useEditor({
|
|
|
383
373
|
},
|
|
384
374
|
})
|
|
385
375
|
|
|
386
|
-
watch(
|
|
387
|
-
|
|
388
|
-
(
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
syncing.value = false
|
|
397
|
-
},
|
|
398
|
-
)
|
|
376
|
+
watch(() => props.modelValue, (next) => {
|
|
377
|
+
const ed = editor.value
|
|
378
|
+
if (!ed || readOnlyMode.value) return
|
|
379
|
+
const html = normalizeHtml(String(next || ''))
|
|
380
|
+
const current = normalizeHtml(ed.getHTML())
|
|
381
|
+
if (current === html) return
|
|
382
|
+
syncing.value = true
|
|
383
|
+
ed.commands.setContent(html || '<p></p>', false)
|
|
384
|
+
syncing.value = false
|
|
385
|
+
})
|
|
399
386
|
|
|
400
|
-
watch(
|
|
401
|
-
()
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
)
|
|
387
|
+
watch(() => props.disabled, (v) => {
|
|
388
|
+
editor.value?.setEditable(!v)
|
|
389
|
+
})
|
|
390
|
+
|
|
391
|
+
function onDocClick(e: MouseEvent) {
|
|
392
|
+
if (!emojiOpen.value) return
|
|
393
|
+
const t = e.target as Node
|
|
394
|
+
if (emojiBtnRef.value?.contains(t)) return
|
|
395
|
+
if (emojiPanelRef.value?.contains(t)) return
|
|
396
|
+
emojiOpen.value = false
|
|
397
|
+
}
|
|
406
398
|
|
|
399
|
+
function onWinReposition() {
|
|
400
|
+
if (emojiOpen.value) placeEmojiPanel()
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
onMounted(() => {
|
|
404
|
+
document.addEventListener('click', onDocClick)
|
|
405
|
+
window.addEventListener('resize', onWinReposition)
|
|
406
|
+
window.addEventListener('scroll', onWinReposition, true)
|
|
407
|
+
})
|
|
407
408
|
onBeforeUnmount(() => {
|
|
409
|
+
document.removeEventListener('click', onDocClick)
|
|
410
|
+
window.removeEventListener('resize', onWinReposition)
|
|
411
|
+
window.removeEventListener('scroll', onWinReposition, true)
|
|
408
412
|
editor.value?.destroy()
|
|
409
413
|
})
|
|
410
414
|
</script>
|
|
@@ -459,10 +463,7 @@ onBeforeUnmount(() => {
|
|
|
459
463
|
cursor: pointer;
|
|
460
464
|
transition: background 0.15s ease, color 0.15s ease;
|
|
461
465
|
|
|
462
|
-
i {
|
|
463
|
-
font-size: 15px;
|
|
464
|
-
line-height: 1;
|
|
465
|
-
}
|
|
466
|
+
i { font-size: 15px; line-height: 1; }
|
|
466
467
|
|
|
467
468
|
&:hover:not(:disabled) {
|
|
468
469
|
background: color-mix(in srgb, var(--brand) 10%, transparent);
|
|
@@ -474,10 +475,7 @@ onBeforeUnmount(() => {
|
|
|
474
475
|
color: var(--brand);
|
|
475
476
|
}
|
|
476
477
|
|
|
477
|
-
&:disabled {
|
|
478
|
-
opacity: 0.35;
|
|
479
|
-
cursor: not-allowed;
|
|
480
|
-
}
|
|
478
|
+
&:disabled { opacity: 0.35; cursor: not-allowed; }
|
|
481
479
|
}
|
|
482
480
|
|
|
483
481
|
.vm-richtext__sep {
|
|
@@ -502,43 +500,14 @@ onBeforeUnmount(() => {
|
|
|
502
500
|
line-height: 1.65;
|
|
503
501
|
word-break: break-word;
|
|
504
502
|
|
|
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
|
-
}
|
|
503
|
+
:deep(p) { margin: 0.5em 0; }
|
|
504
|
+
:deep(ul), :deep(ol) { margin: 0.5em 0; padding-left: 1.25em; }
|
|
505
|
+
:deep(a) { color: var(--brand); text-decoration: underline; }
|
|
506
|
+
:deep(img), :deep(video) { max-width: 100%; height: auto; margin: 8px 0; border-radius: 8px; }
|
|
507
|
+
:deep(table) { width: 100%; border-collapse: collapse; margin: 8px 0; }
|
|
508
|
+
:deep(th), :deep(td) { border: 1px solid var(--border); padding: 6px 8px; }
|
|
509
|
+
:deep(blockquote) { margin: 0.5em 0; padding-left: 12px; border-left: 3px solid var(--border); color: var(--muted-foreground); }
|
|
510
|
+
:deep(h1), :deep(h2), :deep(h3), :deep(h4) { margin: 0.65em 0 0.35em; font-weight: 650; line-height: 1.35; }
|
|
542
511
|
}
|
|
543
512
|
|
|
544
513
|
.vm-richtext.is-readonly .vm-richtext__html {
|
|
@@ -549,6 +518,39 @@ onBeforeUnmount(() => {
|
|
|
549
518
|
</style>
|
|
550
519
|
|
|
551
520
|
<style lang="scss">
|
|
521
|
+
/* Teleport 到 body:勿 scoped */
|
|
522
|
+
.vm-richtext__emoji-panel {
|
|
523
|
+
display: grid;
|
|
524
|
+
grid-template-columns: repeat(10, 28px);
|
|
525
|
+
gap: 2px;
|
|
526
|
+
width: 320px;
|
|
527
|
+
max-height: 280px;
|
|
528
|
+
overflow: auto;
|
|
529
|
+
padding: 8px;
|
|
530
|
+
border: 1px solid var(--border);
|
|
531
|
+
border-radius: 10px;
|
|
532
|
+
background: var(--card);
|
|
533
|
+
box-shadow: var(--shadow-soft, 0 8px 24px rgba(0, 0, 0, 0.12));
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
.vm-richtext__emoji-item {
|
|
537
|
+
display: flex;
|
|
538
|
+
align-items: center;
|
|
539
|
+
justify-content: center;
|
|
540
|
+
width: 28px;
|
|
541
|
+
height: 28px;
|
|
542
|
+
border: none;
|
|
543
|
+
border-radius: 6px;
|
|
544
|
+
background: transparent;
|
|
545
|
+
font-size: 18px;
|
|
546
|
+
line-height: 1;
|
|
547
|
+
cursor: pointer;
|
|
548
|
+
|
|
549
|
+
&:hover {
|
|
550
|
+
background: var(--muted);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
552
554
|
.vm-richtext__content .tiptap.vm-richtext__prose {
|
|
553
555
|
min-height: 100%;
|
|
554
556
|
padding: 12px 14px;
|
|
@@ -557,9 +559,7 @@ onBeforeUnmount(() => {
|
|
|
557
559
|
font-size: 14px;
|
|
558
560
|
line-height: 1.65;
|
|
559
561
|
|
|
560
|
-
p {
|
|
561
|
-
margin: 0.45em 0;
|
|
562
|
-
}
|
|
562
|
+
p { margin: 0.45em 0; }
|
|
563
563
|
|
|
564
564
|
p.is-editor-empty:first-child::before {
|
|
565
565
|
content: attr(data-placeholder);
|
|
@@ -569,58 +569,22 @@ onBeforeUnmount(() => {
|
|
|
569
569
|
color: var(--muted-foreground);
|
|
570
570
|
}
|
|
571
571
|
|
|
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
|
-
}
|
|
572
|
+
h2, h3 { margin: 0.75em 0 0.35em; font-weight: 650; line-height: 1.35; }
|
|
573
|
+
ul, ol { margin: 0.45em 0; padding-left: 1.25em; }
|
|
574
|
+
ul[data-type='taskList'] { list-style: none; padding-left: 0.25em; }
|
|
575
|
+
ul[data-type='taskList'] li { display: flex; gap: 8px; align-items: flex-start; }
|
|
576
|
+
ul[data-type='taskList'] li label { margin-top: 2px; }
|
|
577
|
+
blockquote { margin: 0.45em 0; padding-left: 12px; border-left: 3px solid var(--border); color: var(--muted-foreground); }
|
|
578
|
+
a { color: var(--brand); text-decoration: underline; cursor: pointer; }
|
|
579
|
+
img, video { display: block; max-width: 100%; height: auto; margin: 8px 0; border-radius: 8px; }
|
|
580
|
+
mark { background: color-mix(in srgb, var(--brand) 22%, transparent); border-radius: 2px; padding: 0 2px; }
|
|
581
|
+
code { padding: 0.1em 0.35em; border-radius: 4px; background: var(--muted); font-size: 0.92em; }
|
|
582
|
+
pre { margin: 0.5em 0; padding: 10px 12px; border-radius: 8px; background: var(--muted); overflow-x: auto;
|
|
583
|
+
code { padding: 0; background: transparent; }
|
|
624
584
|
}
|
|
585
|
+
table { width: 100%; border-collapse: collapse; margin: 8px 0; }
|
|
586
|
+
th, td { border: 1px solid var(--border); padding: 6px 8px; vertical-align: top; }
|
|
587
|
+
th { background: var(--muted); font-weight: 600; }
|
|
588
|
+
hr { margin: 12px 0; border: none; border-top: 1px solid var(--border); }
|
|
625
589
|
}
|
|
626
590
|
</style>
|