@veltra/desktop 1.2.23 → 1.2.25

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 (75) hide show
  1. package/dist/components/action/action-group.vue.d.ts +2 -2
  2. package/dist/components/action/action.vue.d.ts +1 -1
  3. package/dist/components/batch-edit/batch-edit.vue.d.ts +2 -2
  4. package/dist/components/cascade/cascade.js +5 -3
  5. package/dist/components/cascade/cascade.js.map +1 -1
  6. package/dist/components/cascade/cascade.vue.d.ts +3 -3
  7. package/dist/components/cascade/use-data-map.js +2 -1
  8. package/dist/components/cascade/use-data-map.js.map +1 -1
  9. package/dist/components/checkbox-group/checkbox-group.js +10 -10
  10. package/dist/components/checkbox-group/checkbox-group.js.map +1 -1
  11. package/dist/components/code-editor/basic-setup.js +2 -1
  12. package/dist/components/code-editor/basic-setup.js.map +1 -1
  13. package/dist/components/code-editor/code-editor.js +136 -79
  14. package/dist/components/code-editor/code-editor.js.map +1 -1
  15. package/dist/components/code-editor/code-editor.vue.d.ts +10 -2
  16. package/dist/components/code-editor/shell-extension.js +139 -0
  17. package/dist/components/code-editor/shell-extension.js.map +1 -0
  18. package/dist/components/code-editor/style.js +1 -0
  19. package/dist/components/code-editor/style2.css +15 -11
  20. package/dist/components/empty/empty.vue.d.ts +1 -1
  21. package/dist/components/file-picker/file-picker.vue.d.ts +1 -1
  22. package/dist/components/input/input.vue.d.ts +2 -2
  23. package/dist/components/multi-select/multi-select.js +31 -36
  24. package/dist/components/multi-select/multi-select.js.map +1 -1
  25. package/dist/components/multi-select/multi-select.vue.d.ts +1 -1
  26. package/dist/components/multi-tree-select/multi-tree-select.js +8 -6
  27. package/dist/components/multi-tree-select/multi-tree-select.js.map +1 -1
  28. package/dist/components/multi-tree-select/multi-tree-select.vue.d.ts +4 -4
  29. package/dist/components/number/number.vue.d.ts +1 -1
  30. package/dist/components/number-input/number-input.vue.d.ts +2 -2
  31. package/dist/components/progress-nodes/progress-nodes.js +6 -4
  32. package/dist/components/progress-nodes/progress-nodes.js.map +1 -1
  33. package/dist/components/radio-group/radio-group.js +8 -6
  34. package/dist/components/radio-group/radio-group.js.map +1 -1
  35. package/dist/components/select/select.js +15 -13
  36. package/dist/components/select/select.js.map +1 -1
  37. package/dist/components/select/use-options.js +7 -4
  38. package/dist/components/select/use-options.js.map +1 -1
  39. package/dist/components/steps/steps.js +3 -2
  40. package/dist/components/steps/steps.js.map +1 -1
  41. package/dist/components/textarea/textarea.vue.d.ts +2 -2
  42. package/dist/components/tree/use-check.js +2 -1
  43. package/dist/components/tree/use-check.js.map +1 -1
  44. package/dist/components/tree/use-select.js +3 -1
  45. package/dist/components/tree/use-select.js.map +1 -1
  46. package/dist/components/tree/use-tree-nodes.js +4 -1
  47. package/dist/components/tree/use-tree-nodes.js.map +1 -1
  48. package/dist/components/tree-select/tree-select.js +6 -4
  49. package/dist/components/tree-select/tree-select.js.map +1 -1
  50. package/dist/components/tree-select/tree-select.vue.d.ts +4 -4
  51. package/dist/node_modules/.bun/@codemirror_commands@6.10.4/node_modules/@codemirror/commands/dist/index.js +12 -1
  52. package/dist/node_modules/.bun/@codemirror_commands@6.10.4/node_modules/@codemirror/commands/dist/index.js.map +1 -1
  53. package/dist/style.js +1 -1
  54. package/dist/types/code-editor.d.ts +13 -2
  55. package/package.json +6 -6
  56. package/src/components/cascade/cascade.vue +6 -3
  57. package/src/components/cascade/use-data-map.ts +3 -2
  58. package/src/components/checkbox-group/checkbox-group.vue +10 -8
  59. package/src/components/code-editor/basic-setup.ts +3 -1
  60. package/src/components/code-editor/code-editor.vue +154 -96
  61. package/src/components/code-editor/shell-extension.ts +199 -0
  62. package/src/components/code-editor/style.scss +20 -13
  63. package/src/components/code-editor/style.ts +1 -0
  64. package/src/components/multi-select/multi-select.vue +31 -30
  65. package/src/components/multi-tree-select/multi-tree-select.vue +5 -2
  66. package/src/components/progress-nodes/progress-nodes.vue +7 -4
  67. package/src/components/radio-group/radio-group.vue +5 -1
  68. package/src/components/select/select.vue +15 -11
  69. package/src/components/select/use-options.ts +7 -4
  70. package/src/components/steps/steps.vue +3 -1
  71. package/src/components/tree/use-check.ts +2 -1
  72. package/src/components/tree/use-select.ts +3 -1
  73. package/src/components/tree/use-tree-nodes.ts +5 -9
  74. package/src/components/tree-select/tree-select.vue +7 -4
  75. package/src/types/code-editor.ts +13 -2
