@veltra/desktop 1.8.0 → 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 (56) hide show
  1. package/dist/components/condition-editor/condition-editor.js +8 -2
  2. package/dist/components/condition-editor/condition-editor.js.map +1 -1
  3. package/dist/components/expression-editor/core/editor.js +22 -0
  4. package/dist/components/expression-editor/core/editor.js.map +1 -1
  5. package/dist/components/expression-editor/expression-editor.js +17 -6
  6. package/dist/components/expression-editor/expression-editor.js.map +1 -1
  7. package/dist/components/expression-editor/style2.css +2 -2
  8. package/dist/components/form-item/style2.css +14 -0
  9. package/dist/components/index.d.ts +2 -1
  10. package/dist/components/index.js +3 -1
  11. package/dist/components/index.js.map +1 -1
  12. package/dist/components/kanban/di.js +7 -0
  13. package/dist/components/kanban/di.js.map +1 -0
  14. package/dist/components/kanban/kanban-column.js +67 -0
  15. package/dist/components/kanban/kanban-column.js.map +1 -0
  16. package/dist/components/kanban/kanban.js +89 -0
  17. package/dist/components/kanban/kanban.js.map +1 -0
  18. package/dist/components/kanban/style.d.ts +1 -0
  19. package/dist/components/kanban/style.js +1 -0
  20. package/dist/components/kanban/style2.css +128 -0
  21. package/dist/components/rich-text-editor/image-node.js +154 -0
  22. package/dist/components/rich-text-editor/image-node.js.map +1 -0
  23. package/dist/components/rich-text-editor/rich-text-editor.js +80 -22
  24. package/dist/components/rich-text-editor/rich-text-editor.js.map +1 -1
  25. package/dist/components/rich-text-editor/style2.css +6 -0
  26. package/dist/components/rich-text-editor/toolbar.js +22 -2
  27. package/dist/components/rich-text-editor/toolbar.js.map +1 -1
  28. package/dist/index.d.ts +48 -46
  29. package/dist/index.js +46 -45
  30. package/dist/types/index.d.ts +3 -2
  31. package/dist/types/kanban.d.ts +69 -0
  32. package/dist/types/rich-text-editor.d.ts +13 -2
  33. package/package.json +1 -1
  34. package/src/components/condition-editor/__test__/condition-editor.test.ts +61 -0
  35. package/src/components/condition-editor/condition-editor.vue +6 -1
  36. package/src/components/expression-editor/__test__/editor.test.ts +66 -0
  37. package/src/components/expression-editor/__test__/expression-editor.test.ts +124 -0
  38. package/src/components/expression-editor/core/editor.ts +33 -0
  39. package/src/components/expression-editor/expression-editor.vue +13 -5
  40. package/src/components/expression-editor/style.scss +8 -9
  41. package/src/components/form-item/style.scss +16 -1
  42. package/src/components/index.ts +2 -0
  43. package/src/components/kanban/__test__/kanban.test.ts +212 -0
  44. package/src/components/kanban/di.ts +19 -0
  45. package/src/components/kanban/index.ts +1 -0
  46. package/src/components/kanban/kanban-column.vue +77 -0
  47. package/src/components/kanban/kanban.vue +81 -0
  48. package/src/components/kanban/style.scss +124 -0
  49. package/src/components/kanban/style.ts +1 -0
  50. package/src/components/rich-text-editor/image-node.ts +193 -0
  51. package/src/components/rich-text-editor/rich-text-editor.vue +131 -29
  52. package/src/components/rich-text-editor/style.scss +7 -0
  53. package/src/components/rich-text-editor/toolbar.vue +21 -1
  54. package/src/types/index.ts +1 -0
  55. package/src/types/kanban.ts +63 -0
  56. package/src/types/rich-text-editor.ts +14 -1
