vome-core 0.3.41 → 0.3.43

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.
Files changed (73) hide show
  1. package/dist/admin/components/ui/avatar/index.js +1 -1
  2. package/dist/admin/components/ui/checkbox/index.js +1 -1
  3. package/dist/admin/components/ui/dialog/index.js +1 -1
  4. package/dist/admin/components/ui/dropdown-menu/index.js +1 -1
  5. package/dist/admin/components/ui/input/index.js +1 -1
  6. package/dist/admin/components/ui/label/index.js +1 -1
  7. package/dist/admin/components/ui/scroll-area/index.js +1 -1
  8. package/dist/admin/components/ui/select/index.js +1 -1
  9. package/dist/admin/components/ui/separator/index.js +1 -1
  10. package/dist/admin/components/ui/sheet/index.js +1 -1
  11. package/dist/admin/components/ui/sonner/index.js +1 -1
  12. package/dist/admin/components/ui/table/index.js +1 -1
  13. package/dist/admin/components/ui/tabs/index.js +1 -1
  14. package/dist/admin/components/ui/tooltip/index.js +1 -1
  15. package/dist/admin/crud/auto-fields.js +1 -1
  16. package/dist/admin/crud/components/vm-richtext.vue +113 -867
  17. package/dist/admin/crud/config.js +1 -1
  18. package/dist/admin/crud/confirm.js +1 -1
  19. package/dist/admin/crud/crud-page-request.js +1 -1
  20. package/dist/admin/crud/data-i18n.js +1 -1
  21. package/dist/admin/crud/dict.js +1 -1
  22. package/dist/admin/crud/export-fetch.js +1 -1
  23. package/dist/admin/crud/key.js +1 -1
  24. package/dist/admin/crud/plugins.js +1 -1
  25. package/dist/admin/crud/span.js +1 -1
  26. package/dist/admin/crud/style.js +1 -1
  27. package/dist/admin/crud/validate.js +1 -1
  28. package/dist/admin/directives/perm.js +1 -1
  29. package/dist/admin/hooks/useUpload.js +1 -1
  30. package/dist/admin/lib/browser.js +1 -1
  31. package/dist/admin/lib/cn.js +1 -1
  32. package/dist/admin/lib/dialog-float.js +1 -1
  33. package/dist/admin/lib/export-excel.js +1 -1
  34. package/dist/admin/lib/import-excel.js +1 -1
  35. package/dist/admin/lib/json.js +1 -1
  36. package/dist/admin/lib/menu.js +1 -1
  37. package/dist/admin/lib/micro-locale.js +1 -1
  38. package/dist/admin/lib/micro-theme.js +1 -1
  39. package/dist/admin/lib/shell.js +1 -1
  40. package/dist/admin/lib/theme-color-options.js +1 -1
  41. package/dist/admin/lib/tree.js +1 -1
  42. package/dist/admin/lib/upload.js +1 -1
  43. package/dist/admin/lib/video-frame.js +1 -1
  44. package/dist/admin/router/create.js +1 -1
  45. package/dist/admin/router/guards.js +1 -1
  46. package/dist/ai/index.js +1 -1
  47. package/dist/client/ai-model-registry.js +1 -1
  48. package/dist/client/base.js +1 -1
  49. package/dist/client/configure.js +1 -1
  50. package/dist/client/dict.js +1 -1
  51. package/dist/client/eps-payload.js +1 -1
  52. package/dist/client/eps.js +1 -1
  53. package/dist/client/index.js +1 -1
  54. package/dist/client/locale-change.js +1 -1
  55. package/dist/client/locale-store.js +1 -1
  56. package/dist/client/service.js +1 -1
  57. package/dist/client/vite-admin.js +1 -1
  58. package/dist/client/vite-auto-import.js +1 -1
  59. package/dist/client/vite-micro-proxy.js +1 -1
  60. package/dist/client/vite-plugin-eps.js +1 -1
  61. package/dist/client/vite-resolve.js +1 -1
  62. package/dist/config/index.js +1 -1
  63. package/dist/index.js +1 -1
  64. package/dist/server/index.js +1 -1
  65. package/dist/shared/datetime-picker.js +1 -1
  66. package/dist/shared/decimal.js +1 -1
  67. package/dist/shared/excel.js +1 -1
  68. package/dist/shared/index.js +1 -1
  69. package/dist/shared/locale-json.js +1 -1
  70. package/dist/shared/tree.js +1 -1
  71. package/package.json +3 -19
  72. package/dist/admin/crud/components/vm-richtext-extensions.d.ts +0 -14
  73. package/dist/admin/crud/components/vm-richtext-extensions.js +0 -1
@@ -17,146 +17,31 @@
17
17
  v-else
18
18
  class="vm-richtext__shell"
19
19
  >
20
- <div
21
- v-if="editor"
20
+ <Toolbar
22
21
  class="vm-richtext__toolbar"
