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.
@@ -21,148 +21,60 @@
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" />
136
- <button
137
- type="button"
138
- 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()"
151
- >
152
- <i class="ri-arrow-go-forward-line" />
153
- </button>
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
- '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',
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
- typeof props.height === 'number' ? `${props.height}px` : String(props.height || '320px')
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
- 'target',
265
- 'rel',
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 pickImage() {
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 = 'image/*'
195
+ input.accept = accept
309
196
  input.onchange = () => {
310
197
  const file = input.files?.[0]
311
- if (file) void insertImage(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
- Placeholder.configure({
351
- placeholder: props.placeholder,
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
- 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
- },
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
- () => 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
- )
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
- () => props.disabled,
402
- (v) => {
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
- 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
- }
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
- 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
- }
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>