@@ -0,0 +1,69 @@
1
+ import { DeconstructValue } from "@veltra/utils";
2
+ //#region src/types/kanban.d.ts
3
+ /** 看板列数据 */
4
+ export interface KanbanColumnItem extends Record<string, any> {
5
+ /** 列标识(必填) */
6
+ key: string;
7
+ /** 列标题 */
8
+ title?: string;
9
+ /** 卡片数据(顺序即展示顺序,拖拽后更新) */
10
+ items: Record<string, any>[];
11
+ }
12
+ /** 看板组件属性 */
13
+ export interface KanbanProps {
14
+ /** 列数据(含每列卡片),拖拽结果经 update:columns 写回 */
15
+ columns?: KanbanColumnItem[];
16
+ /**
17
+ * 卡片唯一标识字段名
18
+ * @default 'id'
19
+ */
20
+ cardKey?: string;
21
+ /**
22
+ * 列标题与默认卡片内容的字段名
23
+ * @default 'title'
24
+ */
25
+ titleKey?: string;
26
+ /** 是否禁用拖拽 */
27
+ disabled?: boolean;
28
+ /**
29
+ * 列头是否显示卡片计数徽标
30
+ * @default true
31
+ */
32
+ countable?: boolean;
33
+ /**
34
+ * 空列占位文案(空列仍可作为拖放目标)
35
+ * @default '暂无内容'
36
+ */
37
+ placeholder?: string;
38
+ }
39
+ /** 看板组件事件 */
40
+ export interface KanbanEmits {
41
+ /** 列数据更新(v-model:columns) */
42
+ (e: 'update:columns', columns: KanbanColumnItem[]): void;
43
+ /** 拖拽结束后触发(含列内排序与跨列转移),返回最新列数据 */
44
+ (e: 'change', columns: KanbanColumnItem[]): void;
45
+ }
46
+ /** 看板插槽 */
47
+ export type KanbanSlots = {
48
+ /** 自定义卡片内容 */
49
+ card?: (props: {
50
+ card: Record<string, any>;
51
+ column: KanbanColumnItem;
52
+ index: number;
53
+ }) => any;
54
+ /** 自定义列头 */
55
+ header?: (props: {
56
+ column: KanbanColumnItem;
57
+ count: number;
58
+ }) => any;
59
+ /** 空列占位 */
60
+ empty?: (props: {
61
+ column: KanbanColumnItem;
62
+ }) => any;
63
+ };
64
+ /** 看板组件暴露的属性和方法(组件内部使用) */
65
+ export interface _KanbanExposed {}
66
+ /** 看板组件暴露的属性和方法(组件外部使用, 引用的值会被自动解构) */
67
+ export type KanbanExposed = DeconstructValue<_KanbanExposed>;
68
+ //#endregion
69
+ //# sourceMappingURL=kanban.d.ts.map
@@ -2,8 +2,10 @@ import { DeconstructValue, FormComponentProps } from "@veltra/utils";
2
2
  //#region src/types/rich-text-editor.d.ts
3
3
  /** 富文本数据格式 */
4
4
  export type RichTextFormat = 'html' | 'json';
5
+ /** 图片上传函数:接收文件,返回上传后的服务器地址 */
6
+ export type RichTextImageUploader = (file: File) => Promise<string>;
5
7
  /** 工具栏项 */
6
- export type ToolbarItem = 'bold' | 'italic' | 'underline' | 'strikethrough' | 'code' | 'heading' | 'bullet-list' | 'ordered-list' | 'blockquote' | 'code-block' | 'link' | 'undo' | 'redo' | '|';
8
+ export type ToolbarItem = 'bold' | 'italic' | 'underline' | 'strikethrough' | 'code' | 'heading' | 'bullet-list' | 'ordered-list' | 'blockquote' | 'code-block' | 'link' | 'image' | 'undo' | 'redo' | '|';
7
9
  /** 富文本编辑器组件属性 */