23
- >
24
- <button type="button" class="vm-richtext__btn" :class="{ 'is-active': editor.isActive('bold') }" :title="crudLabel('rtBold')" @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="crudLabel('rtItalic')" @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="crudLabel('rtUnderline')" @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="crudLabel('rtStrike')" @click="editor.chain().focus().toggleStrike().run()"><i class="ri-strikethrough" /></button>
28
- <span
29
- class="vm-richtext__btn vm-richtext__color-btn"
30
- :class="{ 'is-active': editor.isActive('highlight'), 'is-disabled': disabled }"
31
- :title="crudLabel('rtBgColor')"
32
- role="button"
33
- @pointerdown="rememberSelection"
34
- >
35
- <i class="ri-mark-pen-line" aria-hidden="true" />
36
- <span class="vm-richtext__color-bar" :style="{ background: highlightColor }" />
37
- <vm-color-picker
38
- class="vm-richtext__color-hit"
39
- size="sm"
40
- swatch-only
41
- :enable-theme="false"
42
- :disabled="disabled"
43
- :model-value="highlightColor"
44
- @update:model-value="applyHighlightColor"
45
- />
46
- </span>
47
- <span
48
- class="vm-richtext__btn vm-richtext__color-btn"
49
- :class="{ 'is-active': Boolean(textColor && textColor !== '#111827'), 'is-disabled': disabled }"
50
- :title="crudLabel('rtTextColor')"
51
- role="button"
52
- @pointerdown="rememberSelection"
53
- >
54
- <i class="ri-font-color" aria-hidden="true" />
55
- <span class="vm-richtext__color-bar" :style="{ background: textColor }" />
56
- <vm-color-picker
57
- class="vm-richtext__color-hit"
58
- size="sm"
59
- swatch-only
60
- :enable-theme="false"
61
- :disabled="disabled"
62
- :model-value="textColor"
63
- @update:model-value="applyTextColor"
64
- />
65
- </span>
66
- <span class="vm-richtext__sep" />
67
- <button type="button" class="vm-richtext__btn" :class="{ 'is-active': editor.isActive('heading', { level: 2 }) }" :title="crudLabel('rtH2')" @click="editor.chain().focus().toggleHeading({ level: 2 }).run()">H2</button>
68
- <button type="button" class="vm-richtext__btn" :class="{ 'is-active': editor.isActive('heading', { level: 3 }) }" :title="crudLabel('rtH3')" @click="editor.chain().focus().toggleHeading({ level: 3 }).run()">H3</button>
69
- <button type="button" class="vm-richtext__btn" :class="{ 'is-active': editor.isActive('paragraph') }" :title="crudLabel('rtParagraph')" @click="editor.chain().focus().setParagraph().run()">P</button>
70
- <span class="vm-richtext__sep" />
71
- <button type="button" class="vm-richtext__btn" :class="{ 'is-active': editor.isActive({ textAlign: 'left' }) }" :title="crudLabel('rtAlignLeft')" @click="editor.chain().focus().setTextAlign('left').run()"><i class="ri-align-left" /></button>
72
- <button type="button" class="vm-richtext__btn" :class="{ 'is-active': editor.isActive({ textAlign: 'center' }) }" :title="crudLabel('rtAlignCenter')" @click="editor.chain().focus().setTextAlign('center').run()"><i class="ri-align-center" /></button>
73
- <button type="button" class="vm-richtext__btn" :class="{ 'is-active': editor.isActive({ textAlign: 'right' }) }" :title="crudLabel('rtAlignRight')" @click="editor.chain().focus().setTextAlign('right').run()"><i class="ri-align-right" /></button>
74
- <span class="vm-richtext__sep" />
75
- <button type="button" class="vm-richtext__btn" :class="{ 'is-active': editor.isActive('bulletList') }" :title="crudLabel('rtBulletList')" @click="editor.chain().focus().toggleBulletList().run()"><i class="ri-list-unordered" /></button>
76
- <button type="button" class="vm-richtext__btn" :class="{ 'is-active': editor.isActive('orderedList') }" :title="crudLabel('rtOrderedList')" @click="editor.chain().focus().toggleOrderedList().run()"><i class="ri-list-ordered" /></button>
77
- <button type="button" class="vm-richtext__btn" :class="{ 'is-active': editor.isActive('taskList') }" :title="crudLabel('rtTaskList')" @click="editor.chain().focus().toggleTaskList().run()"><i class="ri-checkbox-line" /></button>
78
- <button type="button" class="vm-richtext__btn" :class="{ 'is-active': editor.isActive('blockquote') }" :title="crudLabel('rtBlockquote')" @click="editor.chain().focus().toggleBlockquote().run()"><i class="ri-double-quotes-l" /></button>
79
- <button type="button" class="vm-richtext__btn" :class="{ 'is-active': editor.isActive('codeBlock') }" :title="crudLabel('rtCodeBlock')" @click="editor.chain().focus().toggleCodeBlock().run()"><i class="ri-code-box-line" /></button>
80
- <button type="button" class="vm-richtext__btn" :title="crudLabel('rtHr')" @click="editor.chain().focus().setHorizontalRule().run()"><i class="ri-separator" /></button>
81
- <span class="vm-richtext__sep" />
82
- <button type="button" class="vm-richtext__btn" :class="{ 'is-active': editor.isActive('link') }" :title="crudLabel('rtLink')" @click="toggleLink"><i class="ri-link" /></button>
83
- <button v-if="uploadEnabled" type="button" class="vm-richtext__btn" :title="crudLabel('rtImage')" @click="pickImage"><i class="ri-image-line" /></button>
84
- <button v-if="uploadEnabled" type="button" class="vm-richtext__btn" :title="crudLabel('rtVideo')" @click="pickVideo"><i class="ri-video-line" /></button>
85
- <button
86
- ref="emojiBtnRef"
87
- type="button"
88
- class="vm-richtext__btn"
89
- :class="{ 'is-active': emojiOpen }"
90
- :title="crudLabel('rtEmoji')"
91
- @click="toggleEmoji"
92
- >
93
- <i class="ri-emotion-line" />
94
- </button>
95
- <Teleport to="body">
96
- <div
97
- v-if="emojiOpen"
98
- ref="emojiPanelRef"
99
- class="vm-richtext__emoji-panel vm-dialog-float"
100
- :style="emojiPanelStyle"
101
- @pointerdown.stop
102
- @mousedown.stop
103
- >
104
- <button
105
- v-for="(e, i) in VM_RICHTEXT_EMOJIS"
106
- :key="i"
107
- type="button"
108
- class="vm-richtext__emoji-item"
109
- @click.stop="insertEmoji(e)"
110
- >
111
- {{ e }}
112
- </button>
113
- </div>
114
- </Teleport>
115
- <span class="vm-richtext__sep" />
116
- <button type="button" class="vm-richtext__btn" :title="crudLabel('rtTable')" @click="insertTable"><i class="ri-table-line" /></button>
117
- <button type="button" class="vm-richtext__btn" :title="crudLabel('rtUndo')" :disabled="!editor.can().undo()" @click="editor.chain().focus().undo().run()"><i class="ri-arrow-go-back-line" /></button>
118
- <button type="button" class="vm-richtext__btn" :title="crudLabel('rtRedo')" :disabled="!editor.can().redo()" @click="editor.chain().focus().redo().run()"><i class="ri-arrow-go-forward-line" /></button>
119
- </div>
120
-
121
- <EditorContent class="vm-richtext__content" :editor="editor" />
122
- <vm-context-menu
123
- ref="tableCtxRef"
124
- :items="tableCtxItems"
125
- :z-index="10050"
126
- @select="onTableCtxSelect"
22
+ :editor="editorRef"
23
+ :default-config="toolbarConfig"
24
+ mode="default"
25
+ />
26
+ <Editor
27
+ class="vm-richtext__editor"
28
+ v-model="html"
29
+ :default-config="editorConfig"
30
+ mode="default"
31
+ @onCreated="handleCreated"
127
32
  />
