@veltra/desktop 1.7.18 → 1.8.0
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/batch-edit-list.js +17 -5
- package/dist/components/batch-edit/batch-edit-list.js.map +1 -1
- package/dist/components/batch-edit/di.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/dialog/style2.css +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/chip.js +1 -15
- package/dist/components/expression-editor/core/chip.js.map +1 -1
- package/dist/components/expression-editor/core/editor.js +20 -18
- package/dist/components/expression-editor/core/editor.js.map +1 -1
- package/dist/components/expression-editor/expression-editor.js +53 -22
- package/dist/components/expression-editor/expression-editor.js.map +1 -1
- package/dist/components/expression-editor/style2.css +135 -96
- 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/index.d.ts +26 -26
- package/dist/components/index.js +52 -52
- 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/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 +1 -1
- package/dist/index.js +2 -2
- package/dist/types/dropdown.d.ts +10 -0
- package/dist/types/multi-tree-select.d.ts +7 -1
- package/dist/types/tree-select.d.ts +7 -1
- package/package.json +1 -1
- package/src/components/batch-edit/__test__/batch-edit.test.ts +119 -7
- package/src/components/batch-edit/batch-edit-form.vue +21 -2
- package/src/components/batch-edit/batch-edit-list.vue +18 -1
- package/src/components/batch-edit/di.ts +1 -1
- 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/dialog/style.scss +2 -1
- package/src/components/dropdown/dropdown.vue +9 -4
- package/src/components/expression-editor/__test__/chip.test.ts +3 -14
- 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/chip.ts +3 -29
- package/src/components/expression-editor/core/editor.ts +28 -21
- package/src/components/expression-editor/expression-editor.vue +47 -21
- package/src/components/expression-editor/style.scss +167 -111
- package/src/components/form/form.vue +0 -1
- package/src/components/form/style.scss +11 -4
- 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/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/multi-tree-select.ts +12 -1
- package/src/types/tree-select.ts +12 -1
|
@@ -7,14 +7,12 @@ $root-name: card;
|
|
|
7
7
|
@include m.b($root-name) {
|
|
8
8
|
border-radius: fn.component-var(card, radius, fn.use-var(radius, large));
|
|
9
9
|
background-color: fn.use-var(bg-color, top);
|
|
10
|
-
border: fn.use-var(border-muted);
|
|
11
10
|
box-shadow: fn.use-var(shadow, sm);
|
|
12
11
|
backdrop-filter: fn.use-var(bg-filter);
|
|
13
12
|
-webkit-backdrop-filter: fn.use-var(bg-filter);
|
|
14
13
|
overflow: hidden;
|
|
15
14
|
|
|
16
15
|
@include m.is(integrate) {
|
|
17
|
-
border: none;
|
|
18
16
|
box-shadow: none;
|
|
19
17
|
--u-card-header-bg: transparent;
|
|
20
18
|
--u-card-action-bg: transparent;
|
|
@@ -81,7 +81,8 @@ $root-name: dialog;
|
|
|
81
81
|
flex-grow: 1;
|
|
82
82
|
|
|
83
83
|
// padding 加在滚动内容而非 body 本身:u-scroll 的 overflow 会裁切子元素 box-shadow
|
|
84
|
-
|
|
84
|
+
// 用子代链限定 body 自己的滚动内容,避免命中弹框内嵌套的 u-scroll
|
|
85
|
+
> #{fn.bem(scroll, container)} > #{fn.bem(scroll, content)} {
|
|
85
86
|
padding: 8px;
|
|
86
87
|
}
|
|
87
88
|
}
|
|
@@ -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
|
|
|
@@ -14,7 +14,6 @@ const cls = {
|
|
|
14
14
|
} as any
|
|
15
15
|
|
|
16
16
|
function makeChip(overrides: Partial<{ value: string; label: string; type?: string }> = {}) {
|
|
17
|
-
const onRemove = vi.fn<(el: HTMLElement) => void>()
|
|
18
17
|
const onReselect = vi.fn<(el: HTMLElement) => void>()
|
|
19
18
|
const segment = {
|
|
20
19
|
kind: 'var' as const,
|
|
@@ -22,8 +21,8 @@ function makeChip(overrides: Partial<{ value: string; label: string; type?: stri
|
|
|
22
21
|
label: overrides.label ?? '用户姓名',
|
|
23
22
|
...(overrides.type ? { type: overrides.type } : {})
|
|
24
23
|
}
|
|
25
|
-
const el = createChip(segment, { cls,
|
|
26
|
-
return { el,
|
|
24
|
+
const el = createChip(segment, { cls, onReselect })
|
|
25
|
+
return { el, onReselect, segment }
|
|
27
26
|
}
|
|
28
27
|
|
|
29
28
|
describe('createChip', () => {
|
|
@@ -54,21 +53,11 @@ describe('createChip', () => {
|
|
|
54
53
|
})
|
|
55
54
|
|
|
56
55
|
it('mousedown on chip body triggers onReselect', () => {
|
|
57
|
-
const { el, onReselect
|
|
56
|
+
const { el, onReselect } = makeChip()
|
|
58
57
|
const label = el.querySelector(`.${cls.e('chip-label')}`) as HTMLElement
|
|
59
58
|
label.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
|
|
60
59
|
expect(onReselect).toHaveBeenCalledTimes(1)
|
|
61
60
|
expect(onReselect).toHaveBeenCalledWith(el)
|
|
62
|
-
expect(onRemove).not.toHaveBeenCalled()
|
|
63
|
-
})
|
|
64
|
-
|
|
65
|
-
it('mousedown on close (×) triggers onRemove and not onReselect', () => {
|
|
66
|
-
const { el, onReselect, onRemove } = makeChip()
|
|
67
|
-
const close = el.querySelector(`[${ChipAttrs.close}]`) as HTMLElement
|
|
68
|
-
close.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
|
|
69
|
-
expect(onRemove).toHaveBeenCalledTimes(1)
|
|
70
|
-
expect(onRemove).toHaveBeenCalledWith(el)
|
|
71
|
-
expect(onReselect).not.toHaveBeenCalled()
|
|
72
61
|
})
|
|
73
62
|
|
|
74
63
|
it('readChipValue returns the bound value', () => {
|
|
@@ -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
|
|
@@ -8,18 +8,15 @@ export interface ChipDeps {
|
|
|
8
8
|
|
|
9
9
|
export interface ChipFactoryOptions extends ChipDeps {
|
|
10
10
|
/**
|
|
11
|
-
* 当用户点击 chip
|
|
11
|
+
* 当用户点击 chip 主体时回调,由调用方决定如何打开重选面板。
|
|
12
12
|
* 收到的 chipEl 当前在 DOM 中的位置即为重选锚点。
|
|
13
13
|
*/
|
|
14
14
|
onReselect: (chipEl: HTMLElement) => void
|
|
15
|
-
/** 当用户点击 × 时回调,调用方应将该 chip 从模型 / DOM 中移除。 */
|
|
16
|
-
onRemove: (chipEl: HTMLElement) => void
|
|
17
15
|
}
|
|
18
16
|
|
|
19
17
|
const CHIP_DATA_SEG = 'var'
|
|
20
18
|
const CHIP_VALUE_ATTR = 'data-value'
|
|
21
19
|
const CHIP_TYPE_ATTR = 'data-var-type'
|
|
22
|
-
const CHIP_CLOSE_ATTR = 'data-chip-close'
|
|
23
20
|
|
|
24
21
|
/** 判断一个 DOM 节点是否为 chip 元素(var 段渲染体) */
|
|
25
22
|
export function isChipElement(node: Node | null): node is HTMLElement {
|
|
@@ -30,12 +27,6 @@ export function isChipElement(node: Node | null): node is HTMLElement {
|
|
|
30
27
|
)
|
|
31
28
|
}
|
|
32
29
|
|
|
33
|
-
/** 判断点击目标是否落在 × 删除区。 */
|
|
34
|
-
function isCloseTarget(target: EventTarget | null): boolean {
|
|
35
|
-
if (!(target instanceof Element)) return false
|
|
36
|
-
return !!target.closest(`[${CHIP_CLOSE_ATTR}]`)
|
|
37
|
-
}
|
|
38
|
-
|
|
39
30
|
/** 从给定 segment 创建一个 chip 元素(var 段渲染体)。 */
|
|
40
31
|
export function createChip(
|
|
41
32
|
segment: Extract<Segment, { kind: 'var' }>,
|
|
@@ -55,25 +46,13 @@ export function createChip(
|
|
|
55
46
|
label.className = cls.e('chip-label')
|
|
56
47
|
label.textContent = segment.type ? `${segment.label} (${segment.type})` : segment.label
|
|
57
48
|
|
|
58
|
-
const close = document.createElement('span')
|
|
59
|
-
close.className = cls.e('chip-close')
|
|
60
|
-
close.setAttribute(CHIP_CLOSE_ATTR, 'true')
|
|
61
|
-
close.setAttribute('aria-label', '删除')
|
|
62
|
-
close.setAttribute('role', 'button')
|
|
63
|
-
close.textContent = '×'
|
|
64
|
-
|
|
65
49
|
chip.appendChild(label)
|
|
66
|
-
chip.appendChild(close)
|
|
67
50
|
|
|
68
51
|
chip.addEventListener('mousedown', (e) => {
|
|
69
52
|
e.preventDefault()
|
|
70
53
|
// 阻止冒泡:避免宿主弹框(如 dialog)的 mousedown 层级提升逻辑把 chip 打开的重选面板盖住
|
|
71
54
|
e.stopPropagation()
|
|
72
|
-
|
|
73
|
-
opts.onRemove(chip)
|
|
74
|
-
} else {
|
|
75
|
-
opts.onReselect(chip)
|
|
76
|
-
}
|
|
55
|
+
opts.onReselect(chip)
|
|
77
56
|
})
|
|
78
57
|
|
|
79
58
|
return chip
|
|
@@ -95,9 +74,4 @@ export function setChipFocused(el: HTMLElement, focused: boolean): void {
|
|
|
95
74
|
}
|
|
96
75
|
|
|
97
76
|
/** 测试支持:暴露用于 DOM 选择器的属性常量。 */
|
|
98
|
-
export const ChipAttrs = {
|
|
99
|
-
seg: 'data-seg',
|
|
100
|
-
value: CHIP_VALUE_ATTR,
|
|
101
|
-
type: CHIP_TYPE_ATTR,
|
|
102
|
-
close: CHIP_CLOSE_ATTR
|
|
103
|
-
} as const
|
|
77
|
+
export const ChipAttrs = { seg: 'data-seg', value: CHIP_VALUE_ATTR, type: CHIP_TYPE_ATTR } as const
|
|
@@ -28,8 +28,6 @@ export interface EditorOptions {
|
|
|
28
28
|
onSelectionChange?: (info: { offset: number | null }) => void
|
|
29
29
|
/** 用户点击某个 chip 的主体(重选) */
|
|
30
30
|
onChipReselect?: (info: { chipEl: HTMLElement; segIndex: number; chipOffset: number }) => void
|
|
31
|
-
/** 用户点击某个 chip 的 ×(删除) */
|
|
32
|
-
onChipRemove?: (info: { chipEl: HTMLElement; segIndex: number }) => void
|
|
33
31
|
}
|
|
34
32
|
|
|
35
33
|
export interface EditorAPI {
|
|
@@ -45,13 +43,16 @@ export interface EditorAPI {
|
|
|
45
43
|
getCaretOffset(): number | null
|
|
46
44
|
/** 把光标设置到指定偏移(不会修改模型) */
|
|
47
45
|
setCaretOffset(offset: number): void
|
|
46
|
+
/**
|
|
47
|
+
* 取序列化偏移处字符的视口矩形(mention 锚点定位用)。
|
|
48
|
+
* 偏移落在 text 段上返回该字符矩形;落在 chip 上返回 chip 元素矩形;越界返回 null。
|
|
49
|
+
*/
|
|
50
|
+
getRectAtOffset(offset: number): DOMRect | null
|
|
48
51
|
/**
|
|
49
52
|
* 用一个 var chip 替换 [start, end) 区间。
|
|
50
53
|
* 调用方通常在 mention 选中时用:start = `@` 起点、end = 当前光标。
|
|
51
54
|
*/
|
|
52
55
|
replaceRangeWithVar(start: number, end: number, variable: EditorVariableInfo): void
|
|
53
|
-
/** 移除指定段下标对应的 var chip(必须是 var 段)。 */
|
|
54
|
-
removeVarAt(segIndex: number): void
|
|
55
56
|
/** 替换指定段下标对应的 var chip 为新变量(必须是 var 段)。 */
|
|
56
57
|
replaceVarAt(segIndex: number, variable: EditorVariableInfo): void
|
|
57
58
|
/** 卸载所有事件监听(不会销毁 container DOM) */
|
|
@@ -85,11 +86,6 @@ export function createEditor(opts: EditorOptions): EditorAPI {
|
|
|
85
86
|
const { segIndex, chipOffset } = locateChip(chipEl)
|
|
86
87
|
if (segIndex < 0) return
|
|
87
88
|
opts.onChipReselect?.({ chipEl, segIndex, chipOffset })
|
|
88
|
-
},
|
|
89
|
-
onRemove: (chipEl) => {
|
|
90
|
-
const { segIndex } = locateChip(chipEl)
|
|
91
|
-
if (segIndex < 0) return
|
|
92
|
-
opts.onChipRemove?.({ chipEl, segIndex })
|
|
93
89
|
}
|
|
94
90
|
})
|
|
95
91
|
}
|
|
@@ -330,6 +326,28 @@ export function createEditor(opts: EditorOptions): EditorAPI {
|
|
|
330
326
|
return null
|
|
331
327
|
}
|
|
332
328
|
|
|
329
|
+
function getRectAtOffset(offset: number): DOMRect | null {
|
|
330
|
+
let pos = 0
|
|
331
|
+
for (const child of Array.from(opts.container.childNodes)) {
|
|
332
|
+
const len = childSerialLength(child)
|
|
333
|
+
if (offset >= pos && offset < pos + len) {
|
|
334
|
+
if (isChipElement(child)) return (child as HTMLElement).getBoundingClientRect()
|
|
335
|
+
const textNode = firstTextDescendant(child)
|
|
336
|
+
if (!textNode) return (child as HTMLElement).getBoundingClientRect()
|
|
337
|
+
const local = Math.min(offset - pos, textNode.data.length)
|
|
338
|
+
const range = document.createRange()
|
|
339
|
+
range.setStart(textNode, local)
|
|
340
|
+
range.setEnd(textNode, Math.min(local + 1, textNode.data.length))
|
|
341
|
+
const rects = range.getClientRects()
|
|
342
|
+
return rects.length > 0
|
|
343
|
+
? (rects[0] as DOMRect)
|
|
344
|
+
: (child as HTMLElement).getBoundingClientRect()
|
|
345
|
+
}
|
|
346
|
+
pos += len
|
|
347
|
+
}
|
|
348
|
+
return null
|
|
349
|
+
}
|
|
350
|
+
|
|
333
351
|
/** input 后:从 DOM 反推 doc,并触发 onChange(如有变化)。 */
|
|
334
352
|
function syncFromDOM(): void {
|
|
335
353
|
if (composing) return
|
|
@@ -412,17 +430,6 @@ export function createEditor(opts: EditorOptions): EditorAPI {
|
|
|
412
430
|
render(a + variable.value.length + 2) // 光标定到 chip 之后
|
|
413
431
|
}
|
|
414
432
|
|
|
415
|
-
function removeVarAt(segIndex: number) {
|
|
416
|
-
const target = doc[segIndex]
|
|
417
|
-
if (!target || target.kind !== 'var') return
|
|
418
|
-
const before = doc.slice(0, segIndex)
|
|
419
|
-
const after = doc.slice(segIndex + 1)
|
|
420
|
-
const caretOffset = sliceLength(before)
|
|
421
|
-
const next = normalize([...before, ...after])
|
|
422
|
-
emitChange(next)
|
|
423
|
-
render(caretOffset)
|
|
424
|
-
}
|
|
425
|
-
|
|
426
433
|
function replaceVarAt(segIndex: number, variable: EditorVariableInfo) {
|
|
427
434
|
const target = doc[segIndex]
|
|
428
435
|
if (!target || target.kind !== 'var') return
|
|
@@ -498,8 +505,8 @@ export function createEditor(opts: EditorOptions): EditorAPI {
|
|
|
498
505
|
},
|
|
499
506
|
getCaretOffset,
|
|
500
507
|
setCaretOffset: setCaret,
|
|
508
|
+
getRectAtOffset,
|
|
501
509
|
replaceRangeWithVar,
|
|
502
|
-
removeVarAt,
|
|
503
510
|
replaceVarAt,
|
|
504
511
|
rerender: () => render(),
|
|
505
512
|
dispose: () => {
|
|
@@ -1,17 +1,27 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<div :class="
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
:
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
2
|
+
<div ref="rootEl" :class="cls.b">
|
|
3
|
+
<!-- 提示行独立在边框盒(shell)之外 -->
|
|
4
|
+
<div v-if="showHint" :class="cls.e('hint')">输入 <kbd>@</kbd> 唤起变量面板</div>
|
|
5
|
+
|
|
6
|
+
<div :class="shellClass">
|
|
7
|
+
<div :class="cls.e('body')">
|
|
8
|
+
<div
|
|
9
|
+
ref="container"
|
|
10
|
+
:class="cls.e('container')"
|
|
11
|
+
:contenteditable="!readonly && !disabled"
|
|
12
|
+
:data-empty="isEmpty || undefined"
|
|
13
|
+
:data-placeholder="props.placeholder"
|
|
14
|
+
@keydown="onKeydown"
|
|
15
|
+
@blur="onBlur"
|
|
16
|
+
></div>
|
|
17
|
+
|
|
18
|
+
<div v-if="showPlaceholder" :class="cls.e('placeholder')">
|
|
19
|
+
{{ props.placeholder }}
|
|
20
|
+
</div>
|
|
21
|
+
</div>
|
|
22
|
+
|
|
23
|
+
<!-- mention 面板的定位锚点:跟随 `@` 字符位置(见 positionMentionAnchor) -->
|
|
24
|
+
<span ref="mentionAnchor" :class="cls.e('mention-anchor')" aria-hidden="true"></span>
|
|
15
25
|
</div>
|
|
16
26
|
|
|
17
27
|
<VariablePicker
|
|
@@ -63,11 +73,14 @@ const { formProps } = injectFormContext()
|
|
|
63
73
|
const { size, disabled, readonly } = useFormFallbackProps([formProps ?? {}, props])
|
|
64
74
|
|
|
65
75
|
const containerRef = useTemplateRef<HTMLDivElement>('container')
|
|
76
|
+
const rootElRef = useTemplateRef<HTMLDivElement>('rootEl')
|
|
77
|
+
const mentionAnchorRef = useTemplateRef<HTMLSpanElement>('mentionAnchor')
|
|
66
78
|
const pickerRef = useTemplateRef<{ handleKeydown: (e: KeyboardEvent) => boolean }>('pickerRef')
|
|
67
79
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
cls.
|
|
80
|
+
/** 视觉盒(边框 / 背景 / 聚焦光晕 / 尺寸态)挂在 shell 上,根节点只做布局 */
|
|
81
|
+
const shellClass = computed(() => [
|
|
82
|
+
cls.e('shell'),
|
|
83
|
+
cls.em('shell', size.value),
|
|
71
84
|
bem.is('disabled', disabled.value),
|
|
72
85
|
bem.is('readonly', readonly.value)
|
|
73
86
|
])
|
|
@@ -80,6 +93,7 @@ const mention: MentionAPI = createMention()
|
|
|
80
93
|
const currentDoc = shallowRef<Doc>([])
|
|
81
94
|
const isEmpty = computed(() => currentDoc.value.length === 0)
|
|
82
95
|
const showPlaceholder = computed(() => isEmpty.value && !disabled.value)
|
|
96
|
+
const showHint = computed(() => isEmpty.value && !disabled.value && !readonly.value)
|
|
83
97
|
|
|
84
98
|
const pickerMode = shallowRef<'mention' | 'reselect' | null>(null)
|
|
85
99
|
const pickerTriggerDom = shallowRef<HTMLElement | undefined>(undefined)
|
|
@@ -88,6 +102,18 @@ const pickerFilter = shallowRef('')
|
|
|
88
102
|
|
|
89
103
|
const pickerVisible = computed(() => pickerMode.value !== null)
|
|
90
104
|
|
|
105
|
+
/** 把 mention 锚点移到 `@` 字符下方,面板据此定位 */
|
|
106
|
+
function positionMentionAnchor(anchorOffset: number) {
|
|
107
|
+
const rootEl = rootElRef.value
|
|
108
|
+
const anchor = mentionAnchorRef.value
|
|
109
|
+
const rect = editorRef.value?.getRectAtOffset(anchorOffset)
|
|
110
|
+
if (!rootEl || !anchor || !rect) return
|
|
111
|
+
// absolute 定位以 padding box 为基准,需扣除根节点边框宽度
|
|
112
|
+
const rootRect = rootEl.getBoundingClientRect()
|
|
113
|
+
anchor.style.left = `${rect.left - rootRect.left - rootEl.clientLeft}px`
|
|
114
|
+
anchor.style.top = `${rect.bottom - rootRect.top - rootEl.clientTop}px`
|
|
115
|
+
}
|
|
116
|
+
|
|
91
117
|
function syncMention() {
|
|
92
118
|
const editor = editorRef.value
|
|
93
119
|
if (!editor) return
|
|
@@ -97,7 +123,8 @@ function syncMention() {
|
|
|
97
123
|
if (state) {
|
|
98
124
|
pickerMode.value = 'mention'
|
|
99
125
|
pickerFilter.value = state.filter
|
|
100
|
-
|
|
126
|
+
positionMentionAnchor(state.anchorOffset)
|
|
127
|
+
pickerTriggerDom.value = mentionAnchorRef.value ?? containerRef.value ?? undefined
|
|
101
128
|
} else if (pickerMode.value === 'mention') {
|
|
102
129
|
closePicker()
|
|
103
130
|
}
|
|
@@ -135,10 +162,9 @@ onMounted(() => {
|
|
|
135
162
|
pickerFilter.value = ''
|
|
136
163
|
pickerTriggerDom.value = chipEl
|
|
137
164
|
reselectingSegIdx.value = segIndex
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
editor.removeVarAt(segIndex)
|
|
165
|
+
// chip 的 mousedown 被 preventDefault,编辑器没有焦点时点击 chip 焦点不会转移,
|
|
166
|
+
// 键盘导航依赖 container 的 keydown,这里主动把焦点拉回编辑器
|
|
167
|
+
containerRef.value?.focus()
|
|
142
168
|
}
|
|
143
169
|
})
|
|
144
170
|
editorRef.value = editor
|