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.
@@ -21,148 +21,74 @@
21
21
  v-if="editor"
22
22
  class="vm-richtext__toolbar"
23
23
  >
24
- <button
25
- type="button"
26
- class="vm-richtext__btn"
27
- :class="{ 'is-active': editor.isActive('bold') }"
28
- title="加粗"
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
- type="button"
91
- class="vm-richtext__btn"
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
- type="button"
119
- class="vm-richtext__btn"
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
- title="撤销"
140
- :disabled="!editor.can().undo()"
141
- @click="editor.chain().focus().undo().run()"
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-arrow-go-forward-line" />
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
- 'br',
180
- 'div',
181
- 'span',
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
- typeof props.height === 'number' ? `${props.height}px` : String(props.height || '320px')
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
- 'target',
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 pickImage() {
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 = 'image/*'
209
+ input.accept = accept
309
210
  input.onchange = () => {
310
211
  const file = input.files?.[0]
311
- if (file) void insertImage(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
- Placeholder.configure({
351
- placeholder: props.placeholder,
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
- class: 'vm-richtext__prose',
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
- () => props.modelValue,
388
- (next) => {
389
- const ed = editor.value
390
- if (!ed || readOnlyMode.value) return
391
- const html = normalizeHtml(String(next || ''))
392
- const current = normalizeHtml(ed.getHTML())
393
- if (current === html) return
394
- syncing.value = true
395
- ed.commands.setContent(html || '<p></p>', false)
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
- () => props.disabled,
402
- (v) => {
403
- editor.value?.setEditable(!v)
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
- margin: 0.5em 0;
507
- }
508
-
509
- :deep(ul),
510
- :deep(ol) {
511
- margin: 0.5em 0;
512
- padding-left: 1.25em;
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
- h3 {
574
- margin: 0.75em 0 0.35em;
575
- font-weight: 650;
576
- line-height: 1.35;
577
- }
578
-
579
- ul,
580
- ol {
581
- margin: 0.45em 0;
582
- padding-left: 1.25em;
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>