@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
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { createApp, defineComponent, h, nextTick, ref } from 'vue'
|
|
3
|
+
|
|
4
|
+
import { UForm } from '../../form'
|
|
5
|
+
import UConditionEditor from '../condition-editor.vue'
|
|
6
|
+
|
|
7
|
+
describe('UConditionEditor 在 UForm 中的状态下发', () => {
|
|
8
|
+
async function mountInForm() {
|
|
9
|
+
const host = document.createElement('div')
|
|
10
|
+
document.body.appendChild(host)
|
|
11
|
+
|
|
12
|
+
const formReadonly = ref(false)
|
|
13
|
+
const formDisabled = ref(false)
|
|
14
|
+
const model = ref({ condition: undefined as unknown })
|
|
15
|
+
|
|
16
|
+
const App = defineComponent({
|
|
17
|
+
setup() {
|
|
18
|
+
return () =>
|
|
19
|
+
h(
|
|
20
|
+
UForm,
|
|
21
|
+
{ model: model.value, readonly: formReadonly.value, disabled: formDisabled.value },
|
|
22
|
+
{ default: () => h(UConditionEditor, { label: '条件', field: 'condition' }) }
|
|
23
|
+
)
|
|
24
|
+
}
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
const app = createApp(App)
|
|
28
|
+
app.mount(host)
|
|
29
|
+
await nextTick()
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
host,
|
|
33
|
+
formReadonly,
|
|
34
|
+
formDisabled,
|
|
35
|
+
root: () => host.querySelector('.u-condition-editor'),
|
|
36
|
+
async settle() {
|
|
37
|
+
await nextTick()
|
|
38
|
+
await nextTick()
|
|
39
|
+
},
|
|
40
|
+
unmount() {
|
|
41
|
+
app.unmount()
|
|
42
|
+
host.remove()
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
it('UForm 的 readonly / disabled 下发到根节点', async () => {
|
|
48
|
+
const ctx = await mountInForm()
|
|
49
|
+
expect(ctx.root()?.className).not.toContain('is-readonly')
|
|
50
|
+
|
|
51
|
+
ctx.formReadonly.value = true
|
|
52
|
+
await ctx.settle()
|
|
53
|
+
expect(ctx.root()?.className).toContain('is-readonly')
|
|
54
|
+
|
|
55
|
+
ctx.formDisabled.value = true
|
|
56
|
+
await ctx.settle()
|
|
57
|
+
expect(ctx.root()?.className).toContain('is-disabled')
|
|
58
|
+
|
|
59
|
+
ctx.unmount()
|
|
60
|
+
})
|
|
61
|
+
})
|
|
@@ -37,7 +37,12 @@ import { createEmptyGroup } from './core/evaluator'
|
|
|
37
37
|
|
|
38
38
|
defineOptions({ name: 'UConditionEditor' })
|
|
39
39
|
|
|
40
|
-
const props = withDefaults(defineProps<ConditionEditorProps>(), {
|
|
40
|
+
const props = withDefaults(defineProps<ConditionEditorProps>(), {
|
|
41
|
+
fields: () => [],
|
|
42
|
+
// Boolean prop 缺省会被 Vue 归一成 false,挡住 UForm 下发的 disabled / readonly,须显式留 undefined
|
|
43
|
+
disabled: undefined,
|
|
44
|
+
readonly: undefined
|
|
45
|
+
})
|
|
41
46
|
|
|
42
47
|
const emit = defineEmits<{ (e: 'update:modelValue', value: ConditionExpression): void }>()
|
|
43
48
|
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
import { useModel, usePop, useTransition } from '@veltra/compositions'
|
|
23
23
|
import { vClickOutside } from '@veltra/directives'
|
|
24
24
|
import { bem, extractNormalVNodes, setStyles, zIndex } from '@veltra/utils'
|
|
25
|
-
import { shallowRef, computed, createVNode, cloneVNode, useTemplateRef, useAttrs } from 'vue'
|
|
25
|
+
import { shallowRef, computed, createVNode, cloneVNode, useTemplateRef, useAttrs, toRef } from 'vue'
|
|
26
26
|
|
|
27
27
|
import type { DropdownProps, DropdownExposed, DropdownEmits } from '../../types'
|
|
28
28
|
import { UNodeRender } from '../node-render'
|
|
@@ -30,7 +30,12 @@ import { useNest } from '../tip/use-nest'
|
|
|
30
30
|
|
|
31
31
|
defineOptions({ name: 'UDropdown', inheritAttrs: false })
|
|
32
32
|
|
|
33
|
-
const props = withDefaults(defineProps<DropdownProps>(), {
|
|
33
|
+
const props = withDefaults(defineProps<DropdownProps>(), {
|
|
34
|
+
trigger: 'hover',
|
|
35
|
+
direction: 'bottom',
|
|
36
|
+
alignment: 'start',
|
|
37
|
+
contentTag: 'div'
|
|
38
|
+
})
|
|
34
39
|
|
|
35
40
|
const emit = defineEmits<DropdownEmits>()
|
|
36
41
|
|
|
@@ -121,8 +126,8 @@ const transitionName = shallowRef('slide-down')
|
|
|
121
126
|
const { update, popperContainerId } = usePop({
|
|
122
127
|
triggerRef: triggerDom,
|
|
123
128
|
contentRef,
|
|
124
|
-
direction:
|
|
125
|
-
alignment:
|
|
129
|
+
direction: toRef(() => props.direction),
|
|
130
|
+
alignment: toRef(() => props.alignment),
|
|
126
131
|
onPop(position) {
|
|
127
132
|
transitionName.value = position.placement.includes('top') ? 'slide-up' : 'slide-down'
|
|
128
133
|
|
|
@@ -45,3 +45,69 @@ describe('createEditor composition', () => {
|
|
|
45
45
|
editor.dispose()
|
|
46
46
|
})
|
|
47
47
|
})
|
|
48
|
+
|
|
49
|
+
describe('createEditor visual empty', () => {
|
|
50
|
+
function setup(initialValue: string) {
|
|
51
|
+
const container = document.createElement('div')
|
|
52
|
+
document.body.appendChild(container)
|
|
53
|
+
|
|
54
|
+
const onVisualEmptyChange = vi.fn()
|
|
55
|
+
const editor = createEditor({
|
|
56
|
+
container,
|
|
57
|
+
cls,
|
|
58
|
+
initialDoc: parse(initialValue),
|
|
59
|
+
onVisualEmptyChange
|
|
60
|
+
})
|
|
61
|
+
return { container, editor, onVisualEmptyChange }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function textSpan(container: HTMLElement): HTMLElement {
|
|
65
|
+
return container.querySelector('[data-seg="text"]')!
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
it('reports empty on first render, non-empty once a chip is present', () => {
|
|
69
|
+
const empty = setup('')
|
|
70
|
+
expect(empty.onVisualEmptyChange).toHaveBeenLastCalledWith(true)
|
|
71
|
+
empty.editor.dispose()
|
|
72
|
+
|
|
73
|
+
const withChip = setup('{user}')
|
|
74
|
+
expect(withChip.onVisualEmptyChange).toHaveBeenLastCalledWith(false)
|
|
75
|
+
withChip.editor.dispose()
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('drops the empty state as soon as IME composition text is in the DOM', () => {
|
|
79
|
+
const { container, editor, onVisualEmptyChange } = setup('')
|
|
80
|
+
|
|
81
|
+
container.dispatchEvent(new Event('compositionstart'))
|
|
82
|
+
textSpan(container).textContent = '阿'
|
|
83
|
+
container.dispatchEvent(new Event('input'))
|
|
84
|
+
|
|
85
|
+
expect(onVisualEmptyChange).toHaveBeenLastCalledWith(false)
|
|
86
|
+
|
|
87
|
+
editor.dispose()
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('restores the empty state when composition text is deleted mid-composition', () => {
|
|
91
|
+
const { container, editor, onVisualEmptyChange } = setup('')
|
|
92
|
+
|
|
93
|
+
container.dispatchEvent(new Event('compositionstart'))
|
|
94
|
+
textSpan(container).textContent = '阿'
|
|
95
|
+
container.dispatchEvent(new Event('input'))
|
|
96
|
+
textSpan(container).textContent = ''
|
|
97
|
+
container.dispatchEvent(new Event('input'))
|
|
98
|
+
|
|
99
|
+
expect(onVisualEmptyChange).toHaveBeenLastCalledWith(true)
|
|
100
|
+
|
|
101
|
+
editor.dispose()
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('keeps the empty state while composition has no visible text yet', () => {
|
|
105
|
+
const { container, editor, onVisualEmptyChange } = setup('')
|
|
106
|
+
|
|
107
|
+
container.dispatchEvent(new Event('compositionstart'))
|
|
108
|
+
|
|
109
|
+
expect(onVisualEmptyChange).toHaveBeenCalledTimes(1)
|
|
110
|
+
|
|
111
|
+
editor.dispose()
|
|
112
|
+
})
|
|
113
|
+
})
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { createApp, defineComponent, h, nextTick, ref } from 'vue'
|
|
3
|
+
|
|
4
|
+
import { UForm } from '../../form'
|
|
5
|
+
import UExpressionEditor from '../expression-editor.vue'
|
|
6
|
+
|
|
7
|
+
async function mountEditor(props: Record<string, unknown> = {}) {
|
|
8
|
+
const host = document.createElement('div')
|
|
9
|
+
document.body.appendChild(host)
|
|
10
|
+
|
|
11
|
+
const app = createApp({ render: () => h(UExpressionEditor, props) })
|
|
12
|
+
app.mount(host)
|
|
13
|
+
// 首次渲染后 editor 才上报框内空态,等一轮刷新再断言
|
|
14
|
+
await nextTick()
|
|
15
|
+
|
|
16
|
+
return {
|
|
17
|
+
host,
|
|
18
|
+
hint: () => host.querySelector('.u-expression-editor__hint'),
|
|
19
|
+
placeholder: () => host.querySelector('.u-expression-editor__placeholder'),
|
|
20
|
+
unmount() {
|
|
21
|
+
app.unmount()
|
|
22
|
+
host.remove()
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe('UExpressionEditor 提示行与占位文字', () => {
|
|
28
|
+
it('提示行常驻:有内容时依然显示', async () => {
|
|
29
|
+
const empty = await mountEditor()
|
|
30
|
+
expect(empty.hint()).not.toBeNull()
|
|
31
|
+
empty.unmount()
|
|
32
|
+
|
|
33
|
+
const filled = await mountEditor({ modelValue: '你好{user}' })
|
|
34
|
+
expect(filled.hint()).not.toBeNull()
|
|
35
|
+
filled.unmount()
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('禁用 / 只读时不显示提示行', async () => {
|
|
39
|
+
const disabled = await mountEditor({ disabled: true })
|
|
40
|
+
expect(disabled.hint()).toBeNull()
|
|
41
|
+
disabled.unmount()
|
|
42
|
+
|
|
43
|
+
const readonly = await mountEditor({ readonly: true })
|
|
44
|
+
expect(readonly.hint()).toBeNull()
|
|
45
|
+
readonly.unmount()
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('占位文字仍随内容显隐', async () => {
|
|
49
|
+
const empty = await mountEditor()
|
|
50
|
+
expect(empty.placeholder()?.textContent?.trim()).toBe('请输入表达式,输入 @ 可插入变量')
|
|
51
|
+
empty.unmount()
|
|
52
|
+
|
|
53
|
+
const filled = await mountEditor({ modelValue: '你好' })
|
|
54
|
+
expect(filled.placeholder()).toBeNull()
|
|
55
|
+
filled.unmount()
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
describe('UExpressionEditor 在 UForm 中的状态下发', () => {
|
|
60
|
+
/** 挂一个 UForm + 内嵌表达式编辑器,返回可切换表单级 readonly / disabled 的句柄 */
|
|
61
|
+
async function mountInForm() {
|
|
62
|
+
const host = document.createElement('div')
|
|
63
|
+
document.body.appendChild(host)
|
|
64
|
+
|
|
65
|
+
const formReadonly = ref(false)
|
|
66
|
+
const formDisabled = ref(false)
|
|
67
|
+
const model = ref({ expression: '' })
|
|
68
|
+
|
|
69
|
+
const App = defineComponent({
|
|
70
|
+
setup() {
|
|
71
|
+
return () =>
|
|
72
|
+
h(
|
|
73
|
+
UForm,
|
|
74
|
+
{ model: model.value, readonly: formReadonly.value, disabled: formDisabled.value },
|
|
75
|
+
{ default: () => h(UExpressionEditor, { label: '表达式', field: 'expression' }) }
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
const app = createApp(App)
|
|
81
|
+
app.mount(host)
|
|
82
|
+
await nextTick()
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
host,
|
|
86
|
+
formReadonly,
|
|
87
|
+
formDisabled,
|
|
88
|
+
shell: () => host.querySelector('.u-expression-editor__shell'),
|
|
89
|
+
container: () => host.querySelector('.u-expression-editor__container'),
|
|
90
|
+
async settle() {
|
|
91
|
+
await nextTick()
|
|
92
|
+
await nextTick()
|
|
93
|
+
},
|
|
94
|
+
unmount() {
|
|
95
|
+
app.unmount()
|
|
96
|
+
host.remove()
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
it('UForm readonly 下发到视觉盒,并禁止编辑', async () => {
|
|
102
|
+
const ctx = await mountInForm()
|
|
103
|
+
expect(ctx.shell()?.className).not.toContain('is-readonly')
|
|
104
|
+
|
|
105
|
+
ctx.formReadonly.value = true
|
|
106
|
+
await ctx.settle()
|
|
107
|
+
|
|
108
|
+
expect(ctx.shell()?.className).toContain('is-readonly')
|
|
109
|
+
expect(ctx.container()?.getAttribute('contenteditable')).toBe('false')
|
|
110
|
+
|
|
111
|
+
ctx.unmount()
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('UForm disabled 下发到视觉盒', async () => {
|
|
115
|
+
const ctx = await mountInForm()
|
|
116
|
+
|
|
117
|
+
ctx.formDisabled.value = true
|
|
118
|
+
await ctx.settle()
|
|
119
|
+
|
|
120
|
+
expect(ctx.shell()?.className).toContain('is-disabled')
|
|
121
|
+
|
|
122
|
+
ctx.unmount()
|
|
123
|
+
})
|
|
124
|
+
})
|
|
@@ -6,9 +6,8 @@
|
|
|
6
6
|
v-for="(node, i) in path"
|
|
7
7
|
:key="`${i}-${node.value || node.label}`"
|
|
8
8
|
:class="[cls.e('path-tree-item'), bem.is('active', i === path.length - 1)]"
|
|
9
|
-
:style="{
|
|
9
|
+
:style="{ '--depth': i }"
|
|
10
10
|
>
|
|
11
|
-
<span v-if="i > 0" :class="cls.e('path-tree-branch')">└</span>
|
|
12
11
|
<span :class="cls.e('path-tree-label')">{{ node.label }}</span>
|
|
13
12
|
</li>
|
|
14
13
|
</ul>
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
@update:visible="handleVisibleChange($event)"
|
|
10
10
|
>
|
|
11
11
|
<template #content>
|
|
12
|
-
<div :class="cls.e('picker')" @mousedown.prevent>
|
|
12
|
+
<div ref="root" :class="cls.e('picker')" @mousedown.prevent>
|
|
13
13
|
<!-- 面包屑:仅逐级模式且非根级显示 -->
|
|
14
14
|
<div v-if="!isFlat && navPath.length > 0" :class="cls.e('breadcrumbs')">
|
|
15
15
|
<span :class="cls.e('breadcrumb-item')">
|
|
@@ -39,7 +39,12 @@
|
|
|
39
39
|
|
|
40
40
|
<div :class="cls.e('picker-body')">
|
|
41
41
|
<div :class="cls.e('panel')" v-if="currentList.length > 0">
|
|
42
|
-
<u-scroll
|
|
42
|
+
<u-scroll
|
|
43
|
+
ref="listScroll"
|
|
44
|
+
tag="ul"
|
|
45
|
+
:class="cls.e('panel-list')"
|
|
46
|
+
:content-class="cls.e('panel-content')"
|
|
47
|
+
>
|
|
43
48
|
<li
|
|
44
49
|
v-for="(item, idx) in currentList"
|
|
45
50
|
:key="`${idx}-${item.value}`"
|
|
@@ -49,7 +54,12 @@
|
|
|
49
54
|
@mouseenter="activeIndex = idx"
|
|
50
55
|
>
|
|
51
56
|
<span :class="cls.e('item-label')">{{ item.label }}</span>
|
|
52
|
-
<u-icon
|
|
57
|
+
<u-icon
|
|
58
|
+
v-if="!isFlat && hasChildren(item)"
|
|
59
|
+
:class="cls.e('item-expand')"
|
|
60
|
+
title="展开下一级"
|
|
61
|
+
@click.stop="expandInto(item)"
|
|
62
|
+
>
|
|
53
63
|
<ArrowRight />
|
|
54
64
|
</u-icon>
|
|
55
65
|
</li>
|
|
@@ -61,6 +71,21 @@
|
|
|
61
71
|
|
|
62
72
|
<PathPreview v-if="isFlat && activePath.length > 0" :path="activePath" />
|
|
63
73
|
</div>
|
|
74
|
+
|
|
75
|
+
<div v-if="currentList.length > 0" :class="cls.e('picker-hint')">
|
|
76
|
+
<template v-if="isFlat">
|
|
77
|
+
<span><kbd>↑</kbd><kbd>↓</kbd> 选择</span>
|
|
78
|
+
<span><kbd>Enter</kbd> 确认</span>
|
|
79
|
+
<span><kbd>Esc</kbd> 关闭</span>
|
|
80
|
+
</template>
|
|
81
|
+
<template v-else>
|
|
82
|
+
<span><kbd>↑</kbd><kbd>↓</kbd> 选择</span>
|
|
83
|
+
<span><kbd>→</kbd> 展开</span>
|
|
84
|
+
<span><kbd>←</kbd> 返回</span>
|
|
85
|
+
<span><kbd>Enter</kbd> 确认</span>
|
|
86
|
+
<span><kbd>Esc</kbd> 关闭</span>
|
|
87
|
+
</template>
|
|
88
|
+
</div>
|
|
64
89
|
</div>
|
|
65
90
|
</template>
|
|
66
91
|
</u-tip>
|
|
@@ -69,7 +94,7 @@
|
|
|
69
94
|
<script lang="ts" setup>
|
|
70
95
|
import { ArrowRight } from '@veltra/icons/normal'
|
|
71
96
|
import { bem } from '@veltra/utils'
|
|
72
|
-
import { computed, nextTick, ref, watch } from 'vue'
|
|
97
|
+
import { computed, nextTick, ref, useTemplateRef, watch } from 'vue'
|
|
73
98
|
|
|
74
99
|
import type { VariableItem } from '../../../types'
|
|
75
100
|
import { UEmpty } from '../../empty'
|
|
@@ -107,6 +132,8 @@ const cls = bem('expression-editor')
|
|
|
107
132
|
|
|
108
133
|
const navPath = ref<VariableItem[]>([])
|
|
109
134
|
const activeIndex = ref(0)
|
|
135
|
+
const root = useTemplateRef('root')
|
|
136
|
+
const listScroll = useTemplateRef<InstanceType<typeof UScroll>>('listScroll')
|
|
110
137
|
|
|
111
138
|
const isFlat = computed(() => props.filter !== '')
|
|
112
139
|
|
|
@@ -192,13 +219,19 @@ function selectActive() {
|
|
|
192
219
|
return
|
|
193
220
|
}
|
|
194
221
|
// 默认 'leaf':进入下一级
|
|
195
|
-
|
|
196
|
-
activeIndex.value = 0
|
|
222
|
+
expandInto(item)
|
|
197
223
|
return
|
|
198
224
|
}
|
|
199
225
|
emit('select', item)
|
|
200
226
|
}
|
|
201
227
|
|
|
228
|
+
/** 进入 item 的下一级(点击展开图标 / leaf 模式点击分支项共用) */
|
|
229
|
+
function expandInto(item: VariableItem) {
|
|
230
|
+
if (!hasChildren(item)) return
|
|
231
|
+
navPath.value = [...navPath.value, item]
|
|
232
|
+
activeIndex.value = 0
|
|
233
|
+
}
|
|
234
|
+
|
|
202
235
|
function flattenSourceFor(it: VariableItem): VariableItem {
|
|
203
236
|
const flat = it as FlatItem
|
|
204
237
|
// 还原为「不带 __path 的纯 VariableItem」,避免泄漏内部字段
|
|
@@ -220,11 +253,20 @@ function moveActive(delta: -1 | 1) {
|
|
|
220
253
|
|
|
221
254
|
function scrollActiveIntoView() {
|
|
222
255
|
void nextTick(() => {
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
256
|
+
// 在面板根节点内查找,避免同页多个编辑器时滚错列表
|
|
257
|
+
const item = root.value?.querySelector(`[data-idx="${activeIndex.value}"]`)
|
|
258
|
+
// expose 代理运行时会解包 ref,类型上按 unknown 收窄
|
|
259
|
+
const scroller = listScroll.value?.containerRef as unknown
|
|
260
|
+
if (!(item instanceof HTMLElement) || !(scroller instanceof HTMLElement)) return
|
|
261
|
+
// 只滚面板列表自身:原生 scrollIntoView 会沿所有滚动祖先链滚动,
|
|
262
|
+
// 面板贴近视口边缘时会连带滚动页面,导致面板与触发器错位抖动
|
|
263
|
+
const top =
|
|
264
|
+
item.getBoundingClientRect().top - scroller.getBoundingClientRect().top + scroller.scrollTop
|
|
265
|
+
const bottom = top + item.offsetHeight
|
|
266
|
+
if (top < scroller.scrollTop) {
|
|
267
|
+
scroller.scrollTop = top
|
|
268
|
+
} else if (bottom > scroller.scrollTop + scroller.clientHeight) {
|
|
269
|
+
scroller.scrollTop = bottom - scroller.clientHeight
|
|
228
270
|
}
|
|
229
271
|
})
|
|
230
272
|
}
|
|
@@ -287,8 +329,7 @@ function handleKeydown(e: KeyboardEvent): boolean {
|
|
|
287
329
|
if (isFlat.value) return false
|
|
288
330
|
const item = currentList.value[activeIndex.value]
|
|
289
331
|
if (item && hasChildren(item)) {
|
|
290
|
-
|
|
291
|
-
activeIndex.value = 0
|
|
332
|
+
expandInto(item)
|
|
292
333
|
return true
|
|
293
334
|
}
|
|
294
335
|
return false
|
|
@@ -24,6 +24,11 @@ export interface EditorOptions {
|
|
|
24
24
|
getVariableMap?: () => Map<string, VariableItem>
|
|
25
25
|
/** 模型每次更新(包括用户键入文本)后回调 */
|
|
26
26
|
onChange?: (doc: Doc) => void
|
|
27
|
+
/**
|
|
28
|
+
* 框内「视觉空态」变化回调:只描述眼下框里有没有可见内容,与模型是否已同步无关。
|
|
29
|
+
* IME 合成期间模型刻意不同步,占位文字 / 提示行这类纯视觉层须跟这个信号走。
|
|
30
|
+
*/
|
|
31
|
+
onVisualEmptyChange?: (empty: boolean) => void
|
|
27
32
|
/** 选中区域 / 光标偏移变化时回调,offset 为序列化字符串中的位置;无选中或不在编辑器内时为 null */
|
|
28
33
|
onSelectionChange?: (info: { offset: number | null }) => void
|
|
29
34
|
/** 用户点击某个 chip 的主体(重选) */
|
|
@@ -43,6 +48,11 @@ export interface EditorAPI {
|
|
|
43
48
|
getCaretOffset(): number | null
|
|
44
49
|
/** 把光标设置到指定偏移(不会修改模型) */
|
|
45
50
|
setCaretOffset(offset: number): void
|
|
51
|
+
/**
|
|
52
|
+
* 取序列化偏移处字符的视口矩形(mention 锚点定位用)。
|
|
53
|
+
* 偏移落在 text 段上返回该字符矩形;落在 chip 上返回 chip 元素矩形;越界返回 null。
|
|
54
|
+
*/
|
|
55
|
+
getRectAtOffset(offset: number): DOMRect | null
|
|
46
56
|
/**
|
|
47
57
|
* 用一个 var chip 替换 [start, end) 区间。
|
|
48
58
|
* 调用方通常在 mention 选中时用:start = `@` 起点、end = 当前光标。
|
|
@@ -61,12 +71,29 @@ export interface EditorAPI {
|
|
|
61
71
|
export function createEditor(opts: EditorOptions): EditorAPI {
|
|
62
72
|
let doc: Doc = normalize(opts.initialDoc)
|
|
63
73
|
let composing = false
|
|
74
|
+
/** null 表示还没算过,首次计算后必定回调一次 */
|
|
75
|
+
let visualEmpty: boolean | null = null
|
|
64
76
|
|
|
65
77
|
function emitChange(next: Doc) {
|
|
66
78
|
doc = next
|
|
67
79
|
opts.onChange?.(doc)
|
|
68
80
|
}
|
|
69
81
|
|
|
82
|
+
/** 框内是否只剩空 text span:任何文本(含 IME 合成中尚未进模型的)或 chip 都算非空 */
|
|
83
|
+
function isVisuallyEmpty(): boolean {
|
|
84
|
+
for (const child of Array.from(opts.container.childNodes)) {
|
|
85
|
+
if (isChipElement(child) || (child.textContent ?? '').length > 0) return false
|
|
86
|
+
}
|
|
87
|
+
return true
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function syncVisualEmpty(): void {
|
|
91
|
+
const empty = isVisuallyEmpty()
|
|
92
|
+
if (empty === visualEmpty) return
|
|
93
|
+
visualEmpty = empty
|
|
94
|
+
opts.onVisualEmptyChange?.(empty)
|
|
95
|
+
}
|
|
96
|
+
|
|
70
97
|
function makeTextSpan(value: string): HTMLSpanElement {
|
|
71
98
|
const span = document.createElement('span')
|
|
72
99
|
span.setAttribute(TEXT_SEG_ATTR, TEXT_SEG_VALUE)
|
|
@@ -131,6 +158,7 @@ export function createEditor(opts: EditorOptions): EditorAPI {
|
|
|
131
158
|
else container.appendChild(makeChipSpan(seg))
|
|
132
159
|
}
|
|
133
160
|
|
|
161
|
+
syncVisualEmpty()
|
|
134
162
|
if (typeof targetCaret === 'number') setCaret(targetCaret)
|
|
135
163
|
}
|
|
136
164
|
|
|
@@ -321,6 +349,28 @@ export function createEditor(opts: EditorOptions): EditorAPI {
|
|
|
321
349
|
return null
|
|
322
350
|
}
|
|
323
351
|
|
|
352
|
+
function getRectAtOffset(offset: number): DOMRect | null {
|
|
353
|
+
let pos = 0
|
|
354
|
+
for (const child of Array.from(opts.container.childNodes)) {
|
|
355
|
+
const len = childSerialLength(child)
|
|
356
|
+
if (offset >= pos && offset < pos + len) {
|
|
357
|
+
if (isChipElement(child)) return (child as HTMLElement).getBoundingClientRect()
|
|
358
|
+
const textNode = firstTextDescendant(child)
|
|
359
|
+
if (!textNode) return (child as HTMLElement).getBoundingClientRect()
|
|
360
|
+
const local = Math.min(offset - pos, textNode.data.length)
|
|
361
|
+
const range = document.createRange()
|
|
362
|
+
range.setStart(textNode, local)
|
|
363
|
+
range.setEnd(textNode, Math.min(local + 1, textNode.data.length))
|
|
364
|
+
const rects = range.getClientRects()
|
|
365
|
+
return rects.length > 0
|
|
366
|
+
? (rects[0] as DOMRect)
|
|
367
|
+
: (child as HTMLElement).getBoundingClientRect()
|
|
368
|
+
}
|
|
369
|
+
pos += len
|
|
370
|
+
}
|
|
371
|
+
return null
|
|
372
|
+
}
|
|
373
|
+
|
|
324
374
|
/** input 后:从 DOM 反推 doc,并触发 onChange(如有变化)。 */
|
|
325
375
|
function syncFromDOM(): void {
|
|
326
376
|
if (composing) return
|
|
@@ -343,13 +393,20 @@ export function createEditor(opts: EditorOptions): EditorAPI {
|
|
|
343
393
|
}
|
|
344
394
|
|
|
345
395
|
function onInput() {
|
|
396
|
+
// 合成中模型不动,但框里已经有字:占位层必须立刻让位
|
|
397
|
+
syncVisualEmpty()
|
|
346
398
|
syncFromDOM()
|
|
347
399
|
}
|
|
348
400
|
function onCompositionStart() {
|
|
349
401
|
composing = true
|
|
350
402
|
}
|
|
403
|
+
function onCompositionUpdate() {
|
|
404
|
+
// 少数浏览器把合成文字落进 DOM 的时机晚于 input,这里再兜一次
|
|
405
|
+
syncVisualEmpty()
|
|
406
|
+
}
|
|
351
407
|
function onCompositionEnd() {
|
|
352
408
|
composing = false
|
|
409
|
+
syncVisualEmpty()
|
|
353
410
|
syncFromDOM()
|
|
354
411
|
}
|
|
355
412
|
function onPaste(e: ClipboardEvent) {
|
|
@@ -386,6 +443,7 @@ export function createEditor(opts: EditorOptions): EditorAPI {
|
|
|
386
443
|
range.collapse(true)
|
|
387
444
|
sel.removeAllRanges()
|
|
388
445
|
sel.addRange(range)
|
|
446
|
+
syncVisualEmpty()
|
|
389
447
|
syncFromDOM()
|
|
390
448
|
}
|
|
391
449
|
|
|
@@ -456,6 +514,7 @@ export function createEditor(opts: EditorOptions): EditorAPI {
|
|
|
456
514
|
const container = opts.container
|
|
457
515
|
container.addEventListener('input', onInput)
|
|
458
516
|
container.addEventListener('compositionstart', onCompositionStart)
|
|
517
|
+
container.addEventListener('compositionupdate', onCompositionUpdate)
|
|
459
518
|
container.addEventListener('compositionend', onCompositionEnd)
|
|
460
519
|
container.addEventListener('paste', onPaste)
|
|
461
520
|
document.addEventListener('selectionchange', onSelectionChange)
|
|
@@ -478,12 +537,14 @@ export function createEditor(opts: EditorOptions): EditorAPI {
|
|
|
478
537
|
},
|
|
479
538
|
getCaretOffset,
|
|
480
539
|
setCaretOffset: setCaret,
|
|
540
|
+
getRectAtOffset,
|
|
481
541
|
replaceRangeWithVar,
|
|
482
542
|
replaceVarAt,
|
|
483
543
|
rerender: () => render(),
|
|
484
544
|
dispose: () => {
|
|
485
545
|
container.removeEventListener('input', onInput)
|
|
486
546
|
container.removeEventListener('compositionstart', onCompositionStart)
|
|
547
|
+
container.removeEventListener('compositionupdate', onCompositionUpdate)
|
|
487
548
|
container.removeEventListener('compositionend', onCompositionEnd)
|
|
488
549
|
container.removeEventListener('paste', onPaste)
|
|
489
550
|
document.removeEventListener('selectionchange', onSelectionChange)
|