@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.
- package/dist/components/batch-edit/batch-edit-form.js +40 -26
- package/dist/components/batch-edit/batch-edit-form.js.map +1 -1
- package/dist/components/batch-edit/use-handlers.js +3 -3
- package/dist/components/batch-edit/use-handlers.js.map +1 -1
- package/dist/components/batch-edit/use-shortcut-key.js +4 -2
- package/dist/components/batch-edit/use-shortcut-key.js.map +1 -1
- package/dist/components/card/style2.css +0 -2
- package/dist/components/condition-editor/condition-editor.js +8 -2
- package/dist/components/condition-editor/condition-editor.js.map +1 -1
- package/dist/components/dropdown/dropdown.js +5 -3
- package/dist/components/dropdown/dropdown.js.map +1 -1
- package/dist/components/expression-editor/components/path-preview.js +3 -6
- package/dist/components/expression-editor/components/path-preview.js.map +1 -1
- package/dist/components/expression-editor/components/variable-picker.js +49 -12
- package/dist/components/expression-editor/components/variable-picker.js.map +1 -1
- package/dist/components/expression-editor/core/editor.js +42 -0
- package/dist/components/expression-editor/core/editor.js.map +1 -1
- package/dist/components/expression-editor/expression-editor.js +68 -22
- package/dist/components/expression-editor/expression-editor.js.map +1 -1
- package/dist/components/expression-editor/style2.css +135 -73
- package/dist/components/form/form.js.map +1 -1
- package/dist/components/form/form.vue_vue_type_script_setup_true_lang.js +0 -1
- package/dist/components/form/form.vue_vue_type_script_setup_true_lang.js.map +1 -1
- package/dist/components/form/style2.css +8 -3
- package/dist/components/form-item/style2.css +14 -0
- package/dist/components/index.d.ts +27 -26
- package/dist/components/index.js +54 -52
- package/dist/components/index.js.map +1 -1
- package/dist/components/kanban/di.js +7 -0
- package/dist/components/kanban/di.js.map +1 -0
- package/dist/components/kanban/kanban-column.js +67 -0
- package/dist/components/kanban/kanban-column.js.map +1 -0
- package/dist/components/kanban/kanban.js +89 -0
- package/dist/components/kanban/kanban.js.map +1 -0
- package/dist/components/kanban/style.d.ts +1 -0
- package/dist/components/kanban/style.js +1 -0
- package/dist/components/kanban/style2.css +128 -0
- package/dist/components/multi-select/multi-select.js +16 -12
- package/dist/components/multi-select/multi-select.js.map +1 -1
- package/dist/components/multi-select/style2.css +7 -0
- package/dist/components/multi-tree-select/multi-tree-select.js +82 -56
- package/dist/components/multi-tree-select/multi-tree-select.js.map +1 -1
- package/dist/components/multi-tree-select/style2.css +7 -0
- package/dist/components/rich-text-editor/image-node.js +154 -0
- package/dist/components/rich-text-editor/image-node.js.map +1 -0
- package/dist/components/rich-text-editor/rich-text-editor.js +80 -22
- package/dist/components/rich-text-editor/rich-text-editor.js.map +1 -1
- package/dist/components/rich-text-editor/style2.css +6 -0
- package/dist/components/rich-text-editor/toolbar.js +22 -2
- package/dist/components/rich-text-editor/toolbar.js.map +1 -1
- package/dist/components/select/select.js +7 -3
- package/dist/components/select/select.js.map +1 -1
- package/dist/components/select/style2.css +7 -0
- package/dist/components/select/use-options.js +14 -3
- package/dist/components/select/use-options.js.map +1 -1
- package/dist/components/tree-select/style2.css +7 -0
- package/dist/components/tree-select/tree-select.js +49 -17
- package/dist/components/tree-select/tree-select.js.map +1 -1
- package/dist/index.d.ts +48 -46
- package/dist/index.js +47 -46
- package/dist/types/dropdown.d.ts +10 -0
- package/dist/types/index.d.ts +3 -2
- package/dist/types/kanban.d.ts +69 -0
- package/dist/types/multi-tree-select.d.ts +7 -1
- package/dist/types/rich-text-editor.d.ts +13 -2
- package/dist/types/tree-select.d.ts +7 -1
- package/package.json +1 -1
- package/src/components/batch-edit/__test__/batch-edit.test.ts +112 -1
- package/src/components/batch-edit/batch-edit-form.vue +21 -2
- package/src/components/batch-edit/use-handlers.ts +6 -5
- package/src/components/batch-edit/use-shortcut-key.ts +5 -2
- package/src/components/card/style.scss +0 -2
- package/src/components/condition-editor/__test__/condition-editor.test.ts +61 -0
- package/src/components/condition-editor/condition-editor.vue +6 -1
- package/src/components/dropdown/dropdown.vue +9 -4
- package/src/components/expression-editor/__test__/editor.test.ts +66 -0
- package/src/components/expression-editor/__test__/expression-editor.test.ts +124 -0
- package/src/components/expression-editor/components/path-preview.vue +1 -2
- package/src/components/expression-editor/components/variable-picker.vue +54 -13
- package/src/components/expression-editor/core/editor.ts +61 -0
- package/src/components/expression-editor/expression-editor.vue +58 -20
- package/src/components/expression-editor/style.scss +160 -76
- package/src/components/form/form.vue +0 -1
- package/src/components/form/style.scss +11 -4
- package/src/components/form-item/style.scss +16 -1
- package/src/components/index.ts +2 -0
- package/src/components/kanban/__test__/kanban.test.ts +212 -0
- package/src/components/kanban/di.ts +19 -0
- package/src/components/kanban/index.ts +1 -0
- package/src/components/kanban/kanban-column.vue +77 -0
- package/src/components/kanban/kanban.vue +81 -0
- package/src/components/kanban/style.scss +124 -0
- package/src/components/kanban/style.ts +1 -0
- package/src/components/multi-select/multi-select.vue +11 -2
- package/src/components/multi-select/style.scss +6 -0
- package/src/components/multi-tree-select/multi-tree-select.vue +58 -9
- package/src/components/multi-tree-select/style.scss +6 -0
- package/src/components/rich-text-editor/image-node.ts +193 -0
- package/src/components/rich-text-editor/rich-text-editor.vue +131 -29
- package/src/components/rich-text-editor/style.scss +7 -0
- package/src/components/rich-text-editor/toolbar.vue +21 -1
- package/src/components/select/__test__/select.test.ts +78 -0
- package/src/components/select/select.vue +13 -3
- package/src/components/select/style.scss +6 -0
- package/src/components/select/use-options.ts +25 -4
- package/src/components/tree-select/__test__/tree-select.test.ts +116 -0
- package/src/components/tree-select/style.scss +6 -0
- package/src/components/tree-select/tree-select.vue +63 -13
- package/src/types/dropdown.ts +10 -0
- package/src/types/index.ts +1 -0
- package/src/types/kanban.ts +63 -0
- package/src/types/multi-tree-select.ts +12 -1
- package/src/types/rich-text-editor.ts +14 -1
- 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 {
|
|
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 {
|
|
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
|
-
|
|
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
|
|
159
|
-
showPlaceholder.value =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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>
|
|
@@ -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 {
|
|
198
|
-
|
|
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
|
}
|