@veltra/desktop 1.7.19 → 1.8.1

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 (114) hide show
  1. package/dist/components/batch-edit/batch-edit-form.js +40 -26
  2. package/dist/components/batch-edit/batch-edit-form.js.map +1 -1
  3. package/dist/components/batch-edit/use-handlers.js +3 -3
  4. package/dist/components/batch-edit/use-handlers.js.map +1 -1
  5. package/dist/components/batch-edit/use-shortcut-key.js +4 -2
  6. package/dist/components/batch-edit/use-shortcut-key.js.map +1 -1
  7. package/dist/components/card/style2.css +0 -2
  8. package/dist/components/condition-editor/condition-editor.js +8 -2
  9. package/dist/components/condition-editor/condition-editor.js.map +1 -1
  10. package/dist/components/dropdown/dropdown.js +5 -3
  11. package/dist/components/dropdown/dropdown.js.map +1 -1
  12. package/dist/components/expression-editor/components/path-preview.js +3 -6
  13. package/dist/components/expression-editor/components/path-preview.js.map +1 -1
  14. package/dist/components/expression-editor/components/variable-picker.js +49 -12
  15. package/dist/components/expression-editor/components/variable-picker.js.map +1 -1
  16. package/dist/components/expression-editor/core/editor.js +42 -0
  17. package/dist/components/expression-editor/core/editor.js.map +1 -1
  18. package/dist/components/expression-editor/expression-editor.js +68 -22
  19. package/dist/components/expression-editor/expression-editor.js.map +1 -1
  20. package/dist/components/expression-editor/style2.css +135 -73
  21. package/dist/components/form/form.js.map +1 -1
  22. package/dist/components/form/form.vue_vue_type_script_setup_true_lang.js +0 -1
  23. package/dist/components/form/form.vue_vue_type_script_setup_true_lang.js.map +1 -1
  24. package/dist/components/form/style2.css +8 -3
  25. package/dist/components/form-item/style2.css +14 -0
  26. package/dist/components/index.d.ts +27 -26
  27. package/dist/components/index.js +54 -52
  28. package/dist/components/index.js.map +1 -1
  29. package/dist/components/kanban/di.js +7 -0
  30. package/dist/components/kanban/di.js.map +1 -0
  31. package/dist/components/kanban/kanban-column.js +67 -0
  32. package/dist/components/kanban/kanban-column.js.map +1 -0
  33. package/dist/components/kanban/kanban.js +89 -0
  34. package/dist/components/kanban/kanban.js.map +1 -0
  35. package/dist/components/kanban/style.d.ts +1 -0
  36. package/dist/components/kanban/style.js +1 -0
  37. package/dist/components/kanban/style2.css +128 -0
  38. package/dist/components/multi-select/multi-select.js +16 -12
  39. package/dist/components/multi-select/multi-select.js.map +1 -1
  40. package/dist/components/multi-select/style2.css +7 -0
  41. package/dist/components/multi-tree-select/multi-tree-select.js +82 -56
  42. package/dist/components/multi-tree-select/multi-tree-select.js.map +1 -1
  43. package/dist/components/multi-tree-select/style2.css +7 -0
  44. package/dist/components/rich-text-editor/image-node.js +154 -0
  45. package/dist/components/rich-text-editor/image-node.js.map +1 -0
  46. package/dist/components/rich-text-editor/rich-text-editor.js +80 -22
  47. package/dist/components/rich-text-editor/rich-text-editor.js.map +1 -1
  48. package/dist/components/rich-text-editor/style2.css +6 -0
  49. package/dist/components/rich-text-editor/toolbar.js +22 -2
  50. package/dist/components/rich-text-editor/toolbar.js.map +1 -1
  51. package/dist/components/select/select.js +7 -3
  52. package/dist/components/select/select.js.map +1 -1
  53. package/dist/components/select/style2.css +7 -0
  54. package/dist/components/select/use-options.js +14 -3
  55. package/dist/components/select/use-options.js.map +1 -1
  56. package/dist/components/tree-select/style2.css +7 -0
  57. package/dist/components/tree-select/tree-select.js +49 -17
  58. package/dist/components/tree-select/tree-select.js.map +1 -1
  59. package/dist/index.d.ts +48 -46
  60. package/dist/index.js +47 -46
  61. package/dist/types/dropdown.d.ts +10 -0
  62. package/dist/types/index.d.ts +3 -2
  63. package/dist/types/kanban.d.ts +69 -0
  64. package/dist/types/multi-tree-select.d.ts +7 -1
  65. package/dist/types/rich-text-editor.d.ts +13 -2
  66. package/dist/types/tree-select.d.ts +7 -1
  67. package/package.json +1 -1
  68. package/src/components/batch-edit/__test__/batch-edit.test.ts +112 -1
  69. package/src/components/batch-edit/batch-edit-form.vue +21 -2
  70. package/src/components/batch-edit/use-handlers.ts +6 -5
  71. package/src/components/batch-edit/use-shortcut-key.ts +5 -2
  72. package/src/components/card/style.scss +0 -2
  73. package/src/components/condition-editor/__test__/condition-editor.test.ts +61 -0
  74. package/src/components/condition-editor/condition-editor.vue +6 -1
  75. package/src/components/dropdown/dropdown.vue +9 -4
  76. package/src/components/expression-editor/__test__/editor.test.ts +66 -0
  77. package/src/components/expression-editor/__test__/expression-editor.test.ts +124 -0
  78. package/src/components/expression-editor/components/path-preview.vue +1 -2
  79. package/src/components/expression-editor/components/variable-picker.vue +54 -13
  80. package/src/components/expression-editor/core/editor.ts +61 -0
  81. package/src/components/expression-editor/expression-editor.vue +58 -20
  82. package/src/components/expression-editor/style.scss +160 -76
  83. package/src/components/form/form.vue +0 -1
  84. package/src/components/form/style.scss +11 -4
  85. package/src/components/form-item/style.scss +16 -1
  86. package/src/components/index.ts +2 -0
  87. package/src/components/kanban/__test__/kanban.test.ts +212 -0
  88. package/src/components/kanban/di.ts +19 -0
  89. package/src/components/kanban/index.ts +1 -0
  90. package/src/components/kanban/kanban-column.vue +77 -0
  91. package/src/components/kanban/kanban.vue +81 -0
  92. package/src/components/kanban/style.scss +124 -0
  93. package/src/components/kanban/style.ts +1 -0
  94. package/src/components/multi-select/multi-select.vue +11 -2
  95. package/src/components/multi-select/style.scss +6 -0
  96. package/src/components/multi-tree-select/multi-tree-select.vue +58 -9
  97. package/src/components/multi-tree-select/style.scss +6 -0
  98. package/src/components/rich-text-editor/image-node.ts +193 -0
  99. package/src/components/rich-text-editor/rich-text-editor.vue +131 -29
  100. package/src/components/rich-text-editor/style.scss +7 -0
  101. package/src/components/rich-text-editor/toolbar.vue +21 -1
  102. package/src/components/select/__test__/select.test.ts +78 -0
  103. package/src/components/select/select.vue +13 -3
  104. package/src/components/select/style.scss +6 -0
  105. package/src/components/select/use-options.ts +25 -4
  106. package/src/components/tree-select/__test__/tree-select.test.ts +116 -0
  107. package/src/components/tree-select/style.scss +6 -0
  108. package/src/components/tree-select/tree-select.vue +63 -13
  109. package/src/types/dropdown.ts +10 -0
  110. package/src/types/index.ts +1 -0
  111. package/src/types/kanban.ts +63 -0
  112. package/src/types/multi-tree-select.ts +12 -1
  113. package/src/types/rich-text-editor.ts +14 -1
  114. package/src/types/tree-select.ts +12 -1
