@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.
Files changed (114) 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/use-handlers.js +3 -3
  4. package/dist/components/batch-edit/use-handlers.js.map +1 -1
  5. package/dist/components/batch-edit/use-shortcut-key.js +4 -2
  6. package/dist/components/batch-edit/use-shortcut-key.js.map +1 -1
  7. package/dist/components/card/style2.css +0 -2
  8. package/dist/components/condition-editor/condition-editor.js +8 -2
  9. package/dist/components/condition-editor/condition-editor.js.map +1 -1
  10. package/dist/components/dropdown/dropdown.js +5 -3
  11. package/dist/components/dropdown/dropdown.js.map +1 -1
  12. package/dist/components/expression-editor/components/path-preview.js +3 -6
  13. package/dist/components/expression-editor/components/path-preview.js.map +1 -1
  14. package/dist/components/expression-editor/components/variable-picker.js +49 -12
  15. package/dist/components/expression-editor/components/variable-picker.js.map +1 -1
  16. package/dist/components/expression-editor/core/editor.js +42 -0
  17. package/dist/components/expression-editor/core/editor.js.map +1 -1
  18. package/dist/components/expression-editor/expression-editor.js +68 -22
  19. package/dist/components/expression-editor/expression-editor.js.map +1 -1
  20. package/dist/components/expression-editor/style2.css +135 -73
  21. package/dist/components/form/form.js.map +1 -1
  22. package/dist/components/form/form.vue_vue_type_script_setup_true_lang.js +0 -1
  23. package/dist/components/form/form.vue_vue_type_script_setup_true_lang.js.map +1 -1
  24. package/dist/components/form/style2.css +8 -3
  25. package/dist/components/form-item/style2.css +14 -0
  26. package/dist/components/index.d.ts +27 -26
  27. package/dist/components/index.js +54 -52
  28. package/dist/components/index.js.map +1 -1
  29. package/dist/components/kanban/di.js +7 -0
  30. package/dist/components/kanban/di.js.map +1 -0
  31. package/dist/components/kanban/kanban-column.js +67 -0
  32. package/dist/components/kanban/kanban-column.js.map +1 -0
  33. package/dist/components/kanban/kanban.js +89 -0
  34. package/dist/components/kanban/kanban.js.map +1 -0
  35. package/dist/components/kanban/style.d.ts +1 -0
  36. package/dist/components/kanban/style.js +1 -0
  37. package/dist/components/kanban/style2.css +128 -0
  38. package/dist/components/multi-select/multi-select.js +16 -12
  39. package/dist/components/multi-select/multi-select.js.map +1 -1
  40. package/dist/components/multi-select/style2.css +7 -0
  41. package/dist/components/multi-tree-select/multi-tree-select.js +82 -56
  42. package/dist/components/multi-tree-select/multi-tree-select.js.map +1 -1
  43. package/dist/components/multi-tree-select/style2.css +7 -0
  44. package/dist/components/rich-text-editor/image-node.js +154 -0
  45. package/dist/components/rich-text-editor/image-node.js.map +1 -0
  46. package/dist/components/rich-text-editor/rich-text-editor.js +80 -22
  47. package/dist/components/rich-text-editor/rich-text-editor.js.map +1 -1
  48. package/dist/components/rich-text-editor/style2.css +6 -0
  49. package/dist/components/rich-text-editor/toolbar.js +22 -2
  50. package/dist/components/rich-text-editor/toolbar.js.map +1 -1
  51. package/dist/components/select/select.js +7 -3
  52. package/dist/components/select/select.js.map +1 -1
  53. package/dist/components/select/style2.css +7 -0
  54. package/dist/components/select/use-options.js +14 -3
  55. package/dist/components/select/use-options.js.map +1 -1
  56. package/dist/components/tree-select/style2.css +7 -0
  57. package/dist/components/tree-select/tree-select.js +49 -17
  58. package/dist/components/tree-select/tree-select.js.map +1 -1
  59. package/dist/index.d.ts +48 -46
  60. package/dist/index.js +47 -46
  61. package/dist/types/dropdown.d.ts +10 -0
  62. package/dist/types/index.d.ts +3 -2
  63. package/dist/types/kanban.d.ts +69 -0
  64. package/dist/types/multi-tree-select.d.ts +7 -1
  65. package/dist/types/rich-text-editor.d.ts +13 -2
  66. package/dist/types/tree-select.d.ts +7 -1
  67. package/package.json +1 -1
  68. package/src/components/batch-edit/__test__/batch-edit.test.ts +112 -1
  69. package/src/components/batch-edit/batch-edit-form.vue +21 -2
  70. package/src/components/batch-edit/use-handlers.ts +6 -5
  71. package/src/components/batch-edit/use-shortcut-key.ts +5 -2
  72. package/src/components/card/style.scss +0 -2
  73. package/src/components/condition-editor/__test__/condition-editor.test.ts +61 -0
  74. package/src/components/condition-editor/condition-editor.vue +6 -1
  75. package/src/components/dropdown/dropdown.vue +9 -4
  76. package/src/components/expression-editor/__test__/editor.test.ts +66 -0
  77. package/src/components/expression-editor/__test__/expression-editor.test.ts +124 -0
  78. package/src/components/expression-editor/components/path-preview.vue +1 -2
  79. package/src/components/expression-editor/components/variable-picker.vue +54 -13
  80. package/src/components/expression-editor/core/editor.ts +61 -0
  81. package/src/components/expression-editor/expression-editor.vue +58 -20
  82. package/src/components/expression-editor/style.scss +160 -76
  83. package/src/components/form/form.vue +0 -1
  84. package/src/components/form/style.scss +11 -4
  85. package/src/components/form-item/style.scss +16 -1
  86. package/src/components/index.ts +2 -0
  87. package/src/components/kanban/__test__/kanban.test.ts +212 -0
  88. package/src/components/kanban/di.ts +19 -0
  89. package/src/components/kanban/index.ts +1 -0
  90. package/src/components/kanban/kanban-column.vue +77 -0
  91. package/src/components/kanban/kanban.vue +81 -0
  92. package/src/components/kanban/style.scss +124 -0
  93. package/src/components/kanban/style.ts +1 -0
  94. package/src/components/multi-select/multi-select.vue +11 -2
  95. package/src/components/multi-select/style.scss +6 -0
  96. package/src/components/multi-tree-select/multi-tree-select.vue +58 -9
  97. package/src/components/multi-tree-select/style.scss +6 -0
  98. package/src/components/rich-text-editor/image-node.ts +193 -0
  99. package/src/components/rich-text-editor/rich-text-editor.vue +131 -29
  100. package/src/components/rich-text-editor/style.scss +7 -0
  101. package/src/components/rich-text-editor/toolbar.vue +21 -1
  102. package/src/components/select/__test__/select.test.ts +78 -0
  103. package/src/components/select/select.vue +13 -3
  104. package/src/components/select/style.scss +6 -0
  105. package/src/components/select/use-options.ts +25 -4
  106. package/src/components/tree-select/__test__/tree-select.test.ts +116 -0
  107. package/src/components/tree-select/style.scss +6 -0
  108. package/src/components/tree-select/tree-select.vue +63 -13
  109. package/src/types/dropdown.ts +10 -0
  110. package/src/types/index.ts +1 -0
  111. package/src/types/kanban.ts +63 -0
  112. package/src/types/multi-tree-select.ts +12 -1
  113. package/src/types/rich-text-editor.ts +14 -1
  114. 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>(), { fields: () => [] })
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>(), { 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
 
@@ -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="{ 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
@@ -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)