@veltra/desktop 1.0.12 → 1.0.14

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 (182) hide show
  1. package/dist/components/action/action-group.js +110 -3
  2. package/dist/components/action/action-group.js.map +1 -1
  3. package/dist/components/action/action-group.vue.d.ts +16 -3
  4. package/dist/components/action/action.js +26 -13
  5. package/dist/components/action/action.js.map +1 -1
  6. package/dist/components/action/action.vue.d.ts +1 -1
  7. package/dist/components/action/style.js +2 -1
  8. package/dist/components/action/style2.css +35 -7
  9. package/dist/components/auto-complete/auto-complete.vue.d.ts +2 -2
  10. package/dist/components/batch-edit/batch-edit-form.js +106 -42
  11. package/dist/components/batch-edit/batch-edit-form.js.map +1 -1
  12. package/dist/components/batch-edit/batch-edit-list.js +84 -47
  13. package/dist/components/batch-edit/batch-edit-list.js.map +1 -1
  14. package/dist/components/batch-edit/batch-edit.js +42 -10
  15. package/dist/components/batch-edit/batch-edit.js.map +1 -1
  16. package/dist/components/batch-edit/style.js +2 -4
  17. package/dist/components/batch-edit/style2.css +202 -30
  18. package/dist/components/batch-edit/use-edit.js +45 -9
  19. package/dist/components/batch-edit/use-edit.js.map +1 -1
  20. package/dist/components/cascade/cascade.js +3 -1
  21. package/dist/components/cascade/cascade.js.map +1 -1
  22. package/dist/components/cascade/cascade.vue.d.ts +3 -3
  23. package/dist/components/checkbox/checkbox-button.vue.d.ts +1 -1
  24. package/dist/components/checkbox-group/checkbox-group.vue.d.ts +2 -2
  25. package/dist/components/code-editor/code-editor.js +145 -49
  26. package/dist/components/code-editor/code-editor.js.map +1 -1
  27. package/dist/components/code-editor/code-editor.vue.d.ts +2 -0
  28. package/dist/components/code-editor/style2.css +87 -19
  29. package/dist/components/collapse/collapse.js.map +1 -1
  30. package/dist/components/collapse/collapse.vue.d.ts +2 -10
  31. package/dist/components/date-picker/date-picker.vue.d.ts +2 -2
  32. package/dist/components/date-range-picker/date-range-picker.vue.d.ts +2 -2
  33. package/dist/components/dialog/dialog.js +6 -2
  34. package/dist/components/dialog/dialog.js.map +1 -1
  35. package/dist/components/dialog/style2.css +2 -3
  36. package/dist/components/drawer/drawer.vue.d.ts +2 -2
  37. package/dist/components/expression-editor/components/path-preview.js +27 -0
  38. package/dist/components/expression-editor/components/path-preview.js.map +1 -0
  39. package/dist/components/expression-editor/components/variable-picker.js +199 -153
  40. package/dist/components/expression-editor/components/variable-picker.js.map +1 -1
  41. package/dist/components/expression-editor/core/chip.js +54 -0
  42. package/dist/components/expression-editor/core/chip.js.map +1 -0
  43. package/dist/components/expression-editor/core/editor.js +424 -0
  44. package/dist/components/expression-editor/core/editor.js.map +1 -0
  45. package/dist/components/expression-editor/core/mention.js +91 -0
  46. package/dist/components/expression-editor/core/mention.js.map +1 -0
  47. package/dist/components/expression-editor/core/model.js +64 -0
  48. package/dist/components/expression-editor/core/model.js.map +1 -0
  49. package/dist/components/expression-editor/di.js +4 -10
  50. package/dist/components/expression-editor/di.js.map +1 -1
  51. package/dist/components/expression-editor/expression-editor.js +168 -114
  52. package/dist/components/expression-editor/expression-editor.js.map +1 -1
  53. package/dist/components/expression-editor/expression-editor.vue.d.ts +2 -1
  54. package/dist/components/expression-editor/style.js +0 -2
  55. package/dist/components/expression-editor/style2.css +145 -219
  56. package/dist/components/index.js +54 -54
  57. package/dist/components/input/input.vue.d.ts +2 -2
  58. package/dist/components/layout/layout.js +12 -11
  59. package/dist/components/layout/layout.js.map +1 -1
  60. package/dist/components/multi-select/multi-select.vue.d.ts +3 -3
  61. package/dist/components/multi-tree-select/multi-tree-select.vue.d.ts +3 -3
  62. package/dist/components/number/number.vue.d.ts +1 -1
  63. package/dist/components/number-input/number-input.js +4 -4
  64. package/dist/components/number-input/number-input.js.map +1 -1
  65. package/dist/components/number-input/number-input.vue.d.ts +2 -2
  66. package/dist/components/number-range-input/number-range-input.vue.d.ts +1 -1
  67. package/dist/components/password-input/password-input.vue.d.ts +1 -1
  68. package/dist/components/progress-nodes/progress-nodes.vue.d.ts +2 -2
  69. package/dist/components/radio-group/radio-group.vue.d.ts +2 -2
  70. package/dist/components/rich-text-editor/rich-text-editor.js +1 -1
  71. package/dist/components/rich-text-editor/rich-text-editor.vue.d.ts +1 -1
  72. package/dist/components/rich-text-editor/toolbar.js +2 -2
  73. package/dist/components/select/select.vue.d.ts +2 -2
  74. package/dist/components/table/use-table.js +16 -0
  75. package/dist/components/table/use-table.js.map +1 -1
  76. package/dist/components/tabs/tabs-horizontal.vue.d.ts +2 -2
  77. package/dist/components/tabs/tabs-vertical.vue.d.ts +2 -2
  78. package/dist/components/tabs/tabs.vue.d.ts +2 -2
  79. package/dist/components/textarea/textarea.vue.d.ts +2 -2
  80. package/dist/components/tree/style2.css +18 -12
  81. package/dist/components/tree/tree-node.js +7 -4
  82. package/dist/components/tree/tree-node.js.map +1 -1
  83. package/dist/components/tree-select/tree-select.vue.d.ts +3 -3
  84. package/dist/index.d.ts +3 -3
  85. package/dist/index.js +83 -83
  86. package/dist/style.js +3 -3
  87. package/dist/types/action.d.ts +38 -4
  88. package/dist/types/cascade.d.ts +2 -3
  89. package/dist/types/code-editor.d.ts +9 -2
  90. package/dist/types/collapse.d.ts +6 -5
  91. package/dist/types/expression-editor.d.ts +9 -1
  92. package/dist/types/index.d.ts +2 -2
  93. package/package.json +11 -12
  94. package/src/components/action/action-group.vue +126 -56
  95. package/src/components/action/action.vue +35 -12
  96. package/src/components/action/style.scss +46 -10
  97. package/src/components/action/style.ts +2 -1
  98. package/src/components/batch-edit/batch-edit-form.vue +121 -45
  99. package/src/components/batch-edit/batch-edit-list.vue +56 -39
  100. package/src/components/batch-edit/batch-edit.vue +57 -6
  101. package/src/components/batch-edit/style.scss +251 -37
  102. package/src/components/batch-edit/style.ts +5 -8
  103. package/src/components/batch-edit/use-edit.ts +58 -11
  104. package/src/components/cascade/cascade.vue +3 -1
  105. package/src/components/code-editor/code-editor.vue +198 -63
  106. package/src/components/code-editor/style.scss +110 -12
  107. package/src/components/collapse/collapse.vue +5 -24
  108. package/src/components/dialog/dialog.vue +6 -2
  109. package/src/components/dialog/style.scss +4 -4
  110. package/src/components/expression-editor/__test__/chip.test.ts +86 -0
  111. package/src/components/expression-editor/__test__/mention.test.ts +122 -0
  112. package/src/components/expression-editor/__test__/model.test.ts +127 -0
  113. package/src/components/expression-editor/components/path-preview.vue +32 -0
  114. package/src/components/expression-editor/components/variable-picker.vue +224 -219
  115. package/src/components/expression-editor/core/chip.ts +101 -0
  116. package/src/components/expression-editor/core/editor.ts +513 -0
  117. package/src/components/expression-editor/core/mention.ts +121 -0
  118. package/src/components/expression-editor/core/model.ts +109 -0
  119. package/src/components/expression-editor/di.ts +4 -32
  120. package/src/components/expression-editor/expression-editor.vue +184 -151
  121. package/src/components/expression-editor/style.scss +148 -220
  122. package/src/components/expression-editor/style.ts +0 -2
  123. package/src/components/layout/layout.vue +17 -19
  124. package/src/components/number-input/number-input.vue +4 -4
  125. package/src/components/table/use-table.ts +16 -0
  126. package/src/components/tree/style.scss +30 -33
  127. package/src/components/tree/tree-node.vue +4 -2
  128. package/src/types/action.ts +42 -4
  129. package/src/types/cascade.ts +4 -3
  130. package/src/types/code-editor.ts +9 -2
  131. package/src/types/collapse.ts +6 -6
  132. package/src/types/expression-editor.ts +9 -0
  133. package/dist/components/action/action-group.vue_vue_type_script_setup_true_lang.js +0 -65
  134. package/dist/components/action/action-group.vue_vue_type_script_setup_true_lang.js.map +0 -1
  135. package/dist/components/expression-editor/constants.js +0 -9
  136. package/dist/components/expression-editor/constants.js.map +0 -1
  137. package/dist/components/expression-editor/internal/editor-runtime/create-runtime.js +0 -47
  138. package/dist/components/expression-editor/internal/editor-runtime/create-runtime.js.map +0 -1
  139. package/dist/components/expression-editor/internal/editor-runtime/model-sync.js +0 -49
  140. package/dist/components/expression-editor/internal/editor-runtime/model-sync.js.map +0 -1
  141. package/dist/components/expression-editor/internal/features/commands/register-command-packs.js +0 -215
  142. package/dist/components/expression-editor/internal/features/commands/register-command-packs.js.map +0 -1
  143. package/dist/components/expression-editor/internal/features/drag-drop/drag-drop-service.js +0 -33
  144. package/dist/components/expression-editor/internal/features/drag-drop/drag-drop-service.js.map +0 -1
  145. package/dist/components/expression-editor/internal/features/insertion/insertion-service.js +0 -33
  146. package/dist/components/expression-editor/internal/features/insertion/insertion-service.js.map +0 -1
  147. package/dist/components/expression-editor/nodes/variable-node.js +0 -94
  148. package/dist/components/expression-editor/nodes/variable-node.js.map +0 -1
  149. package/dist/components/expression-editor/parser.js +0 -32
  150. package/dist/components/expression-editor/parser.js.map +0 -1
  151. package/dist/components/expression-editor/plain-text.js +0 -19
  152. package/dist/components/expression-editor/plain-text.js.map +0 -1
  153. package/dist/components/expression-editor/use-context.js +0 -69
  154. package/dist/components/expression-editor/use-context.js.map +0 -1
  155. package/dist/components/expression-editor/use-decorators.js +0 -27
  156. package/dist/components/expression-editor/use-decorators.js.map +0 -1
  157. package/dist/components/expression-editor/use-expression-drag-drop.js +0 -275
  158. package/dist/components/expression-editor/use-expression-drag-drop.js.map +0 -1
  159. package/src/components/batch-edit/batch-edit-new.vue +0 -168
  160. package/src/components/batch-edit/style-new.scss +0 -35
  161. package/src/components/batch-edit/use-edit-new.ts +0 -68
  162. package/src/components/batch-edit/use-tip.ts +0 -47
  163. package/src/components/expression-editor/__test__/architecture-boundaries.test.ts +0 -103
  164. package/src/components/expression-editor/__test__/architecture-compatibility.test.ts +0 -52
  165. package/src/components/expression-editor/__test__/drag-drop.test.ts +0 -171
  166. package/src/components/expression-editor/__test__/parser.test.ts +0 -62
  167. package/src/components/expression-editor/constants.ts +0 -6
  168. package/src/components/expression-editor/internal/contracts/editor-runtime.ts +0 -11
  169. package/src/components/expression-editor/internal/editor-runtime/create-runtime.ts +0 -64
  170. package/src/components/expression-editor/internal/editor-runtime/index.ts +0 -1
  171. package/src/components/expression-editor/internal/editor-runtime/model-sync.ts +0 -68
  172. package/src/components/expression-editor/internal/features/commands/register-command-packs.ts +0 -517
  173. package/src/components/expression-editor/internal/features/drag-drop/drag-drop-service.ts +0 -51
  174. package/src/components/expression-editor/internal/features/insertion/insertion-service.ts +0 -56
  175. package/src/components/expression-editor/nodes/variable-node.tsx +0 -118
  176. package/src/components/expression-editor/parser.ts +0 -46
  177. package/src/components/expression-editor/plain-text.ts +0 -25
  178. package/src/components/expression-editor/plan.md +0 -22
  179. package/src/components/expression-editor/use-context.ts +0 -134
  180. package/src/components/expression-editor/use-decorators.tsx +0 -29
  181. package/src/components/expression-editor/use-editor.ts +0 -12
  182. package/src/components/expression-editor/use-expression-drag-drop.ts +0 -396
