@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.
Files changed (82) hide show
  1. package/dist/components/batch-edit/batch-edit-form.js +40 -26
  2. package/dist/components/batch-edit/batch-edit-form.js.map +1 -1
  3. package/dist/components/batch-edit/batch-edit-list.js +17 -5
  4. package/dist/components/batch-edit/batch-edit-list.js.map +1 -1
  5. package/dist/components/batch-edit/di.js.map +1 -1
  6. package/dist/components/batch-edit/use-handlers.js +3 -3
  7. package/dist/components/batch-edit/use-handlers.js.map +1 -1
  8. package/dist/components/batch-edit/use-shortcut-key.js +4 -2
  9. package/dist/components/batch-edit/use-shortcut-key.js.map +1 -1
  10. package/dist/components/card/style2.css +0 -2
  11. package/dist/components/dialog/style2.css +1 -1
  12. package/dist/components/dropdown/dropdown.js +5 -3
  13. package/dist/components/dropdown/dropdown.js.map +1 -1
  14. package/dist/components/expression-editor/components/path-preview.js +3 -6
  15. package/dist/components/expression-editor/components/path-preview.js.map +1 -1
  16. package/dist/components/expression-editor/components/variable-picker.js +49 -12
  17. package/dist/components/expression-editor/components/variable-picker.js.map +1 -1
  18. package/dist/components/expression-editor/core/chip.js +1 -15
  19. package/dist/components/expression-editor/core/chip.js.map +1 -1
  20. package/dist/components/expression-editor/core/editor.js +20 -18
  21. package/dist/components/expression-editor/core/editor.js.map +1 -1
  22. package/dist/components/expression-editor/expression-editor.js +53 -22
  23. package/dist/components/expression-editor/expression-editor.js.map +1 -1
  24. package/dist/components/expression-editor/style2.css +135 -96
  25. package/dist/components/form/form.js.map +1 -1
  26. package/dist/components/form/form.vue_vue_type_script_setup_true_lang.js +0 -1
  27. package/dist/components/form/form.vue_vue_type_script_setup_true_lang.js.map +1 -1
  28. package/dist/components/form/style2.css +8 -3
  29. package/dist/components/index.d.ts +26 -26
  30. package/dist/components/index.js +52 -52
  31. package/dist/components/multi-select/multi-select.js +16 -12
  32. package/dist/components/multi-select/multi-select.js.map +1 -1
  33. package/dist/components/multi-select/style2.css +7 -0
  34. package/dist/components/multi-tree-select/multi-tree-select.js +82 -56
  35. package/dist/components/multi-tree-select/multi-tree-select.js.map +1 -1
  36. package/dist/components/multi-tree-select/style2.css +7 -0
  37. package/dist/components/select/select.js +7 -3
  38. package/dist/components/select/select.js.map +1 -1
  39. package/dist/components/select/style2.css +7 -0
  40. package/dist/components/select/use-options.js +14 -3
  41. package/dist/components/select/use-options.js.map +1 -1
  42. package/dist/components/tree-select/style2.css +7 -0
  43. package/dist/components/tree-select/tree-select.js +49 -17
  44. package/dist/components/tree-select/tree-select.js.map +1 -1
  45. package/dist/index.d.ts +1 -1
  46. package/dist/index.js +2 -2
  47. package/dist/types/dropdown.d.ts +10 -0
  48. package/dist/types/multi-tree-select.d.ts +7 -1
  49. package/dist/types/tree-select.d.ts +7 -1
  50. package/package.json +1 -1
  51. package/src/components/batch-edit/__test__/batch-edit.test.ts +119 -7
  52. package/src/components/batch-edit/batch-edit-form.vue +21 -2
  53. package/src/components/batch-edit/batch-edit-list.vue +18 -1
  54. package/src/components/batch-edit/di.ts +1 -1
  55. package/src/components/batch-edit/use-handlers.ts +6 -5
  56. package/src/components/batch-edit/use-shortcut-key.ts +5 -2
  57. package/src/components/card/style.scss +0 -2
  58. package/src/components/dialog/style.scss +2 -1
  59. package/src/components/dropdown/dropdown.vue +9 -4
  60. package/src/components/expression-editor/__test__/chip.test.ts +3 -14
  61. package/src/components/expression-editor/components/path-preview.vue +1 -2
  62. package/src/components/expression-editor/components/variable-picker.vue +54 -13
  63. package/src/components/expression-editor/core/chip.ts +3 -29
  64. package/src/components/expression-editor/core/editor.ts +28 -21
  65. package/src/components/expression-editor/expression-editor.vue +47 -21
  66. package/src/components/expression-editor/style.scss +167 -111
  67. package/src/components/form/form.vue +0 -1
  68. package/src/components/form/style.scss +11 -4
  69. package/src/components/multi-select/multi-select.vue +11 -2
  70. package/src/components/multi-select/style.scss +6 -0
  71. package/src/components/multi-tree-select/multi-tree-select.vue +58 -9
  72. package/src/components/multi-tree-select/style.scss +6 -0
  73. package/src/components/select/__test__/select.test.ts +78 -0
  74. package/src/components/select/select.vue +13 -3
  75. package/src/components/select/style.scss +6 -0
  76. package/src/components/select/use-options.ts +25 -4
  77. package/src/components/tree-select/__test__/tree-select.test.ts +116 -0
  78. package/src/components/tree-select/style.scss +6 -0
  79. package/src/components/tree-select/tree-select.vue +63 -13
  80. package/src/types/dropdown.ts +10 -0
  81. package/src/types/multi-tree-select.ts +12 -1
  82. 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
- @include m.bem(scroll, content) {
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>(), { trigger: 'hover', contentTag: 'div' })
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: 'bottom',
125
- alignment: 'start',
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, onRemove, onReselect })
26
- return { el, onRemove, onReselect, segment }
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, onRemove } = makeChip()
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="{ paddingLeft: `${i * 14}px` }"
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 tag="ul" :class="cls.e('panel-list')" :content-class="cls.e('panel-content')">
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 v-if="!isFlat && hasChildren(item)" :class="cls.e('item-expand')">
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
- navPath.value = [...navPath.value, item]
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
- const list = document.querySelector(`.${cls.e('panel-list')}`)
224
- if (!list) return
225
- const item = list.querySelector(`[data-idx="${activeIndex.value}"]`)
226
- if (item && 'scrollIntoView' in item) {
227
- ;(item as HTMLElement).scrollIntoView({ block: 'nearest' })
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
- navPath.value = [...navPath.value, item]
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
- if (isCloseTarget(e.target)) {
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="className">
3
- <div
4
- ref="container"
5
- :class="cls.e('container')"
6
- :contenteditable="!readonly && !disabled"
7
- :data-empty="isEmpty || undefined"
8
- :data-placeholder="props.placeholder"
9
- @keydown="onKeydown"
10
- @blur="onBlur"
11
- ></div>
12
-
13
- <div v-if="showPlaceholder" :class="cls.e('placeholder')">
14
- {{ props.placeholder }}
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
- const className = computed(() => [
69
- cls.b,
70
- cls.m(size.value),
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
- pickerTriggerDom.value = containerRef.value ?? undefined
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
- onChipRemove: ({ segIndex }) => {
140
- if (disabled.value || readonly.value) return
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