8
10
  export interface RichTextEditorProps extends FormComponentProps {
9
11
  modelValue?: string;
@@ -13,13 +15,22 @@ export interface RichTextEditorProps extends FormComponentProps {
13
15
  toolbar?: ToolbarItem[];
14
16
  /** 占位文本 */
15
17
  placeholder?: string;
18
+ /** 是否允许图片输入(粘贴 / 拖拽 / 工具栏),默认 true */
19
+ image?: boolean;
16
20
  }
17
21
  /** 富文本编辑器组件定义的事件 */
18
22
  export interface RichTextEditorEmits {
19
23
  (e: 'update:modelValue', value: string): void;
20
24
  }
21
25
  /** 富文本编辑器组件暴露的属性和方法(组件内部使用) */
22
- export interface _RichTextEditorExposed {}
26
+ export interface _RichTextEditorExposed {
27
+ /**
28
+ * 上传编辑器内所有待上传图片:对每张图片调用 upload 获取服务器地址并替换节点,
29
+ * 返回替换后的最终内容(格式遵循 format 属性)。
30
+ * 任一上传失败则整体拒绝,图片保持待上传状态可整体重试;已上传的不会重复上传
31
+ */
32
+ uploadImages: (upload: RichTextImageUploader) => Promise<string>;
33
+ }
23
34
  /** 富文本编辑器组件暴露的属性和方法(组件外部使用, 引用的值会被自动解构) */
24
35
  export type RichTextEditorExposed = DeconstructValue<_RichTextEditorExposed>;
25
36
  //#endregion
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@veltra/desktop",
3
- "version": "1.8.0",
3
+ "version": "1.8.1",
4
4
  "homepage": "https://github.com/cabinet-fe/ultra-ui#readme",
5
5
  "bugs": {
6
6
  "url": "https://github.com/cabinet-fe/ultra-ui/issues"
@@ -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
 
@@ -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
+ })
@@ -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 的主体(重选) */
@@ -66,12 +71,29 @@ export interface EditorAPI {
66
71
  export function createEditor(opts: EditorOptions): EditorAPI {
67
72
  let doc: Doc = normalize(opts.initialDoc)
68
73
  let composing = false
74
+ /** null 表示还没算过,首次计算后必定回调一次 */
75
+ let visualEmpty: boolean | null = null
69
76
 
70
77
  function emitChange(next: Doc) {
71
78
  doc = next
72
79
  opts.onChange?.(doc)
73
80
  }
74
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
+
75
97
  function makeTextSpan(value: string): HTMLSpanElement {
76
98
  const span = document.createElement('span')
77
99
  span.setAttribute(TEXT_SEG_ATTR, TEXT_SEG_VALUE)
@@ -136,6 +158,7 @@ export function createEditor(opts: EditorOptions): EditorAPI {
136
158
  else container.appendChild(makeChipSpan(seg))
137
159
  }
138
160
 
161
+ syncVisualEmpty()
139
162
  if (typeof targetCaret === 'number') setCaret(targetCaret)
140
163
  }
141
164
 
@@ -370,13 +393,20 @@ export function createEditor(opts: EditorOptions): EditorAPI {
370
393
  }
371
394
 
372
395
  function onInput() {
396
+ // 合成中模型不动,但框里已经有字:占位层必须立刻让位
397
+ syncVisualEmpty()
373
398
  syncFromDOM()
374
399
  }
375
400
  function onCompositionStart() {
376
401
  composing = true
377
402
  }
403
+ function onCompositionUpdate() {
404
+ // 少数浏览器把合成文字落进 DOM 的时机晚于 input,这里再兜一次
405
+ syncVisualEmpty()
406
+ }
378
407
  function onCompositionEnd() {
379
408
  composing = false
409
+ syncVisualEmpty()
380
410
  syncFromDOM()
381
411
  }
382
412
  function onPaste(e: ClipboardEvent) {
@@ -413,6 +443,7 @@ export function createEditor(opts: EditorOptions): EditorAPI {
413
443
  range.collapse(true)
414
444
  sel.removeAllRanges()
415
445
  sel.addRange(range)
446
+ syncVisualEmpty()
416
447
  syncFromDOM()
417
448
  }
418
449
 
@@ -483,6 +514,7 @@ export function createEditor(opts: EditorOptions): EditorAPI {
483
514
  const container = opts.container
484
515
  container.addEventListener('input', onInput)
485
516
  container.addEventListener('compositionstart', onCompositionStart)
517
+ container.addEventListener('compositionupdate', onCompositionUpdate)
486
518
  container.addEventListener('compositionend', onCompositionEnd)
487
519
  container.addEventListener('paste', onPaste)
488
520
  document.addEventListener('selectionchange', onSelectionChange)
@@ -512,6 +544,7 @@ export function createEditor(opts: EditorOptions): EditorAPI {
512
544
  dispose: () => {
513
545
  container.removeEventListener('input', onInput)
514
546
  container.removeEventListener('compositionstart', onCompositionStart)
547
+ container.removeEventListener('compositionupdate', onCompositionUpdate)
515
548
  container.removeEventListener('compositionend', onCompositionEnd)
516
549
  container.removeEventListener('paste', onPaste)
517
550
  document.removeEventListener('selectionchange', onSelectionChange)
@@ -9,7 +9,7 @@
9
9
  ref="container"
10
10
  :class="cls.e('container')"
11
11
  :contenteditable="!readonly && !disabled"
12
- :data-empty="isEmpty || undefined"
12
+ :data-empty="visualEmpty || undefined"
13
13
  :data-placeholder="props.placeholder"
14
14
  @keydown="onKeydown"
15
15
  @blur="onBlur"
@@ -63,7 +63,10 @@ defineOptions({ name: 'UExpressionEditor' })
63
63
 
64
64
  const props = withDefaults(defineProps<ExpressionEditorProps>(), {
65
65
  placeholder: '请输入表达式,输入 @ 可插入变量',
66
- selectableLevels: 'leaf'
66
+ selectableLevels: 'leaf',
67
+ // Boolean prop 缺省会被 Vue 归一成 false,挡住 UForm 下发的 disabled / readonly,须显式留 undefined
68
+ disabled: undefined,
69
+ readonly: undefined
67
70
  })
68
71
 
69
72
  const emit = defineEmits<{ (e: 'update:modelValue', value: string): void }>()
@@ -91,9 +94,11 @@ const editorRef = shallowRef<EditorAPI | null>(null)
91
94
  const mention: MentionAPI = createMention()
92
95
 
93
96
  const currentDoc = shallowRef<Doc>([])
94
- const isEmpty = computed(() => currentDoc.value.length === 0)
95
- const showPlaceholder = computed(() => isEmpty.value && !disabled.value)
96
- const showHint = computed(() => isEmpty.value && !disabled.value && !readonly.value)
97
+ /** 框内视觉空态:与 IME 合成状态无关,由 editor 在 DOM 变化时上报 */
98
+ const visualEmpty = shallowRef(true)
99
+ const showPlaceholder = computed(() => visualEmpty.value && !disabled.value)
100
+ /** 上方 `@` 使用提示常驻:只跟可编辑状态走,不随内容增减隐藏 */
101
+ const showHint = computed(() => !disabled.value && !readonly.value)
97
102
 
98
103
  const pickerMode = shallowRef<'mention' | 'reselect' | null>(null)
99
104
  const pickerTriggerDom = shallowRef<HTMLElement | undefined>(undefined)
@@ -152,6 +157,9 @@ onMounted(() => {
152
157
  if (value !== (props.modelValue ?? '')) emit('update:modelValue', value)
153
158
  syncMention()
154
159
  },
160
+ onVisualEmptyChange: (empty) => {
161
+ visualEmpty.value = empty
162
+ },
155
163
  onSelectionChange: () => {
156
164
  syncMention()
157
165
  },
@@ -19,16 +19,15 @@ $root-name: expression-editor;
19
19
  border-color fn.use-var(transition, fast) fn.use-var(transition, ease),
20
20
  box-shadow fn.use-var(transition, fast) fn.use-var(transition, ease);
21
21
 
22
- @include m.is-not(disabled, readonly) {
23
- &:hover {
24
- border-color: fn.use-var(color, primary);
25
- }
22
+ // 状态描边压在 0,3,0 以内(与 u-input 同档),表单项错误态才能用普通后代选择器直接压住
23
+ &:hover {
24
+ border-color: fn.use-var(color, primary);
25
+ }
26
26
 
27
- &:focus-within {
28
- border-color: fn.use-var(color, primary);
29
- box-shadow: fn.use-var(focus-ring);
30
- outline: none;
31
- }
27
+ &:focus-within {
28
+ border-color: fn.use-var(color, primary);
29
+ box-shadow: fn.use-var(focus-ring);
30
+ outline: none;
32
31
  }
33
32
 
34
33
  @include m.is('disabled') {
@@ -47,12 +47,12 @@ $root-name: form-item;
47
47
  }
48
48
 
49
49
  @include m.is(error) {
50
+ // is-focus 类驱动聚焦态的控件:hover / is-focus 与错误态同权重,按源序分胜负,故显式列出
50
51
  #{fn.bem(input)},
51
52
  #{fn.bem(multi-select)},
52
53
  #{fn.bem(multi-tree-select)},
53
54
  #{fn.bem(auto-complete)},
54
55
  #{fn.bem(textarea--more)} {
55
- // hover 的 primary 描边与错误态同权重,按源序分胜负:错误态显式压住,避免悬浮时描边回蓝
56
56
  &,
57
57
  &:hover,
58
58
  &.is-focus {
@@ -64,6 +64,21 @@ $root-name: form-item;
64
64
  box-shadow: fn.use-var(focus-ring, danger);
65
65
  }
66
66
  }
67
+
68
+ // :focus-within 驱动聚焦态的编辑器类控件;描红目标是各自的视觉盒(表达式编辑器在 __shell 上)
69
+ #{fn.bem(expression-editor, shell)},
70
+ #{fn.bem(code-editor)},
71
+ #{fn.bem(rich-text-editor)} {
72
+ &,
73
+ &:hover,
74
+ &:focus-within {
75
+ border-color: fn.use-var(color, danger);
76
+ }
77
+
78
+ &:focus-within {
79
+ box-shadow: fn.use-var(focus-ring, danger);
80
+ }
81
+ }
67
82
  }
68
83
 
69
84
  @include m.e(content) {
@@ -153,3 +153,5 @@ export * from './badge'
153
153
  export * from './segment'
154
154
 
155
155
  export * from './image-cropper'
156
+
157
+ export * from './kanban'