@@ -0,0 +1,513 @@
1
+ import type { BEM } from '@veltra/utils'
2
+
3
+ import type { VariableItem } from '../../../types'
4
+ import { createChip, isChipElement, readChipType, readChipValue } from './chip'
5
+ import { normalize, parse, serialize, type Doc, type Segment } from './model'
6
+
7
+ const TEXT_SEG_ATTR = 'data-seg'
8
+ const TEXT_SEG_VALUE = 'text'
9
+
10
+ export interface EditorVariableInfo {
11
+ value: string
12
+ label: string
13
+ type?: string
14
+ }
15
+
16
+ export interface EditorOptions {
17
+ container: HTMLElement
18
+ cls: BEM<'expression-editor'>
19
+ initialDoc: Doc
20
+ /**
21
+ * 在 DOM → 模型反推、字符串 → 模型反序列化时为 chip 补齐 label / type。
22
+ * 用 getter 形式以便外层 `variables` props 变化时拿到最新映射。
23
+ */
24
+ getVariableMap?: () => Map<string, VariableItem>
25
+ /** 模型每次更新(包括用户键入文本)后回调 */
26
+ onChange?: (doc: Doc) => void
27
+ /** 选中区域 / 光标偏移变化时回调,offset 为序列化字符串中的位置;无选中或不在编辑器内时为 null */
28
+ onSelectionChange?: (info: { offset: number | null }) => void
29
+ /** 用户点击某个 chip 的主体(重选) */
30
+ onChipReselect?: (info: { chipEl: HTMLElement; segIndex: number; chipOffset: number }) => void
31
+ /** 用户点击某个 chip 的 ×(删除) */
32
+ onChipRemove?: (info: { chipEl: HTMLElement; segIndex: number }) => void
33
+ }
34
+
35
+ export interface EditorAPI {
36
+ /** 当前模型 */
37
+ getDoc(): Doc
38
+ /** 完全替换模型(会重渲染 DOM) */
39
+ setDoc(doc: Doc): void
40
+ /** 序列化为字符串(modelValue 形态) */
41
+ getValue(): string
42
+ /** 反序列化字符串为模型并替换(可选传入 variableMap 以补齐 label/type) */
43
+ setValue(value: string): void
44
+ /** 当前光标偏移(无 selection 返回 null) */
45
+ getCaretOffset(): number | null
46
+ /** 把光标设置到指定偏移(不会修改模型) */
47
+ setCaretOffset(offset: number): void
48
+ /**
49
+ * 用一个 var chip 替换 [start, end) 区间。
50
+ * 调用方通常在 mention 选中时用:start = `@` 起点、end = 当前光标。
51
+ */
52
+ replaceRangeWithVar(start: number, end: number, variable: EditorVariableInfo): void
53
+ /** 移除指定段下标对应的 var chip(必须是 var 段)。 */
54
+ removeVarAt(segIndex: number): void
55
+ /** 替换指定段下标对应的 var chip 为新变量(必须是 var 段)。 */
56
+ replaceVarAt(segIndex: number, variable: EditorVariableInfo): void
57
+ /** 卸载所有事件监听(不会销毁 container DOM) */
58
+ dispose(): void
59
+ /** 强制根据当前模型重渲染 DOM(光标位置尽量保留) */
60
+ rerender(): void
61
+ /** 容器引用(只读) */
62
+ readonly container: HTMLElement
63
+ }
64
+
65
+ export function createEditor(opts: EditorOptions): EditorAPI {
66
+ let doc: Doc = normalize(opts.initialDoc)
67
+ let composing = false
68
+
69
+ function emitChange(next: Doc) {
70
+ doc = next
71
+ opts.onChange?.(doc)
72
+ }
73
+
74
+ function makeTextSpan(value: string): HTMLSpanElement {
75
+ const span = document.createElement('span')
76
+ span.setAttribute(TEXT_SEG_ATTR, TEXT_SEG_VALUE)
77
+ span.textContent = value
78
+ return span
79
+ }
80
+
81
+ function makeChipSpan(seg: Extract<Segment, { kind: 'var' }>): HTMLElement {
82
+ return createChip(seg, {
83
+ cls: opts.cls,
84
+ onReselect: (chipEl) => {
85
+ const { segIndex, chipOffset } = locateChip(chipEl)
86
+ if (segIndex < 0) return
87
+ 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
+ }
94
+ })
95
+ }
96
+
97
+ function locateChip(chipEl: HTMLElement): { segIndex: number; chipOffset: number } {
98
+ let chipOffset = 0
99
+ let segIdx = -1
100
+ let i = 0
101
+ for (const child of Array.from(opts.container.childNodes)) {
102
+ if (child === chipEl) {
103
+ segIdx = i
104
+ return { segIndex: segIdx, chipOffset }
105
+ }
106
+ chipOffset += childSerialLength(child)
107
+ i++
108
+ }
109
+ return { segIndex: -1, chipOffset: -1 }
110
+ }
111
+
112
+ function childSerialLength(node: Node): number {
113
+ if (isChipElement(node)) return readChipValue(node).length + 2
114
+ return (node.textContent ?? '').length
115
+ }
116
+
117
+ /** 渲染 doc 到 container:每个 var 两侧都补一个 text span(即便空),保证光标可停靠。 */
118
+ function render(targetCaret?: number) {
119
+ const container = opts.container
120
+ container.innerHTML = ''
121
+
122
+ // 在边界与每两个 var 之间确保有 text span 占位
123
+ const expanded: Segment[] = []
124
+ let lastWasVar = false
125
+ let first = true
126
+ for (const seg of doc) {
127
+ if (first) {
128
+ if (seg.kind === 'var') expanded.push({ kind: 'text', value: '' })
129
+ first = false
130
+ } else if (lastWasVar && seg.kind === 'var') {
131
+ expanded.push({ kind: 'text', value: '' })
132
+ }
133
+ expanded.push(seg)
134
+ lastWasVar = seg.kind === 'var'
135
+ }
136
+ if (lastWasVar || expanded.length === 0) expanded.push({ kind: 'text', value: '' })
137
+
138
+ for (const seg of expanded) {
139
+ if (seg.kind === 'text') container.appendChild(makeTextSpan(seg.value))
140
+ else container.appendChild(makeChipSpan(seg))
141
+ }
142
+
143
+ if (typeof targetCaret === 'number') setCaret(targetCaret)
144
+ }
145
+
146
+ /** 从 DOM 反推模型:宽容裸 text node、空 text span。 */
147
+ function readDocFromDOM(): Doc {
148
+ const segs: Segment[] = []
149
+ for (const child of Array.from(opts.container.childNodes)) {
150
+ if (isChipElement(child)) {
151
+ const value = readChipValue(child)
152
+ const domType = readChipType(child)
153
+ const fallbackLabel =
154
+ child.querySelector(`.${opts.cls.e('chip-label')}`)?.textContent ?? value
155
+ // 优先从 variableMap 取最新 label / type,保持与外部数据源一致
156
+ const fromMap = opts.getVariableMap?.().get(value)
157
+ const finalType = fromMap?.type ?? domType
158
+ const seg: Segment = finalType
159
+ ? { kind: 'var', value, label: fromMap?.label ?? fallbackLabel, type: finalType }
160
+ : { kind: 'var', value, label: fromMap?.label ?? fallbackLabel }
161
+ segs.push(seg)
162
+ } else {
163
+ segs.push({ kind: 'text', value: child.textContent ?? '' })
164
+ }
165
+ }
166
+ return normalize(segs)
167
+ }
168
+
169
+ /**
170
+ * 计算光标在序列化字符串中的全局偏移。
171
+ * 规则:
172
+ * - 在 text span / 裸 text node 中:父级 / 节点之前的 segments 长度累加 + selection 内偏移
173
+ * - 在 chip 边界:根据 selection 在 chip 节点之前 / 之后判断
174
+ */
175
+ function getCaretOffset(): number | null {
176
+ const sel = document.getSelection()
177
+ if (!sel || sel.rangeCount === 0) return null
178
+ const range = sel.getRangeAt(0)
179
+ if (!opts.container.contains(range.startContainer)) return null
180
+
181
+ let offset = 0
182
+ let found = false
183
+ for (const child of Array.from(opts.container.childNodes)) {
184
+ if (found) break
185
+ if (isChipElement(child)) {
186
+ if (range.startContainer === child) {
187
+ // selection.startOffset 表示子节点索引:0 表示 chip 起始,>=1 表示 chip 之后
188
+ if (range.startOffset === 0) return offset
189
+ return offset + childSerialLength(child)
190
+ }
191
+ if (range.startContainer === opts.container && getChildIndex(child) === range.startOffset) {
192
+ // 容器层 selection:偏移即子节点索引
193
+ return offset
194
+ }
195
+ offset += childSerialLength(child)
196
+ } else {
197
+ // text span 或裸 text 节点
198
+ if (
199
+ range.startContainer === child ||
200
+ (child.contains(range.startContainer) && child !== opts.container)
201
+ ) {
202
+ // 计算 selection 在该 child textContent 中的偏移
203
+ const localOffset = computeTextOffsetWithin(
204
+ child,
205
+ range.startContainer,
206
+ range.startOffset
207
+ )
208
+ return offset + localOffset
209
+ }
210
+ if (range.startContainer === opts.container && getChildIndex(child) === range.startOffset) {
211
+ return offset
212
+ }
213
+ offset += childSerialLength(child)
214
+ }
215
+ }
216
+ if (
217
+ range.startContainer === opts.container &&
218
+ range.startOffset === opts.container.childNodes.length
219
+ ) {
220
+ return offset
221
+ }
222
+ return null
223
+ }
224
+
225
+ function getChildIndex(node: Node): number {
226
+ let i = 0
227
+ let cur: Node | null = node.previousSibling
228
+ while (cur) {
229
+ i++
230
+ cur = cur.previousSibling
231
+ }
232
+ return i
233
+ }
234
+
235
+ function computeTextOffsetWithin(root: Node, target: Node, targetOffset: number): number {
236
+ if (root === target) return targetOffset
237
+ let total = 0
238
+ function walk(n: Node): boolean {
239
+ if (n === target) {
240
+ total += targetOffset
241
+ return true
242
+ }
243
+ if (n.nodeType === 3) {
244
+ total += (n as Text).data.length
245
+ return false
246
+ }
247
+ for (const c of Array.from(n.childNodes)) {
248
+ if (walk(c)) return true
249
+ }
250
+ return false
251
+ }
252
+ walk(root)
253
+ return total
254
+ }
255
+
256
+ /** 把光标放到 offset 处,offset 大于文档长度时落到末尾。 */
257
+ function setCaret(offset: number): void {
258
+ const sel = document.getSelection()
259
+ if (!sel) return
260
+
261
+ let remain = offset
262
+ const children = Array.from(opts.container.childNodes)
263
+ for (let i = 0; i < children.length; i++) {
264
+ const child = children[i]!
265
+ const len = childSerialLength(child)
266
+ if (isChipElement(child)) {
267
+ if (remain <= 0) {
268
+ // 光标定到 chip 之前 → 落到容器层 child 索引
269
+ placeAt(opts.container, i)
270
+ return
271
+ }
272
+ if (remain < len) {
273
+ // 落在 chip 内部 → 视作 chip 之后
274
+ placeAt(opts.container, i + 1)
275
+ return
276
+ }
277
+ remain -= len
278
+ } else {
279
+ if (remain <= len) {
280
+ // 落在该 text span 内
281
+ placeWithinText(child, remain)
282
+ return
283
+ }
284
+ remain -= len
285
+ }
286
+ }
287
+ // 落到末尾
288
+ if (children.length === 0) {
289
+ placeAt(opts.container, 0)
290
+ return
291
+ }
292
+ const last = children[children.length - 1]!
293
+ if (isChipElement(last)) placeAt(opts.container, children.length)
294
+ else placeWithinText(last, (last.textContent ?? '').length)
295
+ }
296
+
297
+ function placeAt(parent: Node, index: number) {
298
+ const sel = document.getSelection()
299
+ if (!sel) return
300
+ const range = document.createRange()
301
+ range.setStart(parent, index)
302
+ range.collapse(true)
303
+ sel.removeAllRanges()
304
+ sel.addRange(range)
305
+ }
306
+
307
+ function placeWithinText(span: Node, offset: number): void {
308
+ const sel = document.getSelection()
309
+ if (!sel) return
310
+ const target = firstTextDescendant(span)
311
+ const range = document.createRange()
312
+ if (target) {
313
+ const max = target.data.length
314
+ range.setStart(target, Math.min(offset, max))
315
+ } else {
316
+ // 空 span:让光标落到 span 内(child index 0)
317
+ range.setStart(span, 0)
318
+ }
319
+ range.collapse(true)
320
+ sel.removeAllRanges()
321
+ sel.addRange(range)
322
+ }
323
+
324
+ function firstTextDescendant(root: Node): Text | null {
325
+ if (root.nodeType === 3) return root as Text
326
+ for (const c of Array.from(root.childNodes)) {
327
+ const t = firstTextDescendant(c)
328
+ if (t) return t
329
+ }
330
+ return null
331
+ }
332
+
333
+ /** input 后:从 DOM 反推 doc,并触发 onChange(如有变化)。 */
334
+ function syncFromDOM(): void {
335
+ if (composing) return
336
+ const next = readDocFromDOM()
337
+ if (!docEqual(next, doc)) emitChange(next)
338
+ }
339
+
340
+ function docEqual(a: Doc, b: Doc): boolean {
341
+ if (a.length !== b.length) return false
342
+ for (let i = 0; i < a.length; i++) {
343
+ const x = a[i]!
344
+ const y = b[i]!
345
+ if (x.kind !== y.kind) return false
346
+ if (x.kind === 'text' && y.kind === 'text' && x.value !== y.value) return false
347
+ if (x.kind === 'var' && y.kind === 'var') {
348
+ if (x.value !== y.value || x.label !== y.label || x.type !== y.type) return false
349
+ }
350
+ }
351
+ return true
352
+ }
353
+
354
+ function onInput() {
355
+ syncFromDOM()
356
+ }
357
+ function onCompositionStart() {
358
+ composing = true
359
+ }
360
+ function onCompositionEnd() {
361
+ composing = false
362
+ syncFromDOM()
363
+ }
364
+ function onPaste(e: ClipboardEvent) {
365
+ e.preventDefault()
366
+ const text = e.clipboardData?.getData('text/plain') ?? ''
367
+ if (!text) return
368
+ insertPlainTextAtCaret(text)
369
+ }
370
+ function onSelectionChange() {
371
+ if (!opts.onSelectionChange) return
372
+ const sel = document.getSelection()
373
+ if (!sel || sel.rangeCount === 0) {
374
+ opts.onSelectionChange({ offset: null })
375
+ return
376
+ }
377
+ const range = sel.getRangeAt(0)
378
+ if (!opts.container.contains(range.startContainer)) {
379
+ opts.onSelectionChange({ offset: null })
380
+ return
381
+ }
382
+ opts.onSelectionChange({ offset: getCaretOffset() })
383
+ }
384
+
385
+ function insertPlainTextAtCaret(text: string) {
386
+ const sel = document.getSelection()
387
+ if (!sel || sel.rangeCount === 0) return
388
+ const range = sel.getRangeAt(0)
389
+ if (!opts.container.contains(range.startContainer)) return
390
+ range.deleteContents()
391
+ const node = document.createTextNode(text)
392
+ range.insertNode(node)
393
+ range.setStartAfter(node)
394
+ range.collapse(true)
395
+ sel.removeAllRanges()
396
+ sel.addRange(range)
397
+ syncFromDOM()
398
+ }
399
+
400
+ // 命令:替换区间为 var chip
401
+ function replaceRangeWithVar(start: number, end: number, variable: EditorVariableInfo) {
402
+ const a = Math.min(start, end)
403
+ const b = Math.max(start, end)
404
+ const left = sliceDoc(doc, 0, a)
405
+ const right = sliceDoc(doc, b, totalLength(doc))
406
+ const inserted: Segment = variable.type
407
+ ? { kind: 'var', value: variable.value, label: variable.label, type: variable.type }
408
+ : { kind: 'var', value: variable.value, label: variable.label }
409
+ const next = normalize([...left, inserted, ...right])
410
+ emitChange(next)
411
+ render(a + variable.value.length + 2) // 光标定到 chip 之后
412
+ }
413
+
414
+ function removeVarAt(segIndex: number) {
415
+ const target = doc[segIndex]
416
+ if (!target || target.kind !== 'var') return
417
+ const before = doc.slice(0, segIndex)
418
+ const after = doc.slice(segIndex + 1)
419
+ const caretOffset = sliceLength(before)
420
+ const next = normalize([...before, ...after])
421
+ emitChange(next)
422
+ render(caretOffset)
423
+ }
424
+
425
+ function replaceVarAt(segIndex: number, variable: EditorVariableInfo) {
426
+ const target = doc[segIndex]
427
+ if (!target || target.kind !== 'var') return
428
+ const next = doc.slice()
429
+ const replaced: Segment = variable.type
430
+ ? { kind: 'var', value: variable.value, label: variable.label, type: variable.type }
431
+ : { kind: 'var', value: variable.value, label: variable.label }
432
+ next[segIndex] = replaced
433
+ const caretOffset = sliceLength(doc.slice(0, segIndex)) + variable.value.length + 2
434
+ emitChange(normalize(next))
435
+ render(caretOffset)
436
+ }
437
+
438
+ // 工具:基于序列化偏移的切片(不会切开 var)
439
+ function sliceDoc(d: Doc, start: number, end: number): Doc {
440
+ const out: Segment[] = []
441
+ let pos = 0
442
+ for (const seg of d) {
443
+ const len = seg.kind === 'text' ? seg.value.length : seg.value.length + 2
444
+ const segStart = pos
445
+ const segEnd = pos + len
446
+ if (segEnd <= start || segStart >= end) {
447
+ pos += len
448
+ continue
449
+ }
450
+ if (seg.kind === 'text') {
451
+ const a = Math.max(0, start - segStart)
452
+ const b = Math.min(len, end - segStart)
453
+ const slice = seg.value.slice(a, b)
454
+ if (slice) out.push({ kind: 'text', value: slice })
455
+ } else {
456
+ // var 是原子:只有当 [start, end) 完整覆盖该 var 时保留;否则抛弃
457
+ if (start <= segStart && end >= segEnd) out.push(seg)
458
+ }
459
+ pos += len
460
+ }
461
+ return out
462
+ }
463
+
464
+ function sliceLength(d: Doc): number {
465
+ let n = 0
466
+ for (const s of d) n += s.kind === 'text' ? s.value.length : s.value.length + 2
467
+ return n
468
+ }
469
+
470
+ function totalLength(d: Doc): number {
471
+ return sliceLength(d)
472
+ }
473
+
474
+ // 绑定监听
475
+ const container = opts.container
476
+ container.addEventListener('input', onInput)
477
+ container.addEventListener('compositionstart', onCompositionStart)
478
+ container.addEventListener('compositionend', onCompositionEnd)
479
+ container.addEventListener('paste', onPaste)
480
+ document.addEventListener('selectionchange', onSelectionChange)
481
+
482
+ // 初次渲染
483
+ render()
484
+
485
+ return {
486
+ getDoc: () => doc,
487
+ setDoc: (next) => {
488
+ doc = normalize(next)
489
+ render()
490
+ opts.onChange?.(doc)
491
+ },
492
+ getValue: () => serialize(doc),
493
+ setValue: (value) => {
494
+ doc = parse(value, opts.getVariableMap?.())
495
+ render()
496
+ opts.onChange?.(doc)
497
+ },
498
+ getCaretOffset,
499
+ setCaretOffset: setCaret,
500
+ replaceRangeWithVar,
501
+ removeVarAt,
502
+ replaceVarAt,
503
+ rerender: () => render(),
504
+ dispose: () => {
505
+ container.removeEventListener('input', onInput)
506
+ container.removeEventListener('compositionstart', onCompositionStart)
507
+ container.removeEventListener('compositionend', onCompositionEnd)
508
+ container.removeEventListener('paste', onPaste)
509
+ document.removeEventListener('selectionchange', onSelectionChange)
510
+ },
511
+ container
512
+ }
513
+ }
@@ -0,0 +1,121 @@
1
+ import type { Doc } from './model'
2
+
3
+ export interface MentionState {
4
+ /** `@` 字符在序列化字符串中的偏移 */
5
+ anchorOffset: number
6
+ /** `@` 之后到当前光标之间的字符 */
7
+ filter: string
8
+ }
9
+
10
+ export interface MentionAPI {
11
+ /**
12
+ * 根据当前 doc + 光标 offset 推导 mention 状态。
13
+ * 返回最新状态(可能为 null = 未激活)。
14
+ */
15
+ update(doc: Doc, caret: number | null): MentionState | null
16
+ /**
17
+ * 主动退出当前 mention(用户按 Esc / 空格 / ←→ / 失焦)。
18
+ * 退出后即便光标仍在合法范围内,也不会自动重新激活,
19
+ * 直到光标离开当前 anchor 区域(如:@ 被删除或光标跨段)后再次出现新的 `@`。
20
+ */
21
+ dismiss(): void
22
+ /** 选中后由调用方调用,清理状态(含 dismissedAnchor),下一个 `@` 可重新激活。 */
23
+ commit(): void
24
+ /** 当前状态(只读) */
25
+ getState(): MentionState | null
26
+ }
27
+
28
+ export function createMention(): MentionAPI {
29
+ let state: MentionState | null = null
30
+ let dismissedAnchor: number | null = null
31
+
32
+ function derive(doc: Doc, caret: number | null): MentionState | null {
33
+ if (caret === null) return null
34
+
35
+ // 找到 caret 落在哪个 text segment 以及段内偏移
36
+ let pos = 0
37
+ let textValue: string | null = null
38
+ let textStart = 0
39
+ let inTextOffset = 0
40
+ for (const seg of doc) {
41
+ const len = seg.kind === 'text' ? seg.value.length : seg.value.length + 2
42
+ if (caret <= pos + len) {
43
+ if (seg.kind === 'text') {
44
+ textValue = seg.value
45
+ textStart = pos
46
+ inTextOffset = caret - pos
47
+ }
48
+ break
49
+ }
50
+ pos += len
51
+ }
52
+ // 边界:caret 位于结尾且没有命中段,allow caret == 文档末尾且最后段是 text
53
+ if (textValue === null) {
54
+ const lastSeg = doc[doc.length - 1]
55
+ if (lastSeg && lastSeg.kind === 'text') {
56
+ const lastStart = totalLengthBefore(doc, doc.length - 1)
57
+ if (caret === lastStart + lastSeg.value.length) {
58
+ textValue = lastSeg.value
59
+ textStart = lastStart
60
+ inTextOffset = lastSeg.value.length
61
+ }
62
+ }
63
+ }
64
+ if (textValue === null) return null
65
+
66
+ // 从 inTextOffset 向前扫描,遇到空白前的最后一个 @ 即为 anchor
67
+ let i = inTextOffset - 1
68
+ while (i >= 0) {
69
+ const ch = textValue[i]
70
+ if (ch === '@') {
71
+ const filter = textValue.slice(i + 1, inTextOffset)
72
+ if (/\s/.test(filter)) return null
73
+ return { anchorOffset: textStart + i, filter }
74
+ }
75
+ if (ch && /\s/.test(ch)) return null
76
+ i--
77
+ }
78
+ return null
79
+ }
80
+
81
+ function totalLengthBefore(doc: Doc, segIndex: number): number {
82
+ let n = 0
83
+ for (let i = 0; i < segIndex; i++) {
84
+ const s = doc[i]!
85
+ n += s.kind === 'text' ? s.value.length : s.value.length + 2
86
+ }
87
+ return n
88
+ }
89
+
90
+ return {
91
+ update(doc, caret) {
92
+ const candidate = derive(doc, caret)
93
+ if (!candidate) {
94
+ // 派生不到合法 mention,dismissedAnchor 失去意义
95
+ dismissedAnchor = null
96
+ state = null
97
+ return state
98
+ }
99
+ if (dismissedAnchor !== null && candidate.anchorOffset === dismissedAnchor) {
100
+ // 仍处于上次主动退出的同一个 anchor 范围内,不重新激活
101
+ state = null
102
+ return state
103
+ }
104
+ // 走到这里说明:(1)首次激活;或(2)anchor 与 dismissed 不同(例如又输入了一个 @)
105
+ dismissedAnchor = null
106
+ state = candidate
107
+ return state
108
+ },
109
+ dismiss() {
110
+ if (state) dismissedAnchor = state.anchorOffset
111
+ state = null
112
+ },
113
+ commit() {
114
+ state = null
115
+ dismissedAnchor = null
116
+ },
117
+ getState() {
118
+ return state
119
+ }
120
+ }
121
+ }