@veltra/desktop 1.8.0 → 1.8.2
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/condition-editor/condition-editor.js +8 -2
- package/dist/components/condition-editor/condition-editor.js.map +1 -1
- package/dist/components/expression-editor/core/editor.js +22 -0
- package/dist/components/expression-editor/core/editor.js.map +1 -1
- package/dist/components/expression-editor/expression-editor.js +17 -6
- package/dist/components/expression-editor/expression-editor.js.map +1 -1
- package/dist/components/expression-editor/style2.css +2 -2
- package/dist/components/form-item/style2.css +14 -0
- package/dist/components/index.d.ts +2 -1
- package/dist/components/index.js +3 -1
- 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/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/index.d.ts +48 -46
- package/dist/index.js +46 -45
- package/dist/types/index.d.ts +3 -2
- package/dist/types/kanban.d.ts +69 -0
- package/dist/types/rich-text-editor.d.ts +13 -2
- package/package.json +1 -1
- 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/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/core/editor.ts +33 -0
- package/src/components/expression-editor/expression-editor.vue +13 -5
- package/src/components/expression-editor/style.scss +8 -9
- 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/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/types/index.ts +1 -0
- package/src/types/kanban.ts +63 -0
- package/src/types/rich-text-editor.ts +14 -1
|
@@ -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
|
}
|
package/src/types/index.ts
CHANGED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { DeconstructValue } from '@veltra/utils'
|
|
2
|
+
|
|
3
|
+
/** 看板列数据 */
|
|
4
|
+
export interface KanbanColumnItem extends Record<string, any> {
|
|
5
|
+
/** 列标识(必填) */
|
|
6
|
+
key: string
|
|
7
|
+
/** 列标题 */
|
|
8
|
+
title?: string
|
|
9
|
+
/** 卡片数据(顺序即展示顺序,拖拽后更新) */
|
|
10
|
+
items: Record<string, any>[]
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** 看板组件属性 */
|
|
14
|
+
export interface KanbanProps {
|
|
15
|
+
/** 列数据(含每列卡片),拖拽结果经 update:columns 写回 */
|
|
16
|
+
columns?: KanbanColumnItem[]
|
|
17
|
+
/**
|
|
18
|
+
* 卡片唯一标识字段名
|
|
19
|
+
* @default 'id'
|
|
20
|
+
*/
|
|
21
|
+
cardKey?: string
|
|
22
|
+
/**
|
|
23
|
+
* 列标题与默认卡片内容的字段名
|
|
24
|
+
* @default 'title'
|
|
25
|
+
*/
|
|
26
|
+
titleKey?: string
|
|
27
|
+
/** 是否禁用拖拽 */
|
|
28
|
+
disabled?: boolean
|
|
29
|
+
/**
|
|
30
|
+
* 列头是否显示卡片计数徽标
|
|
31
|
+
* @default true
|
|
32
|
+
*/
|
|
33
|
+
countable?: boolean
|
|
34
|
+
/**
|
|
35
|
+
* 空列占位文案(空列仍可作为拖放目标)
|
|
36
|
+
* @default '暂无内容'
|
|
37
|
+
*/
|
|
38
|
+
placeholder?: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** 看板组件事件 */
|
|
42
|
+
export interface KanbanEmits {
|
|
43
|
+
/** 列数据更新(v-model:columns) */
|
|
44
|
+
(e: 'update:columns', columns: KanbanColumnItem[]): void
|
|
45
|
+
/** 拖拽结束后触发(含列内排序与跨列转移),返回最新列数据 */
|
|
46
|
+
(e: 'change', columns: KanbanColumnItem[]): void
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** 看板插槽 */
|
|
50
|
+
export type KanbanSlots = {
|
|
51
|
+
/** 自定义卡片内容 */
|
|
52
|
+
card?: (props: { card: Record<string, any>; column: KanbanColumnItem; index: number }) => any
|
|
53
|
+
/** 自定义列头 */
|
|
54
|
+
header?: (props: { column: KanbanColumnItem; count: number }) => any
|
|
55
|
+
/** 空列占位 */
|
|
56
|
+
empty?: (props: { column: KanbanColumnItem }) => any
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** 看板组件暴露的属性和方法(组件内部使用) */
|
|
60
|
+
export interface _KanbanExposed {}
|
|
61
|
+
|
|
62
|
+
/** 看板组件暴露的属性和方法(组件外部使用, 引用的值会被自动解构) */
|
|
63
|
+
export type KanbanExposed = DeconstructValue<_KanbanExposed>
|
|
@@ -3,6 +3,9 @@ import type { DeconstructValue, FormComponentProps } from '@veltra/utils'
|
|
|
3
3
|
/** 富文本数据格式 */
|
|
4
4
|
export type RichTextFormat = 'html' | 'json'
|
|
5
5
|
|
|
6
|
+
/** 图片上传函数:接收文件,返回上传后的服务器地址 */
|
|
7
|
+
export type RichTextImageUploader = (file: File) => Promise<string>
|
|
8
|
+
|
|
6
9
|
/** 工具栏项 */
|
|
7
10
|
export type ToolbarItem =
|
|
8
11
|
| 'bold'
|
|
@@ -16,6 +19,7 @@ export type ToolbarItem =
|
|
|
16
19
|
| 'blockquote'
|
|
17
20
|
| 'code-block'
|
|
18
21
|
| 'link'
|
|
22
|
+
| 'image'
|
|
19
23
|
| 'undo'
|
|
20
24
|
| 'redo'
|
|
21
25
|
| '|'
|
|
@@ -29,6 +33,8 @@ export interface RichTextEditorProps extends FormComponentProps {
|
|
|
29
33
|
toolbar?: ToolbarItem[]
|
|
30
34
|
/** 占位文本 */
|
|
31
35
|
placeholder?: string
|
|
36
|
+
/** 是否允许图片输入(粘贴 / 拖拽 / 工具栏),默认 true */
|
|
37
|
+
image?: boolean
|
|
32
38
|
}
|
|
33
39
|
|
|
34
40
|
/** 富文本编辑器组件定义的事件 */
|
|
@@ -37,7 +43,14 @@ export interface RichTextEditorEmits {
|
|
|
37
43
|
}
|
|
38
44
|
|
|
39
45
|
/** 富文本编辑器组件暴露的属性和方法(组件内部使用) */
|
|
40
|
-
export interface _RichTextEditorExposed {
|
|
46
|
+
export interface _RichTextEditorExposed {
|
|
47
|
+
/**
|
|
48
|
+
* 上传编辑器内所有待上传图片:对每张图片调用 upload 获取服务器地址并替换节点,
|
|
49
|
+
* 返回替换后的最终内容(格式遵循 format 属性)。
|
|
50
|
+
* 任一上传失败则整体拒绝,图片保持待上传状态可整体重试;已上传的不会重复上传
|
|
51
|
+
*/
|
|
52
|
+
uploadImages: (upload: RichTextImageUploader) => Promise<string>
|
|
53
|
+
}
|
|
41
54
|
|
|
42
55
|
/** 富文本编辑器组件暴露的属性和方法(组件外部使用, 引用的值会被自动解构) */
|
|
43
56
|
export type RichTextEditorExposed = DeconstructValue<_RichTextEditorExposed>
|