package/dist/style.js CHANGED
@@ -25,9 +25,9 @@ import "./components/card/style.js";
25
25
  import "./components/cascade/style.js";
26
26
  import "./components/check-tag/style.js";
27
27
  import "./components/checkbox-group/style.js";
28
+ import "./components/select/style.js";
28
29
  import "./components/code-editor/style.js";
29
30
  import "./components/collapse/style.js";
30
- import "./components/select/style.js";
31
31
  import "./components/expression-editor/style.js";
32
32
  import "./components/condition-editor/style.js";
33
33
  import "./components/contextmenu/style.js";
@@ -5,8 +5,16 @@ type CodeEditorLang = 'js' | 'sql' | 'java' | 'json';
5
5
  /** 代码编辑器组件属性(不支持 `size`) */
6
6
  interface CodeEditorProps extends Omit<FormComponentProps, 'size'> {
7
7
  modelValue?: string;
8
- /** 定义语言 */
9
- language?: CodeEditorLang;
8
+ /**
9
+ * 可选语言列表。长度大于 1 时在编辑器右上角显示内置语言选择器
10
+ */
11
+ langs?: CodeEditorLang[];
12
+ /** 当前语言,配合 `v-model:lang` 使用 */
13
+ lang?: CodeEditorLang;
14
+ /** 不可编辑的前缀外壳(展示在编辑器内,不计入 v-model) */
15
+ prefix?: string;
16
+ /** 不可编辑的后缀外壳(展示在编辑器内,不计入 v-model) */
17
+ suffix?: string;
10
18
  /** 是否使用暗色主题 */
11
19
  dark?: boolean;
12
20
  /**
@@ -18,6 +26,9 @@ interface CodeEditorProps extends Omit<FormComponentProps, 'size'> {
18
26
  /** 代码编辑器组件定义的事件 */
19
27
  interface CodeEditorEmits {
20
28
  (e: 'update:modelValue', value: string): void;
29
+ (e: 'update:lang', value: CodeEditorLang | undefined): void;
30
+ /** 在编辑器失焦且内容有变更时触发 */
31
+ (e: 'change', value: string): void;
21
32
  }
22
33
  /** 代码编辑器组件暴露的属性和方法(组件内部使用) */
23
34
  interface _CodeEditorExposed {}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@veltra/desktop",
3
- "version": "1.2.23",
3
+ "version": "1.2.25",
4
4
  "files": [
5
5
  "dist",
6
6
  "src"
@@ -57,11 +57,11 @@
57
57
  "@cat-kit/core": ">=1.1.8",
58
58
  "@cat-kit/excel": ">=1.0.0",
59
59
  "@cat-kit/fe": "^1.1.8",
60
- "@veltra/compositions": "1.2.23",
61
- "@veltra/directives": "1.2.23",
62
- "@veltra/icons": "1.2.23",
63
- "@veltra/styles": "1.2.23",
64
- "@veltra/utils": "1.2.23",
60
+ "@veltra/compositions": "1.2.25",
61
+ "@veltra/directives": "1.2.25",
62
+ "@veltra/icons": "1.2.25",
63
+ "@veltra/styles": "1.2.25",
64
+ "@veltra/utils": "1.2.25",
65
65
  "vue": ">=3.5.39"
66
66
  },
67
67
  "devDependencies": {
@@ -108,7 +108,7 @@
108
108
  import { Forest, o } from '@cat-kit/core'
109
109
  import { useFormFallbackProps, useUserAction } from '@veltra/compositions'
110
110
  import { ArrowDown, Close, Search } from '@veltra/icons/normal'
111
- import { bem, FORM_EMPTY_CONTENT } from '@veltra/utils'
111
+ import { bem, fieldKey, FORM_EMPTY_CONTENT } from '@veltra/utils'
112
112
  import { injectFormContext } from '@veltra/utils'
113
113
  import { computed, provide, shallowRef, triggerRef, watch } from 'vue'
114
114
 
@@ -146,6 +146,9 @@ const emit = defineEmits<CascadeEmits>()
146
146
 
147
147
  const cls = bem('cascade')
148
148
 
149
+ const labelKey = computed(() => fieldKey(props.labelKey, 'label'))
150
+ const valueKey = computed(() => fieldKey(props.valueKey, 'value'))
151
+
149
152
  const { formProps } = injectFormContext()
150
153
 
151
154
  const { size, disabled, readonly } = useFormFallbackProps([formProps ?? {}, props], {
@@ -169,8 +172,8 @@ const forest = computed(() => {
169
172
  data,
170
173
  index,
171
174
  depth,
172
- value: o(data).get(props.valueKey) ?? '',
173
- label: o(data).get(props.labelKey) ?? '',
175
+ value: o(data).get(valueKey.value) ?? '',
176
+ label: o(data).get(labelKey.value) ?? '',
174
177
  parent
175
178
  })
176
179
  }
@@ -1,4 +1,5 @@
1
1
  import { Forest, o } from '@cat-kit/core'
2
+ import { fieldKey } from '@veltra/utils'
2
3
  import { shallowRef, triggerRef, watchEffect, type ComputedRef, type ShallowRef } from 'vue'
3
4
 
4
5
  import type { CascadeNode, CascadeProps } from '../../types'
@@ -18,12 +19,12 @@ export function useDataMap(options: DataMapOptions): UseDataMapReturned {
18
19
  const dataMap = shallowRef(new Map<string, CascadeNode>())
19
20
 
20
21
  watchEffect(() => {
21
- const { valueKey } = props
22
+ const valueKey = fieldKey(props.valueKey, 'value')
22
23
 
23
24
  dataMap.value.clear()
24
25
 
25
26
  forest.value.dfs((node) => {
26
- const value = o(node.data).get(valueKey!)
27
+ const value = o(node.data).get(valueKey)
27
28
  if (value !== null && value !== undefined) {
28
29
  dataMap.value.set(value, node)
29
30
  }
@@ -27,8 +27,9 @@
27
27
 
28
28
  <script lang="ts" setup>
29
29
  import { useFormFallbackProps } from '@veltra/compositions'
30
- import { bem, FORM_EMPTY_CONTENT } from '@veltra/utils'
30
+ import { bem, fieldKey, FORM_EMPTY_CONTENT } from '@veltra/utils'
31
31
  import { injectFormContext } from '@veltra/utils'
32
+ import { computed } from 'vue'
32
33
 
33
34
  import type { CheckboxGroupProps, CheckboxGroupEmits } from '../../types'
34
35
  import { UCheckbox } from '../checkbox'
@@ -49,6 +50,9 @@ const model = defineModel<any[]>()
49
50
 
50
51
  const cls = bem('checkbox-group')
51
52
 
53
+ const labelKey = computed(() => fieldKey(props.labelKey, 'label'))
54
+ const valueKey = computed(() => fieldKey(props.valueKey, 'value'))
55
+
52
56
  const { formProps } = injectFormContext()
53
57
 
54
58
  const { size, disabled, readonly } = useFormFallbackProps([formProps ?? {}, props], {
@@ -63,8 +67,7 @@ const { size, disabled, readonly } = useFormFallbackProps([formProps ?? {}, prop
63
67
  * @returns 模型值是否存在于model.value中
64
68
  */
65
69
  const getCheckStatus = (item: Record<string, string | number>): boolean => {
66
- const { valueKey } = props
67
- const value = item[valueKey]
70
+ const value = item[valueKey.value]
68
71
  if (!value || !model.value) return false
69
72
  return model.value.includes(value)
70
73
  }
@@ -75,8 +78,7 @@ const getCheckStatus = (item: Record<string, string | number>): boolean => {
75
78
  * @param item - 更新的项
76
79
  */
77
80
  const handleUpdate = (checked: boolean, item: Record<string, string | number>) => {
78
- const { valueKey } = props
79
- const value = item[valueKey]
81
+ const value = item[valueKey.value]
80
82
  if (!value) return
81
83
  if (checked) {
82
84
  model.value = [...(model.value ?? []), value]
@@ -86,8 +88,8 @@ const handleUpdate = (checked: boolean, item: Record<string, string | number>) =
86
88
  }
87
89
 
88
90
  const getLabel = (value: string | number) => {
89
- const { items, valueKey, labelKey } = props
90
- const item = items.find((item) => item[valueKey] === value)
91
- return item?.[labelKey]
91
+ const { items } = props
92
+ const item = items.find((item) => item[valueKey.value] === value)
93
+ return item?.[labelKey.value]
92
94
  }
93
95
  </script>
@@ -4,7 +4,7 @@ import {
4
4
  closeBracketsKeymap,
5
5
  completionKeymap
6
6
  } from '@codemirror/autocomplete'
7
- import { history, defaultKeymap, historyKeymap } from '@codemirror/commands'
7
+ import { history, defaultKeymap, historyKeymap, indentWithTab } from '@codemirror/commands'
8
8
  import {
9
9
  foldGutter,
10
10
  indentOnInput,
@@ -52,6 +52,8 @@ export const basicSetup: Extension = (() => [
52
52
  highlightActiveLine(),
53
53
  highlightSelectionMatches(),
54
54
  keymap.of([
55
+ // Tab 默认留给浏览器焦点导航;代码编辑器需显式绑定缩进,否则焦点会跳到语言选择等控件
56
+ indentWithTab,
55
57
  ...closeBracketsKeymap,
56
58
  ...defaultKeymap,
57
59
  ...searchKeymap,
@@ -1,16 +1,25 @@
1
1
  <template>
2
2
  <u-scroll :class="className" :style="rootStyle" @keyup.enter.stop>
3
3
  <div ref="container"></div>
4
+
5
+ <u-select
6
+ v-if="showLangSelect"
7
+ :class="cls.e('lang-select')"
8
+ v-model="lang"
9
+ size="small"
10
+ :options="langOptions"
11
+ :clearable="false"
12
+ :disabled="disabled"
13
+ />
4
14
  </u-scroll>
5
15
  </template>
6
16
 
7
17
  <script lang="ts" setup>
8
- import { Compartment, EditorState, type Extension } from '@codemirror/state'
18
+ import { Compartment, EditorState, type Extension, type Text } from '@codemirror/state'
9
19
  import { oneDark } from '@codemirror/theme-one-dark'
10
- import { EditorView, ViewPlugin, tooltips, type ViewUpdate } from '@codemirror/view'
20
+ import { EditorView, tooltips } from '@codemirror/view'
11
21
  import { useFormFallbackProps } from '@veltra/compositions'
12
- import { bem, zIndex } from '@veltra/utils'
13
- import { injectFormContext } from '@veltra/utils'
22
+ import { bem, injectFormContext, zIndex } from '@veltra/utils'
14
23
  import {
15
24
  computed,
16
25
  onBeforeUnmount,
@@ -20,10 +29,20 @@ import {
20
29
  type CSSProperties
21
30
  } from 'vue'
22
31
 
23
- import type { CodeEditorProps } from '../../types'
32
+ import type { CodeEditorEmits, CodeEditorLang, CodeEditorProps } from '../../types'
24
33
  import { UScroll } from '../scroll'
34
+ import { USelect } from '../select'
25
35
  import { basicSetup } from './basic-setup'
26
36
  import { loadLanguage } from './lang-loaders'
37
+ import {
38
+ buildFullDoc,
39
+ extractBodyFromText,
40
+ getShellRanges,
41
+ mapSelectionToBody,
42
+ shellExtension,
43
+ textEqualsShellDoc,
44
+ type ShellConfig
45
+ } from './shell-extension'
27
46
 
28
47
  defineOptions({ name: 'UCodeEditor' })
29
48
 
@@ -34,7 +53,10 @@ const props = withDefaults(defineProps<CodeEditorProps>(), {
34
53
  defaultLines: 8
35
54
  })
36
55
 
56
+ const emit = defineEmits<CodeEditorEmits>()
57
+
37
58
  const model = defineModel<string>()
59
+ const lang = defineModel<CodeEditorLang>('lang')
38
60
 
39
61
  const cls = bem('code-editor')
40
62
 
@@ -56,6 +78,22 @@ const rootStyle = computed<CSSProperties>(() => ({
56
78
  '--u-code-editor-default-lines': Math.max(1, props.defaultLines)
57
79
  }))
58
80
 
81
+ const langs = computed(() => props.langs ?? [])
82
+
83
+ const showLangSelect = computed(() => langs.value.length > 1)
84
+
85
+ const langOptions = computed(() =>
86
+ langs.value.map((value) => ({ label: value.toUpperCase(), value }))
87
+ )
88
+
89
+ /** 实际生效的语言:优先 lang model,否则回落 langs[0] */
90
+ const activeLang = computed<CodeEditorLang | undefined>(() => lang.value ?? langs.value[0])
91
+
92
+ const shellConfig = computed<ShellConfig>(() => ({
93
+ prefix: props.prefix ?? '',
94
+ suffix: props.suffix ?? ''
95
+ }))
96
+
59
97
  const containerRef = useTemplateRef('container')
60
98
  const editor = shallowRef<EditorView | null>(null)
61
99
 
@@ -63,77 +101,28 @@ const themeCompartment = new Compartment()
63
101
  const editableCompartment = new Compartment()
64
102
  const readOnlyCompartment = new Compartment()
65
103
  const langCompartment = new Compartment()
104
+ const shellCompartment = new Compartment()
66
105
 
67
- let phantomGutter: HTMLElement | null = null
68
- let phantomContainer: HTMLElement | null = null
69
- let lastPhantomKey = ''
70
-
71
- function resetPhantomCache() {
72
- phantomGutter = null
73
- phantomContainer = null
74
- lastPhantomKey = ''
75
- }
106
+ /** 聚焦时的正文快照,用于失焦时判断是否触发 change */
107
+ let focusSnapshot = ''
76
108
 
77
109
  /**
78
- * 在 .cm-lineNumbers 末尾追加占位行号 DOM,使可视行号数量补齐到 defaultLines。
79
- * CodeMirror 的 lineNumbers() 仅为真实文档行渲染行号,超出部分由本逻辑补全。
110
+ * 编辑器最近一次写出的正文引用。
111
+ * 用于短路 model → doc 同步,避免每次按键对整篇文档做 O(n) 相等比较。
80
112
  */
81
- function syncPhantomLineNumbers() {
82
- const view = editor.value
83
- if (!view) return
84
-
85
- if (!phantomGutter || !phantomGutter.isConnected) {
86
- phantomGutter = view.dom.querySelector<HTMLElement>('.cm-gutter.cm-lineNumbers')
87
- }
88
- if (!phantomGutter) return
89
-
90
- if (!phantomContainer) {
91
- phantomContainer = document.createElement('div')
92
- phantomContainer.className = 'cm-phantom-lines'
93
- }
94
- if (
95
- phantomContainer.parentElement !== phantomGutter ||
96
- phantomContainer !== phantomGutter.lastElementChild
97
- ) {
98
- phantomGutter.appendChild(phantomContainer)
99
- }
113
+ let lastEditorBody: string | undefined
100
114
 
101
- const realLines = view.state.doc.lines
102
- const target = Math.max(0, Math.max(1, props.defaultLines) - realLines)
103
- const lineHeight = view.defaultLineHeight
115
+ /** 上一帧是否处于 IME composition,用于在 composition 结束时补一次 model 同步 */
116
+ let wasComposing = false
104
117
 
105
- const key = `${realLines}|${target}|${lineHeight}`
106
- if (key === lastPhantomKey) return
107
- lastPhantomKey = key
108
-
109
- while (phantomContainer.children.length > target) {
110
- phantomContainer.lastElementChild!.remove()
111
- }
112
- while (phantomContainer.children.length < target) {
113
- const el = document.createElement('div')
114
- el.className = 'cm-gutterElement cm-gutterElement-phantom'
115
- phantomContainer.appendChild(el)
116
- }
117
- for (let i = 0; i < target; i++) {
118
- const el = phantomContainer.children[i] as HTMLElement
119
- const num = String(realLines + i + 1)
120
- if (el.textContent !== num) el.textContent = num
121
- if (lineHeight && el.style.height !== `${lineHeight}px`) {
122
- el.style.height = `${lineHeight}px`
123
- }
124
- }
118
+ /** 将编辑器正文写回 v-model(跳过与上次相同的值,避免多余更新) */
119
+ function writeModelFromDoc(doc: Text) {
120
+ const nextBody = extractBodyFromText(doc, shellConfig.value)
121
+ if (nextBody === lastEditorBody && nextBody === model.value) return
122
+ lastEditorBody = nextBody
123
+ model.value = nextBody
125
124
  }
126
125
 
127
- const phantomLineNumbersPlugin = ViewPlugin.fromClass(
128
- class {
129
- update(update: ViewUpdate) {
130
- if (update.docChanged || update.geometryChanged) {
131
- syncPhantomLineNumbers()
132
- }
133
- }
134
- }
135
- )
136
-
137
126
  function buildExtensions(): Extension[] {
138
127
  return [
139
128
  basicSetup,
@@ -143,7 +132,28 @@ function buildExtensions(): Extension[] {
143
132
  editableCompartment.of(EditorView.editable.of(!disabled.value)),
144
133
  readOnlyCompartment.of(EditorState.readOnly.of(readonly.value)),
145
134
  langCompartment.of([]),
146
- phantomLineNumbersPlugin
135
+ shellCompartment.of(shellExtension(shellConfig.value)),
136
+ EditorView.updateListener.of((update) => {
137
+ const composing = update.view.composing
138
+
139
+ // IME 进行中不写回,避免中间态触发父级重渲染;结束后再统一同步
140
+ if (update.docChanged && !composing) {
141
+ writeModelFromDoc(update.state.doc)
142
+ } else if (wasComposing && !composing) {
143
+ writeModelFromDoc(update.state.doc)
144
+ }
145
+ wasComposing = composing
146
+
147
+ if (!update.focusChanged) return
148
+ if (update.view.hasFocus) {
149
+ focusSnapshot = extractBodyFromText(update.state.doc, shellConfig.value)
150
+ return
151
+ }
152
+ const current = extractBodyFromText(update.state.doc, shellConfig.value)
153
+ if (current !== focusSnapshot) {
154
+ emit('change', current)
155
+ }
156
+ })
147
157
  ]
148
158
  }
149
159
 
@@ -154,7 +164,7 @@ async function applyLanguage() {
154
164
  const view = editor.value
155
165
  if (!view) return
156
166
  const myToken = ++langToken
157
- const { language } = props
167
+ const language = activeLang.value
158
168
 
159
169
  if (!language) {
160
170
  if (myToken !== langToken || editor.value !== view) return
@@ -172,7 +182,8 @@ function destroyEditor() {
172
182
  ++langToken
173
183
  editor.value?.destroy()
174
184
  editor.value = null
175
- resetPhantomCache()
185
+ lastEditorBody = undefined
186
+ wasComposing = false
176
187
  }
177
188
 
178
189
  function createEditor() {
@@ -181,16 +192,17 @@ function createEditor() {
181
192
  const parent = containerRef.value
182
193
  if (!parent) return
183
194
 
195
+ const config = shellConfig.value
196
+ const body = model.value ?? ''
197
+ const doc = buildFullDoc(body, config)
198
+ const { bodyStart } = getShellRanges(doc.length, config)
199
+ lastEditorBody = body
200
+
184
201
  const view = new EditorView({
185
- doc: model.value,
202
+ doc,
203
+ selection: { anchor: bodyStart, head: bodyStart },
186
204
  extensions: buildExtensions(),
187
- parent,
188
- dispatch(tr) {
189
- view.update([tr])
190
- if (tr.docChanged) {
191
- model.value = tr.state.doc.toString()
192
- }
193
- }
205
+ parent
194
206
  })
195
207
 
196
208
  if (myToken !== editorToken) {
@@ -199,10 +211,46 @@ function createEditor() {
199
211
  }
200
212
 
201
213
  editor.value = view
202
- syncPhantomLineNumbers()
203
214
  applyLanguage()
204
215
  }
205
216
 
217
+ /**
218
+ * 将 model 正文同步为完整文档;可选同时 reconfigure shell,
219
+ * 避免 prefix/suffix 变更时「先改扩展、后改文档」导致选区越界。
220
+ */
221
+ function syncDocFromModel(
222
+ view: EditorView,
223
+ body: string,
224
+ config: ShellConfig,
225
+ options?: { reconfigureShell?: boolean; prevShell?: ShellConfig }
226
+ ) {
227
+ const current = view.state.doc
228
+ const docChanged = !textEqualsShellDoc(current, body, config)
229
+ const reconfigureShell = options?.reconfigureShell ?? false
230
+
231
+ if (!docChanged && !reconfigureShell) return
232
+
233
+ const nextDoc = buildFullDoc(body, config)
234
+ const prevShell = options?.prevShell ?? config
235
+ const prevRanges = getShellRanges(current.length, prevShell)
236
+ const nextRanges = getShellRanges(nextDoc.length, config)
237
+ const selection = mapSelectionToBody(view.state.selection, prevRanges, nextRanges, nextDoc.length)
238
+
239
+ view.dispatch({
240
+ ...(docChanged
241
+ ? {
242
+ changes: { from: 0, to: current.length, insert: nextDoc },
243
+ selection,
244
+ // 仅外壳变更时滚入视口;外部 model 回写不应打断用户滚动位置
245
+ ...(reconfigureShell ? { scrollIntoView: true } : {})
246
+ }
247
+ : { selection }),
248
+ ...(reconfigureShell ? { effects: shellCompartment.reconfigure(shellExtension(config)) } : {}),
249
+ // 绕过 shell 的 change/transactionFilter,避免全量替换被拦截或选区被错误钳制
250
+ filter: false
251
+ })
252
+ }
253
+
206
254
  watch(
207
255
  containerRef,
208
256
  (el) => {
@@ -212,7 +260,19 @@ watch(
212
260
  { immediate: true, flush: 'post' }
213
261
  )
214
262
 
215
- watch(() => props.language, applyLanguage)
263
+ watch(activeLang, applyLanguage)
264
+
265
+ /** langs 变化时,若当前 lang 未设或不在列表中则纠正为 langs[0] */
266
+ watch(
267
+ langs,
268
+ (list) => {
269
+ if (!list.length) return
270
+ if (!lang.value || !list.includes(lang.value)) {
271
+ lang.value = list[0]
272
+ }
273
+ },
274
+ { immediate: true }
275
+ )
216
276
 
217
277
  watch(
218
278
  () => props.dark,
@@ -229,25 +289,23 @@ watch(readonly, (v) => {
229
289
  editor.value?.dispatch({ effects: readOnlyCompartment.reconfigure(EditorState.readOnly.of(v)) })
230
290
  })
231
291
 
232
- watch(() => props.defaultLines, syncPhantomLineNumbers)
233
-
234
292
  watch(
235
- [() => props.modelValue, editor],
236
- ([v, e]) => {
293
+ [model, editor, shellConfig],
294
+ ([v, e], [, , prevShell]) => {
237
295
  if (!e) return
296
+ // IME 组字中不接受外部回写,避免打断输入
238
297
  if (e.composing) return
239
- const next = v ?? ''
240
- if (e.state.doc.toString() === next) return
241
-
242
- const valueLength = next.length
243
- const inRange = e.state.selection.ranges.every(
244
- (range) => range.anchor <= valueLength && range.head <= valueLength
245
- )
246
- e.dispatch({
247
- changes: { from: 0, to: e.state.doc.length, insert: next },
248
- selection: inRange ? e.state.selection : { anchor: 0, head: 0 },
249
- scrollIntoView: true
298
+ const config = shellConfig.value
299
+ const shellChanged =
300
+ !prevShell || prevShell.prefix !== config.prefix || prevShell.suffix !== config.suffix
301
+ // 编辑器自身写出的 model 无需回写文档
302
+ if (!shellChanged && v === lastEditorBody) return
303
+ const body = v ?? ''
304
+ syncDocFromModel(e, body, config, {
305
+ reconfigureShell: shellChanged,
306
+ prevShell: prevShell ?? config
250
307
  })
308
+ lastEditorBody = body
251
309
  },
252
310
  { immediate: true }
253
311
  )