128
33
  </div>
129
34
  </div>
130
35
  </template>
131
36
 
132
37
  <script setup lang="ts">
38
+ import '@wangeditor/editor/dist/css/style.css'
133
39
  import { crudLabel } from '../style'
134
- import { computed, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue'
40
+ import { computed, onBeforeUnmount, shallowRef, watch } from 'vue'
135
41
  import DOMPurify from 'dompurify'
136
- import { EditorContent, useEditor } from '@tiptap/vue-3'
137
- import StarterKit from '@tiptap/starter-kit'
138
- import Image from '@tiptap/extension-image'
139
- import Link from '@tiptap/extension-link'
140
- import Placeholder from '@tiptap/extension-placeholder'
141
- import Underline from '@tiptap/extension-underline'
142
- import TextAlign from '@tiptap/extension-text-align'
143
- import Highlight from '@tiptap/extension-highlight'
144
- import TextStyle from '@tiptap/extension-text-style'
145
- import Color from '@tiptap/extension-color'
146
- import TaskList from '@tiptap/extension-task-list'
147
- import TaskItem from '@tiptap/extension-task-item'
148
- import Table from '@tiptap/extension-table'
149
- import TableRow from '@tiptap/extension-table-row'
150
- import TableHeader from '@tiptap/extension-table-header'
151
- import TableCell from '@tiptap/extension-table-cell'
42
+ import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
43
+ import type { IDomEditor, IEditorConfig, IToolbarConfig } from '@wangeditor/editor'
152
44
  import { useUpload } from '../../hooks/useUpload'
153
- import { VM_RICHTEXT_EMOJIS, VmRichtextVideo } from './vm-richtext-extensions'
154
- import VmColorPicker from './vm-color-picker.vue'
155
- import VmContextMenu from './vm-context-menu.vue'
156
- import { CellSelection, cellAround } from '@tiptap/pm/tables'
157
- import type { Editor } from '@tiptap/core'
158
- import type { EditorView } from '@tiptap/pm/view'
159
- import type { CtxMenuItem } from '../../../../typings/admin/comm/crud'
160
45
 
161
46
  defineOptions({ name: 'vm-richtext' })
162
47
 
@@ -190,216 +75,8 @@ const props = withDefaults(
190
75
  const emit = defineEmits<{ 'update:modelValue': [v: string] }>()
191
76
 
192
77
  const { toUpload } = useUpload()
78
+ const editorRef = shallowRef<IDomEditor>()
193
79
  const syncing = shallowRef(false)
194
- const uploading = shallowRef(false)
195
- const emojiOpen = shallowRef(false)
196
- const emojiBtnRef = shallowRef<HTMLElement>()
197
- const emojiPanelRef = shallowRef<HTMLElement>()
198
- const emojiPanelStyle = shallowRef<Record<string, string>>({})
199
- const textColor = ref('#111827')
200
- const highlightColor = ref('#fef08a')
201
- /** 点开取色器前保存选区,避免焦点离开编辑器后丢标记范围 */
202
- let savedSelection: { from: number; to: number } | null = null
203
-
204
- const tableCtxRef = ref<{ open: (ev: MouseEvent) => void; close: () => void } | null>(null)
205
- const tableCtxItems = ref<CtxMenuItem[]>([])
206
- /** 右键按下时缓存多选,防止 mousedown 先把 CellSelection 清掉 */
207
- let pendingCellSel: { anchor: number; head: number } | null = null
208
-
209
- type TableCtxKey =
210
- | 'addColumnBefore'
211
- | 'addColumnAfter'
212
- | 'addRowBefore'
213
- | 'addRowAfter'
214
- | 'deleteColumn'
215
- | 'deleteRow'
216
- | 'mergeCells'
217
- | 'splitCell'
218
-
219
- function cellPosInSelection(sel: CellSelection, cellPos: number) {
220
- let hit = false
221
- sel.forEachCell((_node, pos) => {
222
- if (pos === cellPos) hit = true
223
- })
224
- return hit
225
- }
226
-
227
- function restorePendingCellSelection(view: EditorView) {
228
- if (!pendingCellSel) return false
229
- try {
230
- const sel = CellSelection.create(
231
- view.state.doc,
232
- pendingCellSel.anchor,
233
- pendingCellSel.head,
234
- )
235
- view.dispatch(view.state.tr.setSelection(sel))
236
- return true
237
- } catch {
238
- return false
239
- }
240
- }
241
-
242
- /**
243
- * 右键 mousedown:表格插件会先清掉多选。
244
- * 若点在已有 CellSelection 内 → preventDefault 并记下选区,供 contextmenu 恢复。
245
- */
246
- function onTableMouseDown(view: EditorView, event: MouseEvent) {
247
- if (event.button !== 2) {
248
- pendingCellSel = null
249
- return false
250
- }
251
- const target = event.target as HTMLElement | null
252
- if (!target?.closest?.('td, th')) {
253
- pendingCellSel = null
254
- return false
255
- }
256
-
257
- const { selection } = view.state
258
- if (!(selection instanceof CellSelection)) {
259
- pendingCellSel = null
260
- return false
261
- }
262
-
263
- pendingCellSel = {
264
- anchor: selection.$anchorCell.pos,
265
- head: selection.$headCell.pos,
266
- }
267
-
268
- const coords = view.posAtCoords({ left: event.clientX, top: event.clientY })
269
- if (!coords) return false
270
- const $cell = cellAround(view.state.doc.resolve(coords.pos))
271
- if (!$cell) return false
272
-
273
- if (cellPosInSelection(selection, $cell.pos)) {
274
- event.preventDefault()
275
- return true
276
- }
277
- return false
278
- }
279
-
280
- /** 右键落点:已在多选内则保留;否则选中该单元格作为参照 */
281
- function selectTableCellFromEvent(ed: Editor, event: MouseEvent) {
282
- const view = ed.view
283
- const coords = view.posAtCoords({ left: event.clientX, top: event.clientY })
284
- if (!coords) return false
285
- const $cell = cellAround(view.state.doc.resolve(coords.pos))
286
- if (!$cell) return false
287
-
288
- const { selection } = view.state
289
- if (selection instanceof CellSelection && cellPosInSelection(selection, $cell.pos)) {
290
- return true
291
- }
292
-
293
- // mousedown 已清多选时,用缓存恢复(且右键仍落在原多选内)
294
- if (pendingCellSel) {
295
- const probe = CellSelection.create(
296
- view.state.doc,
297
- pendingCellSel.anchor,
298
- pendingCellSel.head,
299
- )
300
- if (cellPosInSelection(probe, $cell.pos)) {
301
- return restorePendingCellSelection(view)
302
- }
303
- }
304
-
305
- const tr = view.state.tr.setSelection(CellSelection.create(view.state.doc, $cell.pos))
306
- view.dispatch(tr)
307
- return true
308
- }
309
-
310
- function buildTableCtxItems(ed: Editor): CtxMenuItem[] {
311
- const can = ed.can()
312
- return [
313
- {
314
- key: 'addColumnBefore',
315
- label: crudLabel('rtTableColBefore'),
316
- disabled: !can.addColumnBefore(),
317
- },
318
- {
319
- key: 'addColumnAfter',
320
- label: crudLabel('rtTableColAfter'),
321
- disabled: !can.addColumnAfter(),
322
- },
323
- {
324
- key: 'addRowBefore',
325
- label: crudLabel('rtTableRowBefore'),
326
- disabled: !can.addRowBefore(),
327
- },
328
- {
329
- key: 'addRowAfter',
330
- label: crudLabel('rtTableRowAfter'),
331
- disabled: !can.addRowAfter(),
332
- },
333
- {
334
- key: 'deleteColumn',
335
- label: crudLabel('rtTableDeleteCol'),
336
- disabled: !can.deleteColumn(),
337
- },
338
- {
339
- key: 'deleteRow',
340
- label: crudLabel('rtTableDeleteRow'),
341
- disabled: !can.deleteRow(),
342
- },
343
- {
344
- key: 'mergeCells',
345
- label: crudLabel('rtTableMerge'),
346
- disabled: !can.mergeCells(),
347
- },
348
- {
349
- key: 'splitCell',
350
- label: crudLabel('rtTableSplit'),
351
- disabled: !can.splitCell(),
352
- },
353
- ]
354
- }
355
-
356
- function onTableContextMenu(view: EditorView, event: MouseEvent) {
357
- const ed = editor.value
358
- if (!ed || ed.isDestroyed || props.disabled) return false
359
- const target = event.target as HTMLElement | null
360
- if (!target?.closest?.('td, th')) return false
361
- if (!selectTableCellFromEvent(ed, event)) return false
362
- event.preventDefault()
363
- tableCtxItems.value = buildTableCtxItems(ed)
364
- tableCtxRef.value?.open(event)
365
- pendingCellSel = null
366
- return true
367
- }
368
-
369
- function onTableCtxSelect(key: string) {
370
- const ed = editor.value
371
- if (!ed) return
372
- const k = key as TableCtxKey
373
- const chain = ed.chain().focus()
374
- switch (k) {
375
- case 'addColumnBefore':
376
- chain.addColumnBefore().run()
377
- break
378
- case 'addColumnAfter':
379
- chain.addColumnAfter().run()
380
- break
381
- case 'addRowBefore':
382
- chain.addRowBefore().run()
383
- break
384
- case 'addRowAfter':
385
- chain.addRowAfter().run()
386
- break
387
- case 'deleteColumn':
388
- chain.deleteColumn().run()
389
- break
390
- case 'deleteRow':
391
- chain.deleteRow().run()
392
- break
393
- case 'mergeCells':
394
- chain.mergeCells().run()
395
- break
396
- case 'splitCell':
397
- chain.splitCell().run()
398
- break
399
- default:
400
- break
401
- }
402
- }
403
80
 
404
81
  const readOnlyMode = computed(() => props.readonly)
405
82
  const uploadEnabled = computed(() => Boolean(props.uploadImage) || props.autoUpload !== false)
@@ -427,6 +104,8 @@ function normalizeHtml(html: string) {
427
104
 
428
105
  const safeHtml = computed(() => sanitize(String(props.modelValue || '')))
429
106
 
107
+ const html = shallowRef(normalizeHtml(String(props.modelValue || '')) || '<p><br></p>')
108
+
430
109
  async function resolveUpload(file: File): Promise<string> {
431
110
  if (props.uploadImage) return props.uploadImage(file)
432
111
  if (props.autoUpload === false) throw new Error(crudLabel('noUploadImage'))
@@ -434,306 +113,67 @@ async function resolveUpload(file: File): Promise<string> {
434
113
  return res.url
435
114
  }
436
115
 
437
- async function insertImage(file: File) {
438
- if (!editor.value || uploading.value) return
439
- uploading.value = true
440
- try {
441
- const url = await resolveUpload(file)
442
- editor.value.chain().focus().setImage({ src: url, alt: file.name || 'image' }).run()
443
- } finally {
444
- uploading.value = false
445
- }
446
- }
116
+ const toolbarConfig = computed<Partial<IToolbarConfig>>(() => ({
117
+ excludeKeys: uploadEnabled.value ? [] : ['uploadImage', 'uploadVideo'],
118
+ }))
447
119
 
448
- async function insertVideo(file: File) {
449
- if (!editor.value || uploading.value) return
450
- uploading.value = true
451
- try {
452
- const url = await resolveUpload(file)
453
- editor.value.chain().focus().setVideo({ src: url }).run()
454
- } finally {
455
- uploading.value = false
456
- }
457
- }
458
-
459
- function pickFile(accept: string, handler: (file: File) => void) {
460
- const input = document.createElement('input')
461
- input.type = 'file'
462
- input.accept = accept
463
- input.onchange = () => {
464
- const file = input.files?.[0]
465
- if (file) handler(file)
466
- }
467
- input.click()
468
- }
469
-
470
- function pickImage() {
471
- pickFile('image/*', (file) => void insertImage(file))
472
- }
473
-
474
- function pickVideo() {
475
- pickFile('video/*', (file) => void insertVideo(file))
476
- }
477
-
478
- function placeEmojiPanel() {
479
- const btn = emojiBtnRef.value
480
- if (!btn) return
481
- const rect = btn.getBoundingClientRect()
482
- // 10×28 + 9×2 gap + 16 padding
483
- const panelW = 314
484
- const panelH = 280
485
- const gap = 6
486
- const pad = 8
487
- // 右缘对齐按钮(往左展开,避免溢出大弹窗右侧)
488
- let left = rect.right - panelW
489
- let top = rect.bottom + gap
490
-
491
- const host =
492
- (btn.closest('[data-slot="dialog-content"]') as HTMLElement | null) ||
493
- (btn.closest('.vm-dialog-shell-crud') as HTMLElement | null)
494
- const bound = host?.getBoundingClientRect() ?? {
495
- left: pad,
496
- right: window.innerWidth - pad,
497
- top: pad,
498
- bottom: window.innerHeight - pad,
499
- }
500
-
501
- left = Math.min(left, bound.right - panelW - pad)
502
- left = Math.max(left, bound.left + pad)
503
- if (top + panelH > bound.bottom - pad) {
504
- top = Math.max(bound.top + pad, rect.top - panelH - gap)
505
- }
506
-
507
- emojiPanelStyle.value = {
508
- position: 'fixed',
509
- left: `${Math.round(left)}px`,
510
- top: `${Math.round(top)}px`,
511
- width: `${panelW}px`,
512
- zIndex: '10050',
513
- }
514
- }
515
-
516
- async function toggleEmoji() {
517
- emojiOpen.value = !emojiOpen.value
518
- if (emojiOpen.value) {
519
- await nextTick()
520
- placeEmojiPanel()
521
- }
522
- }
523
-
524
- function insertEmoji(emoji: string) {
525
- editor.value?.chain().focus().insertContent(emoji).run()
526
- emojiOpen.value = false
527
- }
528
-
529
- function rememberSelection() {
530
- const ed = editor.value
531
- if (!ed) return
532
- const { from, to } = ed.state.selection
533
- savedSelection = { from, to }
534
- }
535
-
536
- function syncColorModels() {
537
- const ed = editor.value
538
- if (!ed) return
539
- const fg = ed.getAttributes('textStyle').color as string | undefined
540
- textColor.value = fg?.trim() || '#111827'
541
- const bg = ed.getAttributes('highlight').color as string | undefined
542
- highlightColor.value = bg?.trim() || '#fef08a'
543
- }
544
-
545
- function withSavedSelection(run: (chain: {
546
- unsetColor: () => { run: () => boolean }
547
- setColor: (c: string) => { run: () => boolean }
548
- unsetHighlight: () => { run: () => boolean }
549
- setHighlight: (attrs: { color: string }) => { run: () => boolean }
550
- }) => void) {
551
- const ed = editor.value
552
- if (!ed) return
553
- const chain = ed.chain().focus()
554
- if (savedSelection) {
555
- chain.setTextSelection(savedSelection)
556
- }
557
- run(chain as never)
558
- savedSelection = {
559
- from: ed.state.selection.from,
560
- to: ed.state.selection.to,
561
- }
562
- syncColorModels()
563
- }
564
-
565
- function applyTextColor(v: string) {
566
- const color = String(v || '').trim()
567
- withSavedSelection((chain) => {
568
- if (!color) {
569
- chain.unsetColor().run()
570
- return
571
- }
572
- chain.setColor(color).run()
573
- })
574
- }
575
-
576
- function applyHighlightColor(v: string) {
577
- const color = String(v || '').trim()
578
- withSavedSelection((chain) => {
579
- if (!color) {
580
- chain.unsetHighlight().run()
581
- return
582
- }
583
- chain.setHighlight({ color }).run()
584
- })
585
- }
586
-
587
- function insertTable() {
588
- editor.value
589
- ?.chain()
590
- .focus()
591
- .insertTable({ rows: 3, cols: 3, withHeaderRow: true })
592
- .run()
593
- }
594
-
595
- function toggleLink() {
596
- const ed = editor.value
597
- if (!ed) return
598
- const prev = ed.getAttributes('link').href as string | undefined
599
- if (ed.isActive('link')) {
600
- ed.chain().focus().unsetLink().run()
601
- return
602
- }
603
- const url = window.prompt(crudLabel('linkUrlPrompt'), prev || 'https://')
604
- if (url == null) return
605
- const href = url.trim()
606
- if (!href) {
607
- ed.chain().focus().unsetLink().run()
608
- return
609
- }
610
- ed.chain().focus().extendMarkRange('link').setLink({ href, target: '_blank', rel: 'noopener noreferrer' }).run()
611
- }
612
-
613
- function handleMediaPaste(event: ClipboardEvent) {
614
- if (!uploadEnabled.value) return false
615
- const items = Array.from(event.clipboardData?.items || [])
616
- const imageItem = items.find((item) => item.type.startsWith('image/'))
617
- if (imageItem) {
618
- const file = imageItem.getAsFile()
619
- if (!file) return false
620
- event.preventDefault()
621
- void insertImage(file)
622
- return true
623
- }
624
- const videoItem = items.find((item) => item.type.startsWith('video/'))
625
- if (videoItem) {
626
- const file = videoItem.getAsFile()
627
- if (!file) return false
628
- event.preventDefault()
629
- void insertVideo(file)
630
- return true
631
- }
632
- return false
633
- }
634
-
635
- function handleMediaDrop(event: DragEvent, moved: boolean) {
636
- if (moved || !uploadEnabled.value) return false
637
- const files = Array.from(event.dataTransfer?.files || [])
638
- const image = files.find((f) => f.type.startsWith('image/'))
639
- if (image) {
640
- event.preventDefault()
641
- void insertImage(image)
642
- return true
643
- }
644
- const video = files.find((f) => f.type.startsWith('video/'))
645
- if (video) {
646
- event.preventDefault()
647
- void insertVideo(video)
648
- return true
649
- }
650
- return false
651
- }
652
-
653
- const editor = useEditor({
654
- content: normalizeHtml(String(props.modelValue || '')),
655
- editable: !props.disabled,
656
- extensions: [
657
- StarterKit.configure({ heading: { levels: [2, 3] } }),
658
- Underline,
659
- TextStyle,
660
- Color,
661
- Highlight.configure({ multicolor: true }),
662
- TextAlign.configure({ types: ['heading', 'paragraph'] }),
663
- TaskList,
664
- TaskItem.configure({ nested: true }),
665
- Link.configure({
666
- openOnClick: false,
667
- HTMLAttributes: { rel: 'noopener noreferrer', target: '_blank' },
668
- }),
669
- Image.configure({ allowBase64: false }),
670
- VmRichtextVideo,
671
- Table.configure({ resizable: true }),
672
- TableRow,
673
- TableHeader,
674
- TableCell,
675
- Placeholder.configure({
676
- placeholder: props.placeholder ?? crudLabel('richtextPlaceholder'),
677
- }),
678
- ],
679
- editorProps: {
680
- attributes: { class: 'vm-richtext__prose' },
681
- handlePaste: (_view, event) => handleMediaPaste(event),
682
- handleDrop: (_view, event, _slice, moved) => handleMediaDrop(event, moved),
683
- handleDOMEvents: {
684
- mousedown: (view, event) => onTableMouseDown(view, event),
685
- contextmenu: (view, event) => onTableContextMenu(view, event),
120
+ const editorConfig = computed<Partial<IEditorConfig>>(() => ({
121
+ placeholder: props.placeholder ?? crudLabel('richtextPlaceholder'),
122
+ MENU_CONF: {
123
+ uploadImage: {
124
+ async customUpload(file: File, insertFn: (url: string, alt: string, href: string) => void) {
125
+ const url = await resolveUpload(file)
126
+ insertFn(url, file.name || 'image', url)
127
+ },
128
+ },
129
+ uploadVideo: {
130
+ async customUpload(file: File, insertFn: (url: string, poster: string) => void) {
131
+ const url = await resolveUpload(file)
132
+ insertFn(url, '')
133
+ },
686
134
  },
687
135
  },
688
- onUpdate: ({ editor: ed }) => {
689
- if (syncing.value) return
690
- emit('update:modelValue', normalizeHtml(ed.getHTML()))
691
- },
692
- onSelectionUpdate: () => {
693
- syncColorModels()
694
- },
695
- onTransaction: () => {
696
- syncColorModels()
697
- },
698
- })
136
+ }))
699
137
 
700
- watch(() => props.modelValue, (next) => {
701
- const ed = editor.value
702
- if (!ed || readOnlyMode.value) return
703
- const html = normalizeHtml(String(next || ''))
704
- const current = normalizeHtml(ed.getHTML())
705
- if (current === html) return
706
- syncing.value = true
707
- ed.commands.setContent(html || '<p></p>', false)
708
- syncing.value = false
709
- })
138
+ function handleCreated(editor: IDomEditor) {
139
+ editorRef.value = editor
140
+ if (props.disabled) editor.disable()
141
+ else editor.enable()
142
+ }
710
143
 
711
- watch(() => props.disabled, (v) => {
712
- editor.value?.setEditable(!v)
144
+ watch(html, (v) => {
145
+ if (syncing.value || readOnlyMode.value) return
146
+ emit('update:modelValue', normalizeHtml(String(v || '')))
713
147
  })
714
148
 
715
- function onDocPointer(e: Event) {
716
- if (!emojiOpen.value) return
717
- const t = e.target as Node
718
- if (emojiBtnRef.value?.contains(t)) return
719
- if (emojiPanelRef.value?.contains(t)) return
720
- emojiOpen.value = false
721
- }
149
+ watch(
150
+ () => props.modelValue,
151
+ (next) => {
152
+ if (readOnlyMode.value) return
153
+ const n = normalizeHtml(String(next || ''))
154
+ const cur = normalizeHtml(String(html.value || ''))
155
+ if (n === cur) return
156
+ syncing.value = true
157
+ html.value = n || '<p><br></p>'
158
+ syncing.value = false
159
+ },
160
+ )
722
161
 
723
- function onWinReposition() {
724
- if (emojiOpen.value) placeEmojiPanel()
725
- }
162
+ watch(
163
+ () => props.disabled,
164
+ (v) => {
165
+ const ed = editorRef.value
166
+ if (!ed) return
167
+ if (v) ed.disable()
168
+ else ed.enable()
169
+ },
170
+ )
726
171
 
727
- onMounted(() => {
728
- document.addEventListener('pointerdown', onDocPointer, true)
729
- window.addEventListener('resize', onWinReposition)
730
- window.addEventListener('scroll', onWinReposition, true)
731
- })
732
172
  onBeforeUnmount(() => {
733
- document.removeEventListener('pointerdown', onDocPointer, true)
734
- window.removeEventListener('resize', onWinReposition)
735
- window.removeEventListener('scroll', onWinReposition, true)
736
- editor.value?.destroy()
173
+ const ed = editorRef.value
174
+ if (!ed) return
175
+ ed.destroy()
176
+ editorRef.value = undefined
737
177
  })
738
178
  </script>
739
179
 
@@ -741,7 +181,6 @@ onBeforeUnmount(() => {
741
181
  .vm-richtext {
742
182
  width: 100%;
743
183
  min-width: 0;
744
- box-sizing: border-box;
745
184
  }
746
185
 
747
186
  .vm-richtext.is-disabled {
@@ -752,121 +191,21 @@ onBeforeUnmount(() => {
752
191
  .vm-richtext__shell {
753
192
  display: flex;
754
193
  flex-direction: column;
755
- width: 100%;
756
- min-width: 0;
757
- border: 1px solid var(--input);
758
- border-radius: var(--vm-control-radius);
759
- background: var(--card);
760
194
  overflow: hidden;
195
+ border: 1px solid var(--border);
196
+ border-radius: var(--radius, 8px);
197
+ background: var(--card);
761
198
  }
762
199
 
763
200
  .vm-richtext__toolbar {
764
- display: flex;
765
- flex-wrap: wrap;
766
- align-items: center;
767
- gap: 2px;
768
- padding: 6px 8px;
769
- border-bottom: 1px solid var(--border);
770
- background: var(--muted);
771
- }
772
-
773
- .vm-richtext__btn {
774
- display: inline-flex;
775
- align-items: center;
776
- justify-content: center;
777
- min-width: 28px;
778
- height: 28px;
779
- padding: 0 6px;
780
- border: none;
781
- border-radius: 6px;
782
- background: transparent;
783
- color: var(--foreground);
784
- font-size: 12px;
785
- font-weight: 600;
786
- line-height: 1;
787
- cursor: pointer;
788
- transition: background 0.15s ease, color 0.15s ease;
789
-
790
- i { font-size: 15px; line-height: 1; }
791
-
792
- &:hover:not(:disabled) {
793
- background: color-mix(in srgb, var(--brand) 10%, transparent);
794
- color: var(--brand);
795
- }
796
-
797
- &.is-active {
798
- background: color-mix(in srgb, var(--brand) 16%, transparent);
799
- color: var(--brand);
800
- }
801
-
802
- &:disabled { opacity: 0.35; cursor: not-allowed; }
803
- }
804
-
805
- /** 保留原图标 + 底部色条;透明覆盖层承接 vm-color-picker 点击 */
806
- .vm-richtext__color-btn {
807
- position: relative;
808
- display: inline-flex;
809
- flex-direction: column;
810
- align-items: center;
811
- justify-content: center;
812
- gap: 1px;
813
- padding: 0 5px 3px;
814
-
815
- &.is-disabled {
816
- opacity: 0.35;
817
- pointer-events: none;
818
- }
819
-
820
- i {
821
- position: relative;
822
- z-index: 1;
823
- pointer-events: none;
824
- }
825
- }
826
-
827
- .vm-richtext__color-bar {
828
- position: relative;
829
- z-index: 1;
830
- display: block;
831
- width: 14px;
832
- height: 3px;
833
- border-radius: 1px;
834
- pointer-events: none;
835
- box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--border) 70%, transparent);
836
- }
837
-
838
- .vm-richtext__color-hit {
839
- position: absolute !important;
840
- inset: 0;
841
- z-index: 2;
842
- width: auto !important;
843
- opacity: 0;
844
-
845
- :deep(.vm-color-picker__swatch) {
846
- width: 100%;
847
- height: 100%;
848
- border: none;
849
- border-radius: 6px;
850
- background: transparent;
851
- }
852
-
853
- :deep(.vm-color-picker__swatch-fill) {
854
- display: none;
855
- }
856
- }
857
-
858
- .vm-richtext__sep {
859
- width: 1px;
860
- height: 18px;
861
- margin: 0 4px;
862
- background: var(--border);
201
+ border-bottom: 1px solid var(--border) !important;
202
+ background: var(--muted) !important;
863
203
  }
864
204
 
865
- .vm-richtext__content {
866
- height: var(--vm-richtext-height, 320px) !important;
867
- min-height: var(--vm-richtext-height, 320px) !important;
205
+ .vm-richtext__editor {
868
206
  flex-shrink: 0;
869
- overflow-y: auto;
207
+ height: var(--vm-richtext-height, 320px) !important;
208
+ overflow-y: hidden;
870
209
  background: var(--card);
871
210
  }
872
211
 
@@ -878,7 +217,7 @@ onBeforeUnmount(() => {
878
217
  word-break: break-word;
879
218
 
880
219
  :deep(p) { margin: 0.5em 0; }
881
- /* Tailwind preflight 会清掉 list-style,只读展示也要补回 */
220
+ :deep(strong), :deep(b) { font-weight: 800; }
882
221
  :deep(ul) {
883
222
  margin: 0.5em 0;
884
223
  padding-left: 1.5em;
@@ -896,16 +235,21 @@ onBeforeUnmount(() => {
896
235
  :deep(ol ol) { list-style-type: lower-alpha; }
897
236
  :deep(ol ol ol) { list-style-type: lower-roman; }
898
237
  :deep(li) { display: list-item; }
899
- :deep(ul[data-type='taskList']) {
900
- list-style: none;
901
- padding-left: 0.25em;
902
- }
903
238
  :deep(a) { color: var(--brand); text-decoration: underline; }
904
239
  :deep(img), :deep(video) { max-width: 100%; height: auto; margin: 8px 0; border-radius: 8px; }
905
240
  :deep(table) { width: 100%; border-collapse: collapse; margin: 8px 0; }
906
241
  :deep(th), :deep(td) { border: 1px solid var(--border); padding: 6px 8px; }
907
- :deep(blockquote) { margin: 0.5em 0; padding-left: 12px; border-left: 3px solid var(--border); color: var(--muted-foreground); }
908
- :deep(h1), :deep(h2), :deep(h3), :deep(h4) { margin: 0.65em 0 0.35em; font-weight: 650; line-height: 1.35; }
242
+ :deep(blockquote) {
243
+ margin: 0.5em 0;
244
+ padding-left: 12px;
245
+ border-left: 3px solid var(--border);
246
+ color: var(--muted-foreground);
247
+ }
248
+ :deep(h1), :deep(h2), :deep(h3), :deep(h4) {
249
+ margin: 0.65em 0 0.35em;
250
+ font-weight: 650;
251
+ line-height: 1.35;
252
+ }
909
253
  }
910
254
 
911
255
  .vm-richtext.is-readonly .vm-richtext__html {
@@ -913,138 +257,40 @@ onBeforeUnmount(() => {
913
257
  border: none;
914
258
  background: transparent;
915
259
  }
916
- </style>
917
260
 
918
- <style lang="scss">
919
- /* Teleport 到 body:Dialog modal 会给 body pointer-events:none,须 vm-dialog-float 恢复 */
920
- .vm-richtext__emoji-panel {
921
- box-sizing: border-box;
922
- display: grid;
923
- grid-template-columns: repeat(10, 28px);
924
- gap: 2px;
925
- width: 314px;
926
- max-height: 280px;
927
- overflow-x: hidden;
928
- overflow-y: auto;
929
- overscroll-behavior: contain;
930
- padding: 8px;
931
- border: 1px solid var(--border);
932
- border-radius: 10px;
933
- background: var(--card);
934
- box-shadow: var(--shadow-soft, 0 8px 24px rgba(0, 0, 0, 0.12));
935
- scrollbar-width: thin;
936
- pointer-events: auto;
261
+ :deep(.w-e-toolbar) {
262
+ border: none !important;
263
+ background: transparent !important;
937
264
  }
938
265
 
939
- .vm-richtext__emoji-item {
940
- display: flex;
941
- align-items: center;
942
- justify-content: center;
943
- width: 28px;
944
- height: 28px;
945
- border: none;
946
- border-radius: 6px;
947
- background: transparent;
948
- font-size: 18px;
949
- line-height: 1;
950
- cursor: pointer;
266
+ :deep(.w-e-text-container) {
267
+ border: none !important;
268
+ background: var(--card) !important;
269
+ }
951
270
 
952
- &:hover {
953
- background: var(--muted);
954
- }
271
+ :deep(.w-e-scroll) {
272
+ height: var(--vm-richtext-height, 320px) !important;
955
273
  }
956
274
 
957
- .vm-richtext__content .tiptap.vm-richtext__prose {
958
- min-height: 100%;
959
- padding: 12px 14px;
960
- outline: none;
275
+ :deep(.w-e-text-container [data-slate-editor]) {
276
+ min-height: calc(var(--vm-richtext-height, 320px) - 16px) !important;
961
277
  color: var(--foreground);
962
278
  font-size: 14px;
963
279
  line-height: 1.65;
964
280
 
965
- p { margin: 0.45em 0; }
966
-
967
- p.is-editor-empty:first-child::before {
968
- content: attr(data-placeholder);
969
- float: left;
970
- height: 0;
971
- pointer-events: none;
972
- color: var(--muted-foreground);
973
- }
281
+ strong, b { font-weight: 800; }
282
+ }
283
+ </style>
974
284
 
975
- h2, h3 { margin: 0.75em 0 0.35em; font-weight: 650; line-height: 1.35; }
976
- /* Tailwind / 全局 reset 会 list-style:none,必须显式恢复,否则只见缩进不见符号 */
977
- ul {
978
- margin: 0.45em 0;
979
- padding-left: 1.5em;
980
- list-style-type: disc;
981
- list-style-position: outside;
982
- }
983
- ul ul { list-style-type: circle; }
984
- ul ul ul { list-style-type: square; }
985
- ol {
986
- margin: 0.45em 0;
987
- padding-left: 1.5em;
988
- list-style-type: decimal;
989
- list-style-position: outside;
990
- }
991
- ol ol { list-style-type: lower-alpha; }
992
- ol ol ol { list-style-type: lower-roman; }
993
- li {
994
- display: list-item;
995
- }
996
- ul[data-type='taskList'] {
997
- list-style: none;
998
- padding-left: 0.25em;
999
- }
1000
- ul[data-type='taskList'] li {
1001
- display: flex;
1002
- gap: 8px;
1003
- align-items: flex-start;
1004
- }
1005
- ul[data-type='taskList'] li label { margin-top: 2px; }
1006
- blockquote { margin: 0.45em 0; padding-left: 12px; border-left: 3px solid var(--border); color: var(--muted-foreground); }
1007
- a { color: var(--brand); text-decoration: underline; cursor: pointer; }
1008
- img, video { display: block; max-width: 100%; height: auto; margin: 8px 0; border-radius: 8px; }
1009
- mark {
1010
- border-radius: 2px;
1011
- padding: 0 2px;
1012
- }
1013
- mark:not([style*='background']) {
1014
- background: color-mix(in srgb, var(--brand) 22%, transparent);
1015
- }
1016
- code { padding: 0.1em 0.35em; border-radius: 4px; background: var(--muted); font-size: 0.92em; }
1017
- pre { margin: 0.5em 0; padding: 10px 12px; border-radius: 8px; background: var(--muted); overflow-x: auto;
1018
- code { padding: 0; background: transparent; }
1019
- }
1020
- table { width: 100%; border-collapse: collapse; margin: 8px 0; table-layout: fixed; }
1021
- th, td {
1022
- position: relative;
1023
- min-width: 48px;
1024
- padding: 6px 8px;
1025
- border: 1px solid var(--border);
1026
- vertical-align: top;
1027
- }
1028
- th { background: var(--muted); font-weight: 600; }
1029
- .selectedCell {
1030
- background: color-mix(in srgb, var(--brand) 16%, transparent);
1031
- }
1032
- .selectedCell::after {
1033
- content: '';
1034
- position: absolute;
1035
- inset: 0;
1036
- pointer-events: none;
1037
- background: color-mix(in srgb, var(--brand) 10%, transparent);
1038
- }
1039
- .column-resize-handle {
1040
- position: absolute;
1041
- top: 0;
1042
- right: -2px;
1043
- bottom: 0;
1044
- width: 4px;
1045
- background: var(--brand);
1046
- pointer-events: none;
1047
- }
1048
- hr { margin: 12px 0; border: none; border-top: 1px solid var(--border); }
285
+ <style lang="scss">
286
+ /* wangEditor 浮层挂到 body,需高于 Dialog */
287
+ .w-e-bar-item-menus-container,
288
+ .w-e-select-list,
289
+ .w-e-drop-panel,
290
+ .w-e-modal,
291
+ .w-e-panel-content-emotion,
292
+ .w-e-panel-content-color,
293
+ .w-e-panel-content-table {
294
+ z-index: 10050 !important;
1049
295
  }
1050
296
  </style>