@@ -126,4 +126,10 @@ $root-name: multi-tree-select;
126
126
  @include m.e(content-tree) {
127
127
  max-height: 240px;
128
128
  }
129
+
130
+ @include m.e(loading) {
131
+ // 与树内容区同高,加载期间保持面板形态,避免塌缩成贴着输入框的小条
132
+ @include m.flex($justify: center, $align: center);
133
+ min-height: 240px;
134
+ }
129
135
  }
@@ -0,0 +1,193 @@
1
+ import {
2
+ $insertNodes,
3
+ DecoratorNode,
4
+ type DOMConversionMap,
5
+ type DOMConversionOutput,
6
+ type DOMExportOutput,
7
+ type LexicalEditor,
8
+ type LexicalNode,
9
+ type NodeKey,
10
+ type SerializedLexicalNode,
11
+ type Spread
12
+ } from 'lexical'
13
+
14
+ let imageUid = 0
15
+
16
+ function nextImageId(): string {
17
+ return `rte-img-${++imageUid}`
18
+ }
19
+
20
+ export interface SerializedImageNode extends Spread<
21
+ { src: string; alt: string; id: string },
22
+ SerializedLexicalNode
23
+ > {}
24
+
25
+ /**
26
+ * 图片节点:插入时 src 为本地 objectURL、__file 持有原始文件,提交前由宿主调用
27
+ * uploadImages 换成服务器地址。__file 不参与序列化,仅会话内有效。
28
+ * 本仓库为手写 Vue 绑定(无 React 适配层),不走 decorate 渲染管道,
29
+ * createDOM 直接返回 <img> 元素。
30
+ */
31
+ export class ImageNode extends DecoratorNode<void> {
32
+ __src: string
33
+ __alt: string
34
+ __id: string
35
+ __file: File | null
36
+
37
+ static override getType(): string {
38
+ return 'u-image'
39
+ }
40
+
41
+ static override clone(node: ImageNode): ImageNode {
42
+ return new ImageNode(node.__src, node.__alt, node.__id, node.__file, node.__key)
43
+ }
44
+
45
+ static override importJSON(serializedNode: SerializedImageNode): ImageNode {
46
+ return $createImageNode({
47
+ src: serializedNode.src,
48
+ alt: serializedNode.alt,
49
+ id: serializedNode.id
50
+ })
51
+ }
52
+
53
+ constructor(src: string, alt = '', id = nextImageId(), file: File | null = null, key?: NodeKey) {
54
+ super(key)
55
+ this.__src = src
56
+ this.__alt = alt
57
+ this.__id = id
58
+ this.__file = file
59
+ }
60
+
61
+ static override importDOM(): DOMConversionMap | null {
62
+ return { img: () => ({ conversion: convertImageElement, priority: 0 }) }
63
+ }
64
+
65
+ override exportDOM(): DOMExportOutput {
66
+ const element = document.createElement('img')
67
+ element.setAttribute('src', this.__src)
68
+ element.setAttribute('class', 'u-rte-image')
69
+ element.setAttribute('alt', this.__alt)
70
+ element.setAttribute('data-rte-image-id', this.__id)
71
+ return { element }
72
+ }
73
+
74
+ override exportJSON(): SerializedImageNode {
75
+ return { type: 'u-image', version: 1, src: this.__src, alt: this.__alt, id: this.__id }
76
+ }
77
+
78
+ override createDOM(): HTMLElement {
79
+ const element = document.createElement('img')
80
+ element.className = 'u-rte-image'
81
+ element.src = this.__src
82
+ element.alt = this.__alt
83
+ element.draggable = false
84
+ return element
85
+ }
86
+
87
+ override updateDOM(prevNode: ImageNode, element: HTMLElement): boolean {
88
+ const img = element as HTMLImageElement
89
+ if (prevNode.__src !== this.__src) img.src = this.__src
90
+ if (prevNode.__alt !== this.__alt) img.alt = this.__alt
91
+ return false
92
+ }
93
+
94
+ /** Vue 环境无 React 渲染管道,装饰内容即 createDOM 返回的元素本身 */
95
+ override decorate(): null {
96
+ return null
97
+ }
98
+
99
+ getSrc(): string {
100
+ return this.__src
101
+ }
102
+
103
+ getFile(): File | null {
104
+ return this.__file
105
+ }
106
+
107
+ /** 替换为服务器地址,同时清除待上传标记(已上传的节点不再重复上传) */
108
+ setSrc(src: string): void {
109
+ const self = this.getWritable()
110
+ self.__src = src
111
+ self.__file = null
112
+ }
113
+ }
114
+
115
+ function convertImageElement(domNode: HTMLElement): DOMConversionOutput {
116
+ const src = domNode.getAttribute('src')
117
+ if (!src) return { node: null }
118
+ return {
119
+ node: $createImageNode({
120
+ src,
121
+ alt: domNode.getAttribute('alt') ?? '',
122
+ id: domNode.getAttribute('data-rte-image-id') ?? undefined
123
+ })
124
+ }
125
+ }
126
+
127
+ export function $createImageNode(options: {
128
+ src: string
129
+ alt?: string
130
+ id?: string
131
+ file?: File | null
132
+ }): ImageNode {
133
+ return new ImageNode(
134
+ options.src,
135
+ options.alt ?? '',
136
+ options.id ?? nextImageId(),
137
+ options.file ?? null
138
+ )
139
+ }
140
+
141
+ export function $isImageNode(node: LexicalNode | null | undefined): node is ImageNode {
142
+ return node instanceof ImageNode
143
+ }
144
+
145
+ // 各编辑器实例创建的图片 objectURL,用于节点删除 / 组件卸载时回收
146
+ const objectUrls = new WeakMap<LexicalEditor, Set<string>>()
147
+
148
+ function getImageObjectUrls(editor: LexicalEditor): Set<string> {
149
+ let urls = objectUrls.get(editor)
150
+ if (!urls) {
151
+ urls = new Set()
152
+ objectUrls.set(editor, urls)
153
+ }
154
+ return urls
155
+ }
156
+
157
+ /** 插入图片文件:objectURL 即时预览,File 挂在节点上等待提交时上传。返回插入的图片数量 */
158
+ export function insertImageFiles(editor: LexicalEditor, files: File[] | FileList): number {
159
+ const images = Array.from(files).filter((file) => file.type.startsWith('image/'))
160
+ if (!images.length) return 0
161
+
162
+ const urls = getImageObjectUrls(editor)
163
+
164
+ editor.update(() => {
165
+ const nodes = images.map((file) => {
166
+ const url = URL.createObjectURL(file)
167
+ urls.add(url)
168
+ return $createImageNode({ src: url, alt: file.name, file })
169
+ })
170
+ $insertNodes(nodes)
171
+ })
172
+
173
+ return images.length
174
+ }
175
+
176
+ /** 回收已从内容中移除的图片 objectURL,aliveUrls 为当前内容中仍存活的图片地址 */
177
+ export function gcImageObjectUrls(editor: LexicalEditor, aliveUrls: Set<string>): void {
178
+ const urls = objectUrls.get(editor)
179
+ if (!urls?.size) return
180
+ urls.forEach((url) => {
181
+ if (!aliveUrls.has(url)) {
182
+ URL.revokeObjectURL(url)
183
+ urls.delete(url)
184
+ }
185
+ })
186
+ }
187
+
188
+ /** 回收该编辑器创建的全部图片 objectURL(组件卸载时调用) */
189
+ export function revokeImageObjectUrls(editor: LexicalEditor): void {
190
+ const urls = objectUrls.get(editor)
191
+ urls?.forEach((url) => URL.revokeObjectURL(url))
192
+ objectUrls.delete(editor)
193
+ }
@@ -66,7 +66,9 @@ const DEFAULT_TOOLBAR: ToolbarItem[] = [
66
66
  '|',
67
67
  'blockquote',
68
68
  '|',
69
- 'link'
69
+ 'link',
70
+ '|',
71
+ 'image'
70
72
  ]
71
73
  </script>
72
74
 
@@ -80,10 +82,33 @@ import { HeadingNode, QuoteNode } from '@lexical/rich-text'
80
82
  import { useFormFallbackProps } from '@veltra/compositions'
81
83
  import { bem } from '@veltra/utils'
82
84
  import { injectFormContext } from '@veltra/utils'
83
- import { createEditor, $getRoot, $createParagraphNode, type LexicalEditor } from 'lexical'
85
+ import {
86
+ $createRangeSelection,
87
+ $getNodeByKey,
88
+ $getRoot,
89
+ $createParagraphNode,
90
+ $nodesOfType,
91
+ $setSelection,
92
+ COMMAND_PRIORITY_HIGH,
93
+ DROP_COMMAND,
94
+ PASTE_COMMAND,
95
+ createEditor,
96
+ type LexicalEditor
97
+ } from 'lexical'
84
98
  import { computed, onBeforeUnmount, onMounted, ref, shallowRef, useTemplateRef, watch } from 'vue'
85
99
 
86
- import type { RichTextEditorProps } from '../../types'
100
+ import type {
101
+ RichTextEditorProps,
102
+ RichTextImageUploader,
103
+ _RichTextEditorExposed
104
+ } from '../../types'
105
+ import {
106
+ ImageNode,
107
+ $isImageNode,
108
+ gcImageObjectUrls,
109
+ insertImageFiles,
110
+ revokeImageObjectUrls
111
+ } from './image-node'
87
112
  import Toolbar from './toolbar.vue'
88
113
 
89
114
  const props = withDefaults(defineProps<RichTextEditorProps>(), {
@@ -91,7 +116,8 @@ const props = withDefaults(defineProps<RichTextEditorProps>(), {
91
116
  readonly: undefined,
92
117
  format: 'html',
93
118
  toolbar: () => DEFAULT_TOOLBAR,
94
- placeholder: ''
119
+ placeholder: '',
120
+ image: true
95
121
  })
96
122
 
97
123
  const model = defineModel<string>()
@@ -115,7 +141,8 @@ const className = computed<string[]>(() => {
115
141
  })
116
142
 
117
143
  const toolbarItems = computed<ToolbarItem[]>(() => {
118
- return props.toolbar ?? DEFAULT_TOOLBAR
144
+ const items = props.toolbar ?? DEFAULT_TOOLBAR
145
+ return props.image ? items : items.filter((item) => item !== 'image')
119
146
  })
120
147
 
121
148
  const editorContainer = useTemplateRef('editorContainer')
@@ -130,7 +157,7 @@ function initEditor() {
130
157
  const editorInstance = createEditor({
131
158
  namespace: 'URichTextEditor',
132
159
  theme: LEXICAL_THEME,
133
- nodes: [HeadingNode, QuoteNode, ListNode, ListItemNode, LinkNode, AutoLinkNode],
160
+ nodes: [HeadingNode, QuoteNode, ListNode, ListItemNode, LinkNode, AutoLinkNode, ImageNode],
134
161
  editable: !isDisabled.value && !isReadonly.value,
135
162
  onError: (error: Error) => {
136
163
  console.error('Lexical error:', error)
@@ -144,6 +171,47 @@ function initEditor() {
144
171
  cleanupFns.push(registerList(editorInstance))
145
172
  cleanupFns.push(registerHistory(editorInstance, createEmptyHistoryState(), 300))
146
173
 
174
+ // Image input: paste & drop
175
+ const acceptsImage = () => props.image && !isDisabled.value && !isReadonly.value
176
+
177
+ cleanupFns.push(
178
+ editorInstance.registerCommand(
179
+ PASTE_COMMAND,
180
+ (event) => {
181
+ if (!acceptsImage()) return false
182
+ if (insertImageFiles(editorInstance, event.clipboardData?.files ?? []) > 0) {
183
+ event.preventDefault()
184
+ return true
185
+ }
186
+ return false
187
+ },
188
+ COMMAND_PRIORITY_HIGH
189
+ ),
190
+ editorInstance.registerCommand(
191
+ DROP_COMMAND,
192
+ (event) => {
193
+ if (!acceptsImage()) return false
194
+ const files = event.dataTransfer?.files
195
+ if (!files?.length) return false
196
+ // 先把光标落到落点,再插入
197
+ const range = document.caretRangeFromPoint(event.clientX, event.clientY)
198
+ if (range) {
199
+ editorInstance.update(() => {
200
+ const selection = $createRangeSelection()
201
+ selection.applyDOMRange(range)
202
+ $setSelection(selection)
203
+ })
204
+ }
205
+ if (insertImageFiles(editorInstance, files) > 0) {
206
+ event.preventDefault()
207
+ return true
208
+ }
209
+ return false
210
+ },
211
+ COMMAND_PRIORITY_HIGH
212
+ )
213
+ )
214
+
147
215
  // Set initial value
148
216
  if (model.value) {
149
217
  setEditorContent(editorInstance, model.value)
@@ -155,17 +223,16 @@ function initEditor() {
155
223
  editorState.read(
156
224
  () => {
157
225
  const root = $getRoot()
158
- const textContent = root.getTextContent()
159
- showPlaceholder.value = textContent.trim().length === 0
226
+ const imageNodes = $nodesOfType(ImageNode)
227
+ showPlaceholder.value =
228
+ root.getTextContent().trim().length === 0 && imageNodes.length === 0
229
+
230
+ // 回收已从内容中移除的图片 objectURL
231
+ gcImageObjectUrls(editorInstance, new Set(imageNodes.map((node) => node.getSrc())))
160
232
 
161
233
  if (isComposing) return
162
234
 
163
- let value: string
164
- if (props.format === 'json') {
165
- value = JSON.stringify(editorState.toJSON())
166
- } else {
167
- value = $generateHtmlFromNodes(editorInstance)
168
- }
235
+ const value = serializeContent(editorInstance)
169
236
 
170
237
  // Avoid circular updates: only emit if content really changed
171
238
  if (value !== model.value) {
@@ -225,6 +292,47 @@ function setEditorContent(editorInstance: LexicalEditor, value: string) {
225
292
  })
226
293
  }
227
294
 
295
+ function serializeContent(editorInstance: LexicalEditor): string {
296
+ return editorInstance.read(() =>
297
+ props.format === 'json'
298
+ ? JSON.stringify(editorInstance.getEditorState().toJSON())
299
+ : $generateHtmlFromNodes(editorInstance)
300
+ )
301
+ }
302
+
303
+ let isUploadingImages = false
304
+
305
+ async function uploadImages(upload: RichTextImageUploader): Promise<string> {
306
+ const editorInstance = editor.value
307
+ if (!editorInstance) return model.value ?? ''
308
+ if (isUploadingImages) throw new Error('图片正在上传中')
309
+
310
+ const pending = editorInstance.getEditorState().read(() =>
311
+ $nodesOfType(ImageNode)
312
+ .filter((node) => node.getFile())
313
+ .map((node) => ({ key: node.getKey(), file: node.getFile() as File }))
314
+ )
315
+
316
+ if (pending.length) {
317
+ isUploadingImages = true
318
+ try {
319
+ const urls = await Promise.all(pending.map(({ file }) => upload(file)))
320
+ editorInstance.update(() => {
321
+ pending.forEach(({ key }, index) => {
322
+ const node = $getNodeByKey(key)
323
+ if ($isImageNode(node)) node.setSrc(urls[index])
324
+ })
325
+ })
326
+ } finally {
327
+ isUploadingImages = false
328
+ }
329
+ }
330
+
331
+ return serializeContent(editorInstance)
332
+ }
333
+
334
+ defineExpose<_RichTextEditorExposed>({ uploadImages })
335
+
228
336
  // Watch external model changes
229
337
  watch(model, (newVal) => {
230
338
  if (!editor.value || isComposing) return
@@ -232,22 +340,15 @@ watch(model, (newVal) => {
232
340
  const editorInstance = editor.value
233
341
 
234
342
  // Compare current content to avoid circular updates
235
- editorInstance.read(() => {
236
- let currentValue: string
237
- if (props.format === 'json') {
238
- currentValue = JSON.stringify(editorInstance.getEditorState().toJSON())
239
- } else {
240
- currentValue = $generateHtmlFromNodes(editorInstance)
241
- }
343
+ const currentValue = serializeContent(editorInstance)
242
344
 
243
- if (currentValue !== newVal) {
244
- isComposing = true
245
- setEditorContent(editorInstance, newVal ?? '')
246
- queueMicrotask(() => {
247
- isComposing = false
248
- })
249
- }
250
- })
345
+ if (currentValue !== newVal) {
346
+ isComposing = true
347
+ setEditorContent(editorInstance, newVal ?? '')
348
+ queueMicrotask(() => {
349
+ isComposing = false
350
+ })
351
+ }
251
352
  })
252
353
 
253
354
  // Watch disabled/readonly changes
@@ -264,6 +365,7 @@ onMounted(() => {
264
365
  onBeforeUnmount(() => {
265
366
  cleanupFns.forEach((fn) => fn())
266
367
  cleanupFns = []
368
+ if (editor.value) revokeImageObjectUrls(editor.value)
267
369
  editor.value = null
268
370
  })
269
371
  </script>
@@ -381,3 +381,10 @@ $root-name: rich-text-editor;
381
381
  text-decoration: none;
382
382
  }
383
383
  }
384
+
385
+ // Image
386
+ .u-rte-image {
387
+ max-width: 100%;
388
+ height: auto;
389
+ vertical-align: bottom;
390
+ }
@@ -35,6 +35,8 @@
35
35
  <span :class="cls.e('toolbar-icon')" v-html="getIcon(item)"></span>
36
36
  </button>
37
37
  </template>
38
+
39
+ <input ref="imageInput" type="file" accept="image/*" multiple hidden @change="onImageChange" />
38
40
  </div>
39
41
  </template>
40
42
 
@@ -56,9 +58,10 @@ import {
56
58
  $createParagraphNode,
57
59
  type RangeSelection
58
60
  } from 'lexical'
59
- import { ref, onBeforeUnmount, watch } from 'vue'
61
+ import { ref, onBeforeUnmount, useTemplateRef, watch } from 'vue'
60
62
 
61
63
  import type { ToolbarItem } from '../../types'
64
+ import { insertImageFiles } from './image-node'
62
65
 
63
66
  const props = defineProps<{
64
67
  editor: LexicalEditor | null
@@ -68,6 +71,8 @@ const props = defineProps<{
68
71
 
69
72
  const cls = bem('rich-text-editor')
70
73
 
74
+ const imageInput = useTemplateRef('imageInput')
75
+
71
76
  // Active states
72
77
  const isBold = ref(false)
73
78
  const isItalic = ref(false)
@@ -205,6 +210,9 @@ function onToolbarAction(item: ToolbarItem) {
205
210
  case 'link':
206
211
  toggleLink(editorInstance)
207
212
  break
213
+ case 'image':
214
+ imageInput.value?.click()
215
+ break
208
216
  case 'undo':
209
217
  editorInstance.dispatchCommand(UNDO_COMMAND, undefined)
210
218
  break
@@ -217,6 +225,15 @@ function onToolbarAction(item: ToolbarItem) {
217
225
  editorInstance.focus()
218
226
  }
219
227
 
228
+ function onImageChange(event: Event) {
229
+ const input = event.target as HTMLInputElement
230
+ if (input.files?.length && props.editor && !props.disabled) {
231
+ insertImageFiles(props.editor, input.files)
232
+ }
233
+ // 允许重复选择同一文件
234
+ input.value = ''
235
+ }
236
+
220
237
  function onHeadingChange(event: Event) {
221
238
  const editorInstance = props.editor
222
239
  if (!editorInstance || props.disabled) return
@@ -271,6 +288,7 @@ function getTitle(item: ToolbarItem): string {
271
288
  blockquote: '引用',
272
289
  'code-block': '代码块',
273
290
  link: '链接',
291
+ image: '图片',
274
292
  undo: '撤销',
275
293
  redo: '重做'
276
294
  }
@@ -296,6 +314,8 @@ function getIcon(item: ToolbarItem): string {
296
314
  'code-block':
297
315
  '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="18" rx="2"/><path d="m8 10-3 2 3 2"/><path d="m16 10 3 2-3 2"/><path d="m13 7-2 10"/></svg>',
298
316
  link: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>',
317
+ image:
318
+ '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>',
299
319
  undo: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7v6h6"/><path d="M21 17a9 9 0 0 0-9-9 9 9 0 0 0-6 2.3L3 13"/></svg>',
300
320
  redo: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 7v6h-6"/><path d="M3 17a9 9 0 0 1 9-9 9 9 0 0 1 6 2.3L21 13"/></svg>'
301
321
  }
@@ -218,4 +218,82 @@ describe('USelect', () => {
218
218
  unmount()
219
219
  }
220
220
  })
221
+
222
+ it('loads options from a remote function with loading state', async () => {
223
+ let resolveRemote!: (options: Record<string, any>[]) => void
224
+ const calls: string[] = []
225
+ const { host, unmount } = mountSelect({
226
+ options: (qs: string) => {
227
+ calls.push(qs)
228
+ return new Promise((resolve) => {
229
+ resolveRemote = resolve
230
+ })
231
+ }
232
+ })
233
+
234
+ try {
235
+ // 挂载即以空串调用一次
236
+ expect(calls).toEqual([''])
237
+
238
+ await openDropdown(host)
239
+ expect(document.body.querySelector('.u-select__loading')).toBeTruthy()
240
+ expect(queryOptions()).toHaveLength(0)
241
+
242
+ resolveRemote([{ label: '远程A', value: 'a' }])
243
+ await sleep(50)
244
+ expect(document.body.querySelector('.u-select__loading')).toBeFalsy()
245
+ expect(queryOptions().map((el) => el.textContent)).toEqual(['远程A'])
246
+ } finally {
247
+ unmount()
248
+ }
249
+ })
250
+
251
+ it('queries the remote function on input', async () => {
252
+ const calls: string[] = []
253
+ const { host, unmount } = mountSelect({
254
+ options: (qs: string) => {
255
+ calls.push(qs)
256
+ return Promise.resolve(
257
+ qs ? [{ label: '上海', value: 'shanghai' }] : [{ label: '北京', value: 'beijing' }]
258
+ )
259
+ }
260
+ })
261
+
262
+ try {
263
+ await openDropdown(host)
264
+ await typeQuery(host, '上海')
265
+ expect(calls).toEqual(['', '上海'])
266
+ expect(queryOptions().map((el) => el.textContent)).toEqual(['上海'])
267
+ } finally {
268
+ unmount()
269
+ }
270
+ })
271
+
272
+ it('discards stale remote responses when a newer query resolves first', async () => {
273
+ const pending: ((options: Record<string, any>[]) => void)[] = []
274
+ const { host, unmount } = mountSelect({
275
+ options: (qs: string) =>
276
+ new Promise((resolve) => {
277
+ pending.push(resolve)
278
+ })
279
+ })
280
+
281
+ try {
282
+ await openDropdown(host)
283
+ await typeQuery(host, '北')
284
+ expect(pending).toHaveLength(2)
285
+
286
+ // 新查询先返回
287
+ pending[1]!([{ label: '北京', value: 'beijing' }])
288
+ await sleep(20)
289
+ expect(queryOptions().map((el) => el.textContent)).toEqual(['北京'])
290
+
291
+ // 慢的旧响应(初始空串查询)后返回,不能覆盖新结果
292
+ pending[0]!([{ label: '旧数据', value: 'old' }])
293
+ await sleep(20)
294
+ expect(queryOptions().map((el) => el.textContent)).toEqual(['北京'])
295
+ } finally {
296
+ unmount()
297
+ }
298
+ })
221
299
  })
@@ -58,8 +58,11 @@
58
58
  单选列表。虚拟化启用时,内容容器 height 由 useVirtualizer 命令式写入;
59
59
  此处 content-style 仅承担 grid 相关样式,避免 height 变化触发模板重渲染。
60
60
  -->
61
+ <div v-if="loading" :class="cls.e('loading')">
62
+ <ULoading type="dual-ring" />
63
+ </div>
61
64
  <u-scroll
62
- v-if="options.length"
65
+ v-else-if="options.length"
63
66
  tag="ul"
64
67
  :class="cls.e('options')"
65
68
  ref="scrollRef"
@@ -137,6 +140,7 @@ import { UDropdown } from '../dropdown'
137
140
  import { UEmpty } from '../empty'
138
141
  import { UIcon } from '../icon'
139
142
  import { UInput } from '../input'
143
+ import { ULoading } from '../loading'
140
144
  import { UScroll } from '../scroll'
141
145
  import { useKeyboard } from './use-keyboard'
142
146
  import { useOptions } from './use-options'
@@ -194,8 +198,14 @@ const filterable = computed(() => {
194
198
  return props.filterable || typeof props.options === 'function'
195
199
  })
196
200
 
197
- const { queryString, options, allOptions, temOptionsToCreatedOptions, clearCreatedOptions } =
198
- useOptions({ props })
201
+ const {
202
+ queryString,
203
+ loading,
204
+ options,
205
+ allOptions,
206
+ temOptionsToCreatedOptions,
207
+ clearCreatedOptions
208
+ } = useOptions({ props })
199
209
 
200
210
  const dropdownVisible = shallowRef(false)
201
211
 
@@ -116,4 +116,10 @@ $root-name: select;
116
116
  text-align: center;
117
117
  padding: fn.use-var(gap, small) 0;
118
118
  }
119
+
120
+ @include m.e(loading) {
121
+ // 与选项内容区同高,加载期间保持面板形态,避免塌缩成贴着输入框的小条
122
+ @include m.flex($justify: center, $align: center);
123
+ min-height: 240px;
124
+ }
119
125
  }