@h-ai/ui 0.1.0-alpha.32 → 0.1.0-alpha.34
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.
- package/README.md +5 -5
- package/dist/components/compounds/Combobox.svelte +12 -4
- package/dist/components/compounds/Combobox.svelte.d.ts +4 -1
- package/dist/components/compounds/DatePicker.svelte +19 -4
- package/dist/components/compounds/DatePicker.svelte.d.ts +4 -1
- package/dist/components/compounds/TagInput.svelte +36 -31
- package/dist/components/control-size.d.ts +18 -0
- package/dist/components/control-size.js +18 -0
- package/dist/components/primitives/Input.svelte +4 -25
- package/dist/components/primitives/Select.svelte +3 -9
- package/dist/components/scenes/ai/AiDocumentEditor.svelte +203 -22
- package/dist/components/scenes/ai/document-download.js +19 -2
- package/dist/components/scenes/ai/document-parse.d.ts +25 -2
- package/dist/components/scenes/ai/document-parse.js +137 -11
- package/dist/components/scenes/ai/index.d.ts +1 -1
- package/dist/components/scenes/ai/mermaid-host-sync.d.ts +34 -0
- package/dist/components/scenes/ai/mermaid-host-sync.js +35 -0
- package/dist/components/scenes/ai/mermaid-render.d.ts +5 -0
- package/dist/components/scenes/ai/mermaid-render.js +7 -0
- package/dist/components/scenes/iam/PasswordInput.svelte +4 -15
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -187,11 +187,11 @@ components/
|
|
|
187
187
|
|
|
188
188
|
#### Bits UI headless 交互
|
|
189
189
|
|
|
190
|
-
| 组件 | 描述 | 主要属性
|
|
191
|
-
| ------------ | --------------------------- |
|
|
192
|
-
| `Combobox` | 可搜索下拉选择(单选/多选) | `options`, `value`, `multiple`, `placeholder` |
|
|
193
|
-
| `Calendar` | 独立日历 | `value: DateValue`, `minValue`, `maxValue`
|
|
194
|
-
| `DatePicker` | 日期输入+弹出 | `value: DateValue`, `minValue`, `maxValue`, `error` |
|
|
190
|
+
| 组件 | 描述 | 主要属性 |
|
|
191
|
+
| ------------ | --------------------------- | ----------------------------------------------------------- |
|
|
192
|
+
| `Combobox` | 可搜索下拉选择(单选/多选) | `options`, `value`, `multiple`, `placeholder`, `size` |
|
|
193
|
+
| `Calendar` | 独立日历 | `value: DateValue`, `minValue`, `maxValue` |
|
|
194
|
+
| `DatePicker` | 日期输入+弹出 | `value: DateValue`, `minValue`, `maxValue`, `error`, `size` |
|
|
195
195
|
|
|
196
196
|
> 日期值使用 `@internationalized/date` 的 `DateValue` / `CalendarDate` 类型。
|
|
197
197
|
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
@prop {string | string[]} value - 选中值(双向绑定,单选为 string,多选为 string[])
|
|
9
9
|
@prop {boolean} multiple - 是否多选模式
|
|
10
10
|
@prop {string} placeholder - 输入框占位符
|
|
11
|
+
@prop {Size} size - 输入框尺寸
|
|
11
12
|
@prop {boolean} disabled - 是否禁用
|
|
12
13
|
@prop {string} error - 错误消息
|
|
13
14
|
@prop {string} label - 表单标签
|
|
@@ -33,10 +34,11 @@
|
|
|
33
34
|
/>
|
|
34
35
|
-->
|
|
35
36
|
<script lang='ts'>
|
|
36
|
-
import type { DataAttributes } from '../../types.js'
|
|
37
|
+
import type { DataAttributes, Size } from '../../types.js'
|
|
37
38
|
import { Combobox } from 'bits-ui'
|
|
38
39
|
import { uiM } from '../../messages.js'
|
|
39
40
|
import { cn, getDataAttributes } from '../../utils.js'
|
|
41
|
+
import { getFormControlSizeClasses } from '../control-size.js'
|
|
40
42
|
import BareButton from '../primitives/BareButton.svelte'
|
|
41
43
|
|
|
42
44
|
/** 选项定义 */
|
|
@@ -60,6 +62,8 @@
|
|
|
60
62
|
multiple?: boolean
|
|
61
63
|
/** 输入框占位符 */
|
|
62
64
|
placeholder?: string
|
|
65
|
+
/** 尺寸 */
|
|
66
|
+
size?: Size
|
|
63
67
|
/** 是否禁用 */
|
|
64
68
|
disabled?: boolean
|
|
65
69
|
/** 错误消息 */
|
|
@@ -77,6 +81,7 @@
|
|
|
77
81
|
value = $bindable(),
|
|
78
82
|
multiple = false,
|
|
79
83
|
placeholder = '',
|
|
84
|
+
size = 'md',
|
|
80
85
|
disabled = false,
|
|
81
86
|
error,
|
|
82
87
|
label: fieldLabel,
|
|
@@ -86,6 +91,7 @@
|
|
|
86
91
|
}: Props & DataAttributes = $props()
|
|
87
92
|
|
|
88
93
|
const dataAttributes = $derived(getDataAttributes(restProps))
|
|
94
|
+
const sizeClasses = $derived(getFormControlSizeClasses(size))
|
|
89
95
|
// 为未提供 value 时设置初始默认值(仅在初始化时执行一次)
|
|
90
96
|
// 统一以空字符串初始化,避免在初始化阶段捕获 multiple 的初始值。
|
|
91
97
|
// 多选模式会通过 multiVal 派生为 [] 传给 Combobox.Root。
|
|
@@ -214,7 +220,8 @@
|
|
|
214
220
|
<div class='relative'>
|
|
215
221
|
<div
|
|
216
222
|
class={cn(
|
|
217
|
-
'
|
|
223
|
+
'rounded-lg border bg-base-100 flex flex-wrap gap-1.5 items-center py-1 pl-2.5 pr-9 transition-[border-color,box-shadow] duration-150',
|
|
224
|
+
sizeClasses.minHeight,
|
|
218
225
|
disabled ? 'opacity-50 cursor-not-allowed bg-base-200' : 'cursor-text',
|
|
219
226
|
controlStateClass,
|
|
220
227
|
)}
|
|
@@ -237,7 +244,7 @@
|
|
|
237
244
|
|
|
238
245
|
<Combobox.Input
|
|
239
246
|
bind:ref={inputRef}
|
|
240
|
-
class='flex-1 min-w-20 bg-transparent py-0.5
|
|
247
|
+
class={cn('flex-1 min-w-20 bg-transparent py-0.5 outline-none placeholder:text-base-content/40', sizeClasses.spacing)}
|
|
241
248
|
placeholder={selectedOptions.length === 0 ? placeholder : ''}
|
|
242
249
|
{disabled}
|
|
243
250
|
oninput={handleInput}
|
|
@@ -307,7 +314,8 @@
|
|
|
307
314
|
<Combobox.Input
|
|
308
315
|
bind:ref={inputRef}
|
|
309
316
|
class={cn(
|
|
310
|
-
'
|
|
317
|
+
'w-full rounded-lg border bg-base-100 pr-9 outline-none transition-[border-color,box-shadow] duration-150 placeholder:text-base-content/40',
|
|
318
|
+
sizeClasses.control,
|
|
311
319
|
controlStateClass,
|
|
312
320
|
disabled && 'opacity-50 cursor-not-allowed',
|
|
313
321
|
)}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { DataAttributes } from '../../types.js';
|
|
1
|
+
import type { DataAttributes, Size } from '../../types.js';
|
|
2
2
|
import { Combobox } from 'bits-ui';
|
|
3
3
|
/** 选项定义 */
|
|
4
4
|
interface Option {
|
|
@@ -20,6 +20,8 @@ interface Props {
|
|
|
20
20
|
multiple?: boolean;
|
|
21
21
|
/** 输入框占位符 */
|
|
22
22
|
placeholder?: string;
|
|
23
|
+
/** 尺寸 */
|
|
24
|
+
size?: Size;
|
|
23
25
|
/** 是否禁用 */
|
|
24
26
|
disabled?: boolean;
|
|
25
27
|
/** 错误消息 */
|
|
@@ -42,6 +44,7 @@ type $$ComponentProps = Props & DataAttributes;
|
|
|
42
44
|
* @prop {string | string[]} value - 选中值(双向绑定,单选为 string,多选为 string[])
|
|
43
45
|
* @prop {boolean} multiple - 是否多选模式
|
|
44
46
|
* @prop {string} placeholder - 输入框占位符
|
|
47
|
+
* @prop {Size} size - 输入框尺寸
|
|
45
48
|
* @prop {boolean} disabled - 是否禁用
|
|
46
49
|
* @prop {string} error - 错误消息
|
|
47
50
|
* @prop {string} label - 表单标签
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
@prop {DateValue} minValue - 最小可选日期
|
|
8
8
|
@prop {DateValue} maxValue - 最大可选日期
|
|
9
9
|
@prop {string} placeholder - 输入框占位文本
|
|
10
|
+
@prop {Size} size - 输入框尺寸
|
|
10
11
|
@prop {boolean} disabled - 是否禁用
|
|
11
12
|
@prop {string} error - 错误消息
|
|
12
13
|
@prop {function} onchange - 日期变更回调
|
|
@@ -22,9 +23,10 @@
|
|
|
22
23
|
-->
|
|
23
24
|
<script lang='ts'>
|
|
24
25
|
import type { DateValue } from '@internationalized/date'
|
|
25
|
-
import type { DataAttributes } from '../../types.js'
|
|
26
|
+
import type { DataAttributes, Size } from '../../types.js'
|
|
26
27
|
import { DatePicker as BitsDatePicker } from 'bits-ui'
|
|
27
|
-
import { getDataAttributes } from '../../utils.js'
|
|
28
|
+
import { cn, getDataAttributes } from '../../utils.js'
|
|
29
|
+
import { getFormControlSizeClasses } from '../control-size.js'
|
|
28
30
|
|
|
29
31
|
interface Props {
|
|
30
32
|
/** 选中日期(双向绑定) */
|
|
@@ -39,6 +41,8 @@
|
|
|
39
41
|
isDateUnavailable?: (date: DateValue) => boolean
|
|
40
42
|
/** 是否禁用 */
|
|
41
43
|
disabled?: boolean
|
|
44
|
+
/** 尺寸 */
|
|
45
|
+
size?: Size
|
|
42
46
|
/** 错误消息 */
|
|
43
47
|
error?: string
|
|
44
48
|
/** 自定义类名 */
|
|
@@ -54,6 +58,7 @@
|
|
|
54
58
|
weekStartsOn = 1,
|
|
55
59
|
isDateUnavailable,
|
|
56
60
|
disabled = false,
|
|
61
|
+
size = 'md',
|
|
57
62
|
error,
|
|
58
63
|
class: className = '',
|
|
59
64
|
onchange,
|
|
@@ -61,6 +66,7 @@
|
|
|
61
66
|
}: Props & DataAttributes = $props()
|
|
62
67
|
|
|
63
68
|
const dataAttributes = $derived(getDataAttributes(restProps))
|
|
69
|
+
const sizeClasses = $derived(getFormControlSizeClasses(size))
|
|
64
70
|
</script>
|
|
65
71
|
|
|
66
72
|
<div {...dataAttributes} class='fieldset w-full {className}'>
|
|
@@ -73,12 +79,21 @@
|
|
|
73
79
|
{disabled}
|
|
74
80
|
onValueChange={v => onchange?.(v)}
|
|
75
81
|
>
|
|
76
|
-
<BitsDatePicker.Input
|
|
82
|
+
<BitsDatePicker.Input
|
|
83
|
+
class={cn(
|
|
84
|
+
'flex w-full items-center gap-0.5 rounded-lg border bg-base-100 pr-2 transition-[border-color,box-shadow] duration-150',
|
|
85
|
+
sizeClasses.control,
|
|
86
|
+
error
|
|
87
|
+
? 'border-error/60 focus-within:ring-2 focus-within:ring-error/15'
|
|
88
|
+
: 'border-base-content/15 hover:border-base-content/25 focus-within:border-primary/50 focus-within:ring-2 focus-within:ring-primary/10',
|
|
89
|
+
disabled && 'cursor-not-allowed opacity-50',
|
|
90
|
+
)}
|
|
91
|
+
>
|
|
77
92
|
{#snippet children({ segments })}
|
|
78
93
|
{#each segments as { part, value: segValue }, index (`${part}-${index}`)}
|
|
79
94
|
<BitsDatePicker.Segment
|
|
80
95
|
{part}
|
|
81
|
-
class='rounded px-0.5 py-0.5
|
|
96
|
+
class='rounded px-0.5 py-0.5 tabular-nums focus:bg-primary focus:text-primary-content focus:outline-none data-[type=literal]:px-0 data-[type=literal]:text-base-content/50'
|
|
82
97
|
>
|
|
83
98
|
{segValue}
|
|
84
99
|
</BitsDatePicker.Segment>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { DateValue } from '@internationalized/date';
|
|
2
|
-
import type { DataAttributes } from '../../types.js';
|
|
2
|
+
import type { DataAttributes, Size } from '../../types.js';
|
|
3
3
|
interface Props {
|
|
4
4
|
/** 选中日期(双向绑定) */
|
|
5
5
|
value?: DateValue;
|
|
@@ -13,6 +13,8 @@ interface Props {
|
|
|
13
13
|
isDateUnavailable?: (date: DateValue) => boolean;
|
|
14
14
|
/** 是否禁用 */
|
|
15
15
|
disabled?: boolean;
|
|
16
|
+
/** 尺寸 */
|
|
17
|
+
size?: Size;
|
|
16
18
|
/** 错误消息 */
|
|
17
19
|
error?: string;
|
|
18
20
|
/** 自定义类名 */
|
|
@@ -30,6 +32,7 @@ type $$ComponentProps = Props & DataAttributes;
|
|
|
30
32
|
* @prop {DateValue} minValue - 最小可选日期
|
|
31
33
|
* @prop {DateValue} maxValue - 最大可选日期
|
|
32
34
|
* @prop {string} placeholder - 输入框占位文本
|
|
35
|
+
* @prop {Size} size - 输入框尺寸
|
|
33
36
|
* @prop {boolean} disabled - 是否禁用
|
|
34
37
|
* @prop {string} error - 错误消息
|
|
35
38
|
* @prop {function} onchange - 日期变更回调
|
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
<script lang='ts'>
|
|
11
11
|
import type { DataAttributes, TagInputProps } from '../../types.js'
|
|
12
12
|
import { uiM } from '../../messages.js'
|
|
13
|
-
import { cn, getDataAttributes
|
|
13
|
+
import { cn, getDataAttributes } from '../../utils.js'
|
|
14
|
+
import { getFormControlSizeClasses } from '../control-size.js'
|
|
14
15
|
import BareInput from '../primitives/BareInput.svelte'
|
|
15
16
|
import Tag from '../primitives/Tag.svelte'
|
|
16
17
|
|
|
@@ -27,6 +28,7 @@
|
|
|
27
28
|
}: TagInputProps & DataAttributes = $props()
|
|
28
29
|
|
|
29
30
|
const dataAttributes = $derived(getDataAttributes(restProps))
|
|
31
|
+
const sizeClasses = $derived(getFormControlSizeClasses(size))
|
|
30
32
|
let inputValue = $state('')
|
|
31
33
|
let inputElement = $state<HTMLInputElement | undefined>(undefined)
|
|
32
34
|
|
|
@@ -34,9 +36,10 @@
|
|
|
34
36
|
|
|
35
37
|
const containerClass = $derived(
|
|
36
38
|
cn(
|
|
37
|
-
'
|
|
38
|
-
|
|
39
|
-
|
|
39
|
+
'flex h-auto w-full flex-wrap items-center gap-1 rounded-lg border border-base-content/15 bg-base-100 py-1 transition-[border-color,box-shadow] duration-150 focus-within:border-primary/50 focus-within:ring-2 focus-within:ring-primary/10',
|
|
40
|
+
sizeClasses.minHeight,
|
|
41
|
+
sizeClasses.spacing,
|
|
42
|
+
disabled && 'cursor-not-allowed opacity-50',
|
|
40
43
|
className,
|
|
41
44
|
),
|
|
42
45
|
)
|
|
@@ -79,32 +82,34 @@
|
|
|
79
82
|
}
|
|
80
83
|
</script>
|
|
81
84
|
|
|
82
|
-
<div {...dataAttributes}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
85
|
+
<div {...dataAttributes} class='fieldset w-full'>
|
|
86
|
+
<div
|
|
87
|
+
class={containerClass}
|
|
88
|
+
onclick={() => inputElement?.focus()}
|
|
89
|
+
onkeydown={e => e.key === 'Enter' && inputElement?.focus()}
|
|
90
|
+
role='textbox'
|
|
91
|
+
tabindex='-1'
|
|
92
|
+
>
|
|
93
|
+
{#each tags as tag, index (index)}
|
|
94
|
+
<Tag
|
|
95
|
+
text={tag}
|
|
96
|
+
size='sm'
|
|
97
|
+
closable={!disabled}
|
|
98
|
+
onclose={() => removeTag(index)}
|
|
99
|
+
/>
|
|
100
|
+
{/each}
|
|
97
101
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
102
|
+
{#if canAddMore && !disabled}
|
|
103
|
+
<BareInput
|
|
104
|
+
type='text'
|
|
105
|
+
class='flex-1 min-w-[100px] bg-transparent border-none outline-none'
|
|
106
|
+
bind:value={inputValue}
|
|
107
|
+
bind:inputRef={inputElement}
|
|
108
|
+
placeholder={displayPlaceholder}
|
|
109
|
+
{disabled}
|
|
110
|
+
onkeydown={handleKeydown}
|
|
111
|
+
onblur={handleBlur}
|
|
112
|
+
/>
|
|
113
|
+
{/if}
|
|
114
|
+
</div>
|
|
110
115
|
</div>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { Size } from '../types.js';
|
|
2
|
+
interface FormControlSizeClasses {
|
|
3
|
+
/** 固定高度,适用于单行控件 */
|
|
4
|
+
height: string;
|
|
5
|
+
/** 最小高度,适用于内容可换行的控件 */
|
|
6
|
+
minHeight: string;
|
|
7
|
+
/** 水平内边距与字号 */
|
|
8
|
+
spacing: string;
|
|
9
|
+
/** 固定高度、水平内边距与字号的完整组合 */
|
|
10
|
+
control: string;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* 获取输入类控件的统一尺寸类名。
|
|
14
|
+
*
|
|
15
|
+
* 仅供组件内部复用,确保 Input、Select 与复合输入控件使用同一盒模型。
|
|
16
|
+
*/
|
|
17
|
+
export declare function getFormControlSizeClasses(size: Size): FormControlSizeClasses;
|
|
18
|
+
export {};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
const FORM_CONTROL_SIZE_CLASSES = {
|
|
2
|
+
'xs': { height: 'h-8', minHeight: 'min-h-8', spacing: 'px-2.5 text-xs', control: 'h-8 px-2.5 text-xs' },
|
|
3
|
+
'sm': { height: 'h-9', minHeight: 'min-h-9', spacing: 'px-3 text-sm', control: 'h-9 px-3 text-sm' },
|
|
4
|
+
'md': { height: 'h-10', minHeight: 'min-h-10', spacing: 'px-3 text-sm', control: 'h-10 px-3 text-sm' },
|
|
5
|
+
'lg': { height: 'h-12', minHeight: 'min-h-12', spacing: 'px-4 text-base', control: 'h-12 px-4 text-base' },
|
|
6
|
+
'xl': { height: 'h-14', minHeight: 'min-h-14', spacing: 'px-4 text-lg', control: 'h-14 px-4 text-lg' },
|
|
7
|
+
'2xl': { height: 'h-14', minHeight: 'min-h-14', spacing: 'px-4 text-lg', control: 'h-14 px-4 text-lg' },
|
|
8
|
+
'3xl': { height: 'h-14', minHeight: 'min-h-14', spacing: 'px-4 text-lg', control: 'h-14 px-4 text-lg' },
|
|
9
|
+
'4xl': { height: 'h-14', minHeight: 'min-h-14', spacing: 'px-4 text-lg', control: 'h-14 px-4 text-lg' },
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* 获取输入类控件的统一尺寸类名。
|
|
13
|
+
*
|
|
14
|
+
* 仅供组件内部复用,确保 Input、Select 与复合输入控件使用同一盒模型。
|
|
15
|
+
*/
|
|
16
|
+
export function getFormControlSizeClasses(size) {
|
|
17
|
+
return FORM_CONTROL_SIZE_CLASSES[size];
|
|
18
|
+
}
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
<script lang='ts'>
|
|
6
6
|
import type { DataAttributes, InputProps } from '../../types.js'
|
|
7
7
|
import { cn, getDataAttributes } from '../../utils.js'
|
|
8
|
+
import { getFormControlSizeClasses } from '../control-size.js'
|
|
8
9
|
|
|
9
10
|
let {
|
|
10
11
|
value = $bindable(''),
|
|
@@ -39,29 +40,7 @@
|
|
|
39
40
|
}: InputProps & DataAttributes = $props()
|
|
40
41
|
|
|
41
42
|
const dataAttributes = $derived(getDataAttributes(restProps))
|
|
42
|
-
const
|
|
43
|
-
size === 'xs'
|
|
44
|
-
? 'h-8'
|
|
45
|
-
: size === 'sm'
|
|
46
|
-
? 'h-9'
|
|
47
|
-
: size === 'lg'
|
|
48
|
-
? 'h-12'
|
|
49
|
-
: size === 'xl'
|
|
50
|
-
? 'h-14'
|
|
51
|
-
: 'h-10',
|
|
52
|
-
)
|
|
53
|
-
|
|
54
|
-
const inputSpacingClass = $derived(
|
|
55
|
-
size === 'xs'
|
|
56
|
-
? 'px-2.5 text-xs'
|
|
57
|
-
: size === 'sm'
|
|
58
|
-
? 'px-3 text-sm'
|
|
59
|
-
: size === 'lg'
|
|
60
|
-
? 'px-4 text-base'
|
|
61
|
-
: size === 'xl'
|
|
62
|
-
? 'px-4 text-lg'
|
|
63
|
-
: 'px-3 text-sm',
|
|
64
|
-
)
|
|
43
|
+
const sizeClasses = $derived(getFormControlSizeClasses(size))
|
|
65
44
|
|
|
66
45
|
const inputTypeClass = $derived(
|
|
67
46
|
type === 'number'
|
|
@@ -72,7 +51,7 @@
|
|
|
72
51
|
const containerClass = $derived(
|
|
73
52
|
cn(
|
|
74
53
|
'flex items-center w-full rounded-lg border bg-base-100',
|
|
75
|
-
|
|
54
|
+
sizeClasses.height,
|
|
76
55
|
error
|
|
77
56
|
? 'border-error/60 focus-within:ring-2 focus-within:ring-error/15'
|
|
78
57
|
: 'border-base-content/15 focus-within:border-primary/50 focus-within:ring-2 focus-within:ring-primary/10',
|
|
@@ -136,7 +115,7 @@
|
|
|
136
115
|
autocomplete={autocomplete as HTMLInputElement['autocomplete']}
|
|
137
116
|
class={cn(
|
|
138
117
|
'flex-1 h-full bg-transparent border-none outline-none placeholder:text-base-content/35',
|
|
139
|
-
|
|
118
|
+
sizeClasses.spacing,
|
|
140
119
|
inputTypeClass,
|
|
141
120
|
)}
|
|
142
121
|
bind:value
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
<script lang='ts' generics="T = string">
|
|
12
12
|
import type { DataAttributes, SelectProps } from '../../types.js'
|
|
13
13
|
import { cn, getDataAttributes, portal } from '../../utils.js'
|
|
14
|
+
import { getFormControlSizeClasses } from '../control-size.js'
|
|
14
15
|
|
|
15
16
|
let {
|
|
16
17
|
value = $bindable<T>(),
|
|
@@ -39,14 +40,7 @@
|
|
|
39
40
|
// 下拉层定位样式(portal 到 body,用 fixed 逃逸 overflow/stacking)
|
|
40
41
|
let dropdownStyle = $state('')
|
|
41
42
|
|
|
42
|
-
|
|
43
|
-
const sizeClasses: Record<string, string> = {
|
|
44
|
-
xs: 'h-8 text-xs px-2.5',
|
|
45
|
-
sm: 'h-9 text-sm px-3',
|
|
46
|
-
md: 'h-10 text-sm px-3',
|
|
47
|
-
lg: 'h-12 text-base px-4',
|
|
48
|
-
xl: 'h-14 text-lg px-4',
|
|
49
|
-
}
|
|
43
|
+
const sizeClasses = $derived(getFormControlSizeClasses(size))
|
|
50
44
|
|
|
51
45
|
// 选项文字尺寸
|
|
52
46
|
const optionTextClass = $derived(
|
|
@@ -81,7 +75,7 @@
|
|
|
81
75
|
const wrapperClass = $derived(
|
|
82
76
|
cn(
|
|
83
77
|
'relative flex w-full min-w-0 items-center rounded-lg border bg-base-100 cursor-pointer',
|
|
84
|
-
sizeClasses
|
|
78
|
+
sizeClasses.control,
|
|
85
79
|
error
|
|
86
80
|
? 'border-error/60 focus-within:ring-2 focus-within:ring-error/15'
|
|
87
81
|
: isDropdownOpen
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
MarkdownTextAlignKind,
|
|
27
27
|
} from './document-types.js'
|
|
28
28
|
import { tick } from 'svelte'
|
|
29
|
+
import { SvelteMap } from 'svelte/reactivity'
|
|
29
30
|
import { writeTextToClipboard } from '../../../internal/browser-safety.js'
|
|
30
31
|
import { uiM } from '../../../messages.js'
|
|
31
32
|
import { cn, getDataAttributes } from '../../../utils.js'
|
|
@@ -37,7 +38,12 @@
|
|
|
37
38
|
import { resolveDocumentMarkdownContent } from './document-download.js'
|
|
38
39
|
import { renderMarkdownDocument } from './document-parse.js'
|
|
39
40
|
import { parseMarkdown } from './markdown-parse.js'
|
|
40
|
-
import {
|
|
41
|
+
import {
|
|
42
|
+
createMermaidSourceSignature,
|
|
43
|
+
getMermaidHostRenderAction,
|
|
44
|
+
isCurrentMermaidRenderToken,
|
|
45
|
+
} from './mermaid-host-sync.js'
|
|
46
|
+
import { renderMermaidDiagram, stripMermaidSvgStyleElements } from './mermaid-render.js'
|
|
41
47
|
|
|
42
48
|
interface SelectionToolbarPosition {
|
|
43
49
|
/** 选区工具条相对滚动容器的 top 坐标。 */
|
|
@@ -59,6 +65,17 @@
|
|
|
59
65
|
error?: string
|
|
60
66
|
}
|
|
61
67
|
|
|
68
|
+
interface MermaidRenderCacheEntry {
|
|
69
|
+
/** 当前 Mermaid 源码签名。 */
|
|
70
|
+
signature: string
|
|
71
|
+
/** 当前 Mermaid 渲染状态。 */
|
|
72
|
+
status: 'rendering' | 'ready' | 'error'
|
|
73
|
+
/** 渲染成功后的 SVG 内容。 */
|
|
74
|
+
html?: string
|
|
75
|
+
/** 渲染失败时展示的错误摘要。 */
|
|
76
|
+
error?: string
|
|
77
|
+
}
|
|
78
|
+
|
|
62
79
|
interface SelectionFormatState {
|
|
63
80
|
/** 当前选区所在段落样式,用于标题/正文下拉默认值。 */
|
|
64
81
|
blockFormat: MarkdownBlockStyleKind
|
|
@@ -226,6 +243,7 @@
|
|
|
226
243
|
// 复制前后的图标以内联 SVG 缓存,避免每次点击都重新拼接按钮内容。
|
|
227
244
|
const COPY_ICON = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>`
|
|
228
245
|
const CHECK_ICON = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>`
|
|
246
|
+
const MERMAID_SYNC_IDLE_DELAY_MS = 220
|
|
229
247
|
|
|
230
248
|
let {
|
|
231
249
|
// 暴露给外层的滚动容器引用,用于同步滚动或定位选区工具条。
|
|
@@ -367,6 +385,12 @@
|
|
|
367
385
|
let copyFeedbackTimer: number | undefined = $state()
|
|
368
386
|
// 每个代码块的运行状态和预览结果,key 为 codeBlockId。
|
|
369
387
|
let codePreviews = $state<Record<string, CodePreviewState>>({})
|
|
388
|
+
// Mermaid host 会随 {@html} 重建,渲染缓存必须独立于 DOM 节点保存。
|
|
389
|
+
const mermaidRenderCache = new SvelteMap<string, MermaidRenderCacheEntry>()
|
|
390
|
+
// 流式输出会高频重建 {@html},Mermaid 同步必须合并到内容静默后执行。
|
|
391
|
+
let mermaidSyncTimer: number | undefined
|
|
392
|
+
let mermaidSyncFrame: number | undefined
|
|
393
|
+
let scheduledMermaidRenderSignature = ''
|
|
370
394
|
// selectionFormatState 跟随真实 DOM 选区,负责驱动按钮按下态与下拉默认值。
|
|
371
395
|
let selectionFormatState = $state<SelectionFormatState>({
|
|
372
396
|
...DEFAULT_SELECTION_FORMAT_STATE,
|
|
@@ -419,8 +443,13 @@
|
|
|
419
443
|
allowHtmlTags,
|
|
420
444
|
}),
|
|
421
445
|
)
|
|
422
|
-
//
|
|
423
|
-
const
|
|
446
|
+
// Mermaid 占位 HTML 在流式阶段可能不变,必须额外订阅源码签名变化。
|
|
447
|
+
const mermaidRenderSignature = $derived(
|
|
448
|
+
renderResult.blocks
|
|
449
|
+
.filter(block => block.kind === 'mermaid')
|
|
450
|
+
.map(block => block.signature)
|
|
451
|
+
.join('\n'),
|
|
452
|
+
)
|
|
424
453
|
// outline 是左侧目录的原始数据源。
|
|
425
454
|
const outline = $derived(renderResult.outline)
|
|
426
455
|
// outlineHasContent 用来区分“目录被收起”和“正文确实没有标题”。
|
|
@@ -492,14 +521,26 @@
|
|
|
492
521
|
}
|
|
493
522
|
|
|
494
523
|
$effect(() => {
|
|
495
|
-
void
|
|
524
|
+
void renderResult.blocks
|
|
525
|
+
void mermaidRenderSignature
|
|
496
526
|
if (typeof window === 'undefined' || !previewHost) {
|
|
527
|
+
cancelMermaidHostsSync()
|
|
497
528
|
return
|
|
498
529
|
}
|
|
499
530
|
|
|
531
|
+
if (!mermaidRenderSignature) {
|
|
532
|
+
scheduledMermaidRenderSignature = ''
|
|
533
|
+
cancelMermaidHostsSync()
|
|
534
|
+
}
|
|
535
|
+
else if (
|
|
536
|
+
mermaidRenderSignature !== scheduledMermaidRenderSignature
|
|
537
|
+
) {
|
|
538
|
+
scheduledMermaidRenderSignature = mermaidRenderSignature
|
|
539
|
+
scheduleMermaidHostsSync()
|
|
540
|
+
}
|
|
541
|
+
|
|
500
542
|
requestAnimationFrame(() => {
|
|
501
543
|
syncCodePreviewHosts()
|
|
502
|
-
syncMermaidHosts()
|
|
503
544
|
syncActiveHeadingFromScroll()
|
|
504
545
|
syncPinnedTitleVisibility()
|
|
505
546
|
})
|
|
@@ -939,10 +980,16 @@
|
|
|
939
980
|
: ''
|
|
940
981
|
|
|
941
982
|
if (result.kind === 'mermaid') {
|
|
942
|
-
host.innerHTML = `<div class="hai-md-preview-card hai-md-preview-mermaid"><div class="hai-md-preview-head">${previewTitle}</div>${previewDesc}<div class="hai-md-mermaid hai-md-mermaid-preview"></div></div>`
|
|
983
|
+
host.innerHTML = `<div class="hai-md-preview-card hai-md-preview-mermaid"><div class="hai-md-preview-head">${previewTitle}</div>${previewDesc}<div class="hai-md-mermaid hai-md-mermaid-preview" contenteditable="false"></div></div>`
|
|
943
984
|
const mermaidEl = host.querySelector<HTMLElement>('.hai-md-mermaid-preview')
|
|
944
985
|
if (mermaidEl) {
|
|
945
|
-
|
|
986
|
+
const signature = createMermaidSourceSignature('preview', result.content)
|
|
987
|
+
void renderMermaidHost(
|
|
988
|
+
mermaidEl,
|
|
989
|
+
result.content,
|
|
990
|
+
signature,
|
|
991
|
+
`preview:${signature}`,
|
|
992
|
+
)
|
|
946
993
|
}
|
|
947
994
|
return
|
|
948
995
|
}
|
|
@@ -974,6 +1021,43 @@
|
|
|
974
1021
|
* mermaid 图表位于 `{@html}` 注入的 DOM 内部,渲染是异步的。
|
|
975
1022
|
* 阅读态的图表块和 code 模式预览槽都复用这里把源码渲染为 SVG。
|
|
976
1023
|
*/
|
|
1024
|
+
function scheduleMermaidHostsSync(): void {
|
|
1025
|
+
if (typeof window === 'undefined') {
|
|
1026
|
+
return
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
if (mermaidSyncTimer !== undefined) {
|
|
1030
|
+
window.clearTimeout(mermaidSyncTimer)
|
|
1031
|
+
}
|
|
1032
|
+
if (mermaidSyncFrame !== undefined) {
|
|
1033
|
+
window.cancelAnimationFrame(mermaidSyncFrame)
|
|
1034
|
+
mermaidSyncFrame = undefined
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
mermaidSyncTimer = window.setTimeout(() => {
|
|
1038
|
+
mermaidSyncTimer = undefined
|
|
1039
|
+
mermaidSyncFrame = window.requestAnimationFrame(() => {
|
|
1040
|
+
mermaidSyncFrame = undefined
|
|
1041
|
+
syncMermaidHosts()
|
|
1042
|
+
})
|
|
1043
|
+
}, MERMAID_SYNC_IDLE_DELAY_MS)
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
function cancelMermaidHostsSync(): void {
|
|
1047
|
+
if (typeof window === 'undefined') {
|
|
1048
|
+
return
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
if (mermaidSyncTimer !== undefined) {
|
|
1052
|
+
window.clearTimeout(mermaidSyncTimer)
|
|
1053
|
+
mermaidSyncTimer = undefined
|
|
1054
|
+
}
|
|
1055
|
+
if (mermaidSyncFrame !== undefined) {
|
|
1056
|
+
window.cancelAnimationFrame(mermaidSyncFrame)
|
|
1057
|
+
mermaidSyncFrame = undefined
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
|
|
977
1061
|
function syncMermaidHosts(): void {
|
|
978
1062
|
if (!previewHost) {
|
|
979
1063
|
return
|
|
@@ -981,13 +1065,6 @@
|
|
|
981
1065
|
|
|
982
1066
|
const hosts = previewHost.querySelectorAll<HTMLElement>('[data-mermaid-host]')
|
|
983
1067
|
for (const host of hosts) {
|
|
984
|
-
if (
|
|
985
|
-
host.dataset.mermaidStatus === 'ready'
|
|
986
|
-
|| host.dataset.mermaidStatus === 'rendering'
|
|
987
|
-
) {
|
|
988
|
-
continue
|
|
989
|
-
}
|
|
990
|
-
|
|
991
1068
|
const blockId = host.dataset.mermaidHost
|
|
992
1069
|
if (!blockId) {
|
|
993
1070
|
continue
|
|
@@ -998,7 +1075,19 @@
|
|
|
998
1075
|
continue
|
|
999
1076
|
}
|
|
1000
1077
|
|
|
1001
|
-
|
|
1078
|
+
const nextSignature = createMermaidSourceSignature(block.id, block.code)
|
|
1079
|
+
const cached = mermaidRenderCache.get(blockId)
|
|
1080
|
+
const action = getMermaidHostRenderAction({
|
|
1081
|
+
status: cached?.status ?? host.dataset.mermaidStatus,
|
|
1082
|
+
renderedSignature: cached?.signature ?? host.dataset.mermaidSourceSignature,
|
|
1083
|
+
nextSignature,
|
|
1084
|
+
})
|
|
1085
|
+
if (!action.shouldRender) {
|
|
1086
|
+
applyCachedMermaidRender(host, cached)
|
|
1087
|
+
continue
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
void renderMermaidHost(host, block.code, action.token, blockId)
|
|
1002
1091
|
}
|
|
1003
1092
|
}
|
|
1004
1093
|
|
|
@@ -1006,16 +1095,98 @@
|
|
|
1006
1095
|
* 把单个 mermaid 源码渲染进目标节点;语法错误时展示本地化错误提示。
|
|
1007
1096
|
* `mermaidStatus` 既避免同一帧重复渲染,也兜住多次 effect 触发。
|
|
1008
1097
|
*/
|
|
1009
|
-
async function renderMermaidHost(
|
|
1098
|
+
async function renderMermaidHost(
|
|
1099
|
+
host: HTMLElement,
|
|
1100
|
+
code: string,
|
|
1101
|
+
renderToken: string,
|
|
1102
|
+
cacheKey: string,
|
|
1103
|
+
): Promise<void> {
|
|
1104
|
+
const cached = mermaidRenderCache.get(cacheKey)
|
|
1105
|
+
if (
|
|
1106
|
+
cached
|
|
1107
|
+
&& (cached.status === 'ready' || cached.status === 'rendering' || cached.status === 'error')
|
|
1108
|
+
&& cached.signature === renderToken
|
|
1109
|
+
) {
|
|
1110
|
+
applyCachedMermaidRender(host, cached)
|
|
1111
|
+
return
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1010
1114
|
host.dataset.mermaidStatus = 'rendering'
|
|
1115
|
+
host.dataset.mermaidSourceSignature = renderToken
|
|
1116
|
+
mermaidRenderCache.set(cacheKey, {
|
|
1117
|
+
signature: renderToken,
|
|
1118
|
+
status: 'rendering',
|
|
1119
|
+
})
|
|
1011
1120
|
try {
|
|
1012
1121
|
const svg = await renderMermaidDiagram(code)
|
|
1013
|
-
|
|
1014
|
-
|
|
1122
|
+
if (!isCurrentMermaidRenderToken(renderToken, mermaidRenderCache.get(cacheKey)?.signature)) {
|
|
1123
|
+
return
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
const entry: MermaidRenderCacheEntry = {
|
|
1127
|
+
signature: renderToken,
|
|
1128
|
+
status: 'ready',
|
|
1129
|
+
html: stripMermaidSvgStyleElements(svg),
|
|
1130
|
+
}
|
|
1131
|
+
mermaidRenderCache.set(cacheKey, entry)
|
|
1132
|
+
applyCachedMermaidRender(host, entry)
|
|
1133
|
+
applyCachedMermaidRenderToCurrentHosts(cacheKey, entry)
|
|
1015
1134
|
}
|
|
1016
1135
|
catch (error) {
|
|
1017
|
-
|
|
1018
|
-
|
|
1136
|
+
if (!isCurrentMermaidRenderToken(renderToken, mermaidRenderCache.get(cacheKey)?.signature)) {
|
|
1137
|
+
return
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
const message = error instanceof Error ? error.message : uiM('markdown_mermaid_failed')
|
|
1141
|
+
const entry: MermaidRenderCacheEntry = {
|
|
1142
|
+
signature: renderToken,
|
|
1143
|
+
status: 'error',
|
|
1144
|
+
error: message,
|
|
1145
|
+
}
|
|
1146
|
+
mermaidRenderCache.set(cacheKey, entry)
|
|
1147
|
+
applyCachedMermaidRender(host, entry)
|
|
1148
|
+
applyCachedMermaidRenderToCurrentHosts(cacheKey, entry)
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
function applyCachedMermaidRender(
|
|
1153
|
+
host: HTMLElement,
|
|
1154
|
+
entry: MermaidRenderCacheEntry | undefined,
|
|
1155
|
+
): void {
|
|
1156
|
+
if (!entry) {
|
|
1157
|
+
return
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
host.dataset.mermaidStatus = entry.status
|
|
1161
|
+
host.dataset.mermaidSourceSignature = entry.signature
|
|
1162
|
+
if (host.dataset.mermaidAppliedSignature === entry.signature) {
|
|
1163
|
+
return
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
if (entry.status === 'ready' && entry.html !== undefined) {
|
|
1167
|
+
host.innerHTML = entry.html
|
|
1168
|
+
host.dataset.mermaidAppliedSignature = entry.signature
|
|
1169
|
+
return
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
if (entry.status === 'error') {
|
|
1173
|
+
host.innerHTML = `<div class="hai-md-mermaid-error">${escapePreviewText(entry.error ?? uiM('markdown_mermaid_failed'))}</div>`
|
|
1174
|
+
host.dataset.mermaidAppliedSignature = entry.signature
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
function applyCachedMermaidRenderToCurrentHosts(
|
|
1179
|
+
cacheKey: string,
|
|
1180
|
+
entry: MermaidRenderCacheEntry,
|
|
1181
|
+
): void {
|
|
1182
|
+
if (!previewHost) {
|
|
1183
|
+
return
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
for (const host of previewHost.querySelectorAll<HTMLElement>('[data-mermaid-host]')) {
|
|
1187
|
+
if (host.dataset.mermaidHost === cacheKey) {
|
|
1188
|
+
applyCachedMermaidRender(host, entry)
|
|
1189
|
+
}
|
|
1019
1190
|
}
|
|
1020
1191
|
}
|
|
1021
1192
|
|
|
@@ -3172,8 +3343,18 @@
|
|
|
3172
3343
|
oninput={handlePreviewInput}
|
|
3173
3344
|
onblur={handlePreviewBlur}
|
|
3174
3345
|
>
|
|
3175
|
-
|
|
3176
|
-
|
|
3346
|
+
{#each renderResult.blocks as block (block.id)}
|
|
3347
|
+
{#if block.kind === 'html'}
|
|
3348
|
+
<!-- eslint-disable-next-line svelte/no-at-html-tags -- Markdown HTML 块渲染 -->
|
|
3349
|
+
{@html block.html}
|
|
3350
|
+
{:else}
|
|
3351
|
+
<div
|
|
3352
|
+
class='hai-md-mermaid'
|
|
3353
|
+
contenteditable='false'
|
|
3354
|
+
data-mermaid-host={block.codeBlockId}
|
|
3355
|
+
></div>
|
|
3356
|
+
{/if}
|
|
3357
|
+
{/each}
|
|
3177
3358
|
</article>
|
|
3178
3359
|
</div>
|
|
3179
3360
|
</section>
|
|
@@ -13,6 +13,22 @@ const BROWSER_PDF_PAGE_BREAK_SAMPLE_STEP_PX = 4;
|
|
|
13
13
|
const BROWSER_PDF_PAGE_BREAK_WHITE_THRESHOLD = 246;
|
|
14
14
|
const BROWSER_PDF_PAGE_BREAK_ALPHA_THRESHOLD = 12;
|
|
15
15
|
const BROWSER_PDF_PAGE_BREAK_EMPTY_ROW_MAX_INK = 1;
|
|
16
|
+
function escapeExportHtml(value) {
|
|
17
|
+
return value
|
|
18
|
+
.replace(/&/g, '&')
|
|
19
|
+
.replace(/</g, '<')
|
|
20
|
+
.replace(/>/g, '>')
|
|
21
|
+
.replace(/"/g, '"')
|
|
22
|
+
.replace(/'/g, ''');
|
|
23
|
+
}
|
|
24
|
+
function renderDocumentBlocksForExport(blocks) {
|
|
25
|
+
return blocks.map((block) => {
|
|
26
|
+
if (block.kind === 'html') {
|
|
27
|
+
return block.html;
|
|
28
|
+
}
|
|
29
|
+
return `<pre><code class="language-${escapeExportHtml(block.language)}">${escapeExportHtml(block.code)}</code></pre>`;
|
|
30
|
+
}).join('\n');
|
|
31
|
+
}
|
|
16
32
|
/**
|
|
17
33
|
* code 类型产物通常只有裸代码正文。
|
|
18
34
|
* 下载和预览都通过同一份规范化逻辑包装成 fenced markdown,避免两边导出的结构不一致。
|
|
@@ -75,14 +91,15 @@ export async function downloadAiDocument(request) {
|
|
|
75
91
|
showRunButton: false,
|
|
76
92
|
breaks: true,
|
|
77
93
|
});
|
|
78
|
-
const
|
|
94
|
+
const renderedHtml = renderDocumentBlocksForExport(rendered.blocks);
|
|
95
|
+
const exportHtml = buildDocumentExportHtml(exportTitle, renderedHtml);
|
|
79
96
|
if (builtIn.id === 'word') {
|
|
80
97
|
downloadBlob(new Blob([`\uFEFF${exportHtml}`], {
|
|
81
98
|
type: 'application/msword;charset=utf-8',
|
|
82
99
|
}), `${baseName}.doc`);
|
|
83
100
|
return;
|
|
84
101
|
}
|
|
85
|
-
await downloadDocumentAsPdf(htmlToPdfFilename(exportTitle),
|
|
102
|
+
await downloadDocumentAsPdf(htmlToPdfFilename(exportTitle), renderedHtml);
|
|
86
103
|
}
|
|
87
104
|
function createBuiltInDownloadAction(format) {
|
|
88
105
|
switch (format) {
|
|
@@ -25,12 +25,35 @@ export interface MarkdownDocumentParseOptions {
|
|
|
25
25
|
allowHtmlTags?: boolean;
|
|
26
26
|
}
|
|
27
27
|
export interface MarkdownRenderResult {
|
|
28
|
-
/** Rendered HTML output. */
|
|
29
|
-
html: string;
|
|
30
28
|
/** Extracted outline items for navigation. */
|
|
31
29
|
outline: MarkdownOutlineItem[];
|
|
32
30
|
/** Extracted code blocks for run/copy features. */
|
|
33
31
|
codeBlocks: MarkdownCodeBlockItem[];
|
|
32
|
+
/** 阅读态使用的稳定块列表,避免流式更新销毁重型块 DOM。 */
|
|
33
|
+
blocks: MarkdownDocumentBlock[];
|
|
34
|
+
}
|
|
35
|
+
export type MarkdownDocumentBlock = MarkdownDocumentHtmlBlock | MarkdownDocumentMermaidBlock;
|
|
36
|
+
export interface MarkdownDocumentHtmlBlock {
|
|
37
|
+
/** 普通 Markdown HTML 块。 */
|
|
38
|
+
kind: 'html';
|
|
39
|
+
/** 稳定块 id,用于 Svelte keyed each。 */
|
|
40
|
+
id: string;
|
|
41
|
+
/** 已按现有安全策略渲染后的 HTML。 */
|
|
42
|
+
html: string;
|
|
43
|
+
}
|
|
44
|
+
export interface MarkdownDocumentMermaidBlock {
|
|
45
|
+
/** 已闭合 Mermaid fence 块。 */
|
|
46
|
+
kind: 'mermaid';
|
|
47
|
+
/** 稳定块 id,用于 Svelte keyed each。 */
|
|
48
|
+
id: string;
|
|
49
|
+
/** 对应 codeBlocks 中的代码块 id。 */
|
|
50
|
+
codeBlockId: string;
|
|
51
|
+
/** Mermaid 源码。 */
|
|
52
|
+
code: string;
|
|
53
|
+
/** 原始 fence info string。 */
|
|
54
|
+
language: string;
|
|
55
|
+
/** Mermaid 源码签名,源码变化时才需要重渲染。 */
|
|
56
|
+
signature: string;
|
|
34
57
|
}
|
|
35
58
|
/**
|
|
36
59
|
* Render markdown into HTML and extract outline + code block metadata.
|
|
@@ -28,6 +28,41 @@ function readHtmlTokenSource(token) {
|
|
|
28
28
|
? token.text
|
|
29
29
|
: '';
|
|
30
30
|
}
|
|
31
|
+
/**
|
|
32
|
+
* 判断 fenced code block 是否已经输出闭合 fence。
|
|
33
|
+
*
|
|
34
|
+
* marked 在流式半截内容里也会把未闭合 fence 识别成 code token;如果此时提前
|
|
35
|
+
* 调用 Mermaid,会把临时语法错误渲染成错误 SVG 并残留在界面上。
|
|
36
|
+
*/
|
|
37
|
+
function isClosedFencedCodeBlock(token) {
|
|
38
|
+
const firstLineEnd = token.raw.indexOf('\n');
|
|
39
|
+
const firstLine = firstLineEnd === -1
|
|
40
|
+
? token.raw
|
|
41
|
+
: token.raw.slice(0, firstLineEnd);
|
|
42
|
+
const openingStart = firstLine.search(/\S/);
|
|
43
|
+
if (openingStart === -1 || openingStart > 3) {
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
const markerChar = firstLine[openingStart];
|
|
47
|
+
if (markerChar !== '`' && markerChar !== '~') {
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
let markerLength = 0;
|
|
51
|
+
for (let index = openingStart; firstLine[index] === markerChar; index += 1) {
|
|
52
|
+
markerLength += 1;
|
|
53
|
+
}
|
|
54
|
+
if (markerLength < 3) {
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
const trimmedRaw = token.raw.trimEnd();
|
|
58
|
+
const lastLineStart = trimmedRaw.lastIndexOf('\n');
|
|
59
|
+
if (lastLineStart === -1) {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
const closingFence = trimmedRaw.slice(lastLineStart + 1).trim();
|
|
63
|
+
return closingFence.length >= markerLength
|
|
64
|
+
&& [...closingFence].every(char => char === markerChar);
|
|
65
|
+
}
|
|
31
66
|
/**
|
|
32
67
|
* Create a URL-safe heading slug while keeping letters and digits.
|
|
33
68
|
*/
|
|
@@ -98,18 +133,21 @@ function createRendererObject(options, state) {
|
|
|
98
133
|
const headingHtml = this.parser.parseInline(token.tokens);
|
|
99
134
|
return `<h${depth} id="${escapeHtml(id)}" data-heading-id="${escapeHtml(id)}">${headingHtml}</h${depth}>`;
|
|
100
135
|
},
|
|
101
|
-
code(
|
|
136
|
+
code(token) {
|
|
137
|
+
const { text, lang } = token;
|
|
102
138
|
// rawLanguage is the original info string from the fence.
|
|
103
139
|
const rawLanguage = lang?.trim() || '';
|
|
104
140
|
// mermaid 块在阅读态自动渲染为图表;只有 code 模式的代码/预览切换才保留源码视图。
|
|
105
|
-
if (isMermaidLanguage(rawLanguage)
|
|
141
|
+
if (isMermaidLanguage(rawLanguage)
|
|
142
|
+
&& !options.showCodePreviewToggle
|
|
143
|
+
&& isClosedFencedCodeBlock(token)) {
|
|
106
144
|
const mermaidBlockId = `hai-md-code-${state.codeBlocks.length + 1}`;
|
|
107
145
|
state.codeBlocks.push({
|
|
108
146
|
id: mermaidBlockId,
|
|
109
147
|
code: text,
|
|
110
148
|
language: rawLanguage,
|
|
111
149
|
});
|
|
112
|
-
return `<div class="hai-md-mermaid" data-mermaid-host="${escapeHtml(mermaidBlockId)}"></div>`;
|
|
150
|
+
return `<div class="hai-md-mermaid" contenteditable="false" data-mermaid-host="${escapeHtml(mermaidBlockId)}"></div>`;
|
|
113
151
|
}
|
|
114
152
|
// highlightLanguage is validated against supported languages.
|
|
115
153
|
const highlightLanguage = rawLanguage && isLanguageSupported(rawLanguage)
|
|
@@ -236,16 +274,105 @@ function createMarkedInstance(options, state) {
|
|
|
236
274
|
breaks: options.breaks,
|
|
237
275
|
});
|
|
238
276
|
}
|
|
277
|
+
function createMermaidSourceSignature(blockId, code) {
|
|
278
|
+
return JSON.stringify([blockId, code]);
|
|
279
|
+
}
|
|
280
|
+
function createEmptyRenderResult() {
|
|
281
|
+
return {
|
|
282
|
+
outline: [],
|
|
283
|
+
codeBlocks: [],
|
|
284
|
+
blocks: [],
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
function createHtmlBlock(id, html) {
|
|
288
|
+
return html
|
|
289
|
+
? {
|
|
290
|
+
kind: 'html',
|
|
291
|
+
id,
|
|
292
|
+
html,
|
|
293
|
+
}
|
|
294
|
+
: null;
|
|
295
|
+
}
|
|
296
|
+
function renderMarkdownDocumentHtmlChunk(content, options, state) {
|
|
297
|
+
const marked = createMarkedInstance(options, state);
|
|
298
|
+
const result = marked.parse(content);
|
|
299
|
+
return {
|
|
300
|
+
html: typeof result === 'string' ? result : '',
|
|
301
|
+
codeBlocks: state.codeBlocks,
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
function isClosedMermaidToken(token, options) {
|
|
305
|
+
if (token.type !== 'code'
|
|
306
|
+
|| !('text' in token)
|
|
307
|
+
|| typeof token.text !== 'string') {
|
|
308
|
+
return false;
|
|
309
|
+
}
|
|
310
|
+
const codeToken = token;
|
|
311
|
+
const rawLanguage = codeToken.lang?.trim() || '';
|
|
312
|
+
return isMermaidLanguage(rawLanguage)
|
|
313
|
+
&& !options.showCodePreviewToggle
|
|
314
|
+
&& isClosedFencedCodeBlock(codeToken);
|
|
315
|
+
}
|
|
316
|
+
function renderMarkdownDocumentBlocks(content, options) {
|
|
317
|
+
const marked = new Marked({
|
|
318
|
+
extensions: createEditorMarkdownExtensions(),
|
|
319
|
+
gfm: true,
|
|
320
|
+
breaks: options.breaks,
|
|
321
|
+
});
|
|
322
|
+
const tokens = marked.lexer(content);
|
|
323
|
+
const blocks = [];
|
|
324
|
+
let htmlTokens = [];
|
|
325
|
+
const state = {
|
|
326
|
+
outline: [],
|
|
327
|
+
codeBlocks: [],
|
|
328
|
+
headingIds: new Map(),
|
|
329
|
+
};
|
|
330
|
+
let nextBlockIndex = 1;
|
|
331
|
+
function flushHtmlTokens() {
|
|
332
|
+
if (htmlTokens.length === 0) {
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
const chunk = htmlTokens.map(token => token.raw).join('');
|
|
336
|
+
const result = renderMarkdownDocumentHtmlChunk(chunk, options, state);
|
|
337
|
+
const block = createHtmlBlock(`hai-md-block-${nextBlockIndex}`, result.html);
|
|
338
|
+
if (block) {
|
|
339
|
+
blocks.push(block);
|
|
340
|
+
nextBlockIndex += 1;
|
|
341
|
+
}
|
|
342
|
+
htmlTokens = [];
|
|
343
|
+
}
|
|
344
|
+
for (const token of tokens) {
|
|
345
|
+
if (!isClosedMermaidToken(token, options)) {
|
|
346
|
+
htmlTokens.push(token);
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
flushHtmlTokens();
|
|
350
|
+
const codeBlockId = `hai-md-code-${state.codeBlocks.length + 1}`;
|
|
351
|
+
const rawLanguage = token.lang?.trim() || '';
|
|
352
|
+
state.codeBlocks.push({
|
|
353
|
+
id: codeBlockId,
|
|
354
|
+
code: token.text,
|
|
355
|
+
language: rawLanguage,
|
|
356
|
+
});
|
|
357
|
+
blocks.push({
|
|
358
|
+
kind: 'mermaid',
|
|
359
|
+
id: `hai-md-block-${nextBlockIndex}`,
|
|
360
|
+
codeBlockId,
|
|
361
|
+
code: token.text,
|
|
362
|
+
language: rawLanguage,
|
|
363
|
+
signature: createMermaidSourceSignature(codeBlockId, token.text),
|
|
364
|
+
});
|
|
365
|
+
nextBlockIndex += 1;
|
|
366
|
+
}
|
|
367
|
+
flushHtmlTokens();
|
|
368
|
+
return blocks;
|
|
369
|
+
}
|
|
239
370
|
/**
|
|
240
371
|
* Render markdown into HTML and extract outline + code block metadata.
|
|
241
372
|
*/
|
|
242
373
|
export function renderMarkdownDocument(content, options) {
|
|
243
374
|
if (!content) {
|
|
244
|
-
return
|
|
245
|
-
html: '',
|
|
246
|
-
outline: [],
|
|
247
|
-
codeBlocks: [],
|
|
248
|
-
};
|
|
375
|
+
return createEmptyRenderResult();
|
|
249
376
|
}
|
|
250
377
|
// mergedOptions ensures defaults are always applied.
|
|
251
378
|
const mergedOptions = {
|
|
@@ -260,11 +387,10 @@ export function renderMarkdownDocument(content, options) {
|
|
|
260
387
|
};
|
|
261
388
|
// marked instance is scoped to this render call.
|
|
262
389
|
const marked = createMarkedInstance(mergedOptions, state);
|
|
263
|
-
|
|
264
|
-
const result = marked.parse(content);
|
|
390
|
+
marked.parse(content);
|
|
265
391
|
return {
|
|
266
|
-
html: typeof result === 'string' ? result : '',
|
|
267
392
|
outline: createNumberedOutline(state.outline),
|
|
268
393
|
codeBlocks: state.codeBlocks,
|
|
394
|
+
blocks: renderMarkdownDocumentBlocks(content, mergedOptions),
|
|
269
395
|
};
|
|
270
396
|
}
|
|
@@ -10,7 +10,7 @@ export { default as AiDocumentEditor } from './AiDocumentEditor.svelte';
|
|
|
10
10
|
export { downloadAiDocument, resolveDocumentDownloadActions, resolveDocumentMarkdownContent, } from './document-download.js';
|
|
11
11
|
export type { AiDocumentDownloadFormat, AiDocumentDownloadRequest, AiDocumentResolvedDownloadAction, } from './document-download.js';
|
|
12
12
|
export { renderMarkdownDocument } from './document-parse.js';
|
|
13
|
-
export type { MarkdownDocumentParseOptions, MarkdownRenderResult, } from './document-parse.js';
|
|
13
|
+
export type { MarkdownDocumentBlock, MarkdownDocumentHtmlBlock, MarkdownDocumentMermaidBlock, MarkdownDocumentParseOptions, MarkdownRenderResult, } from './document-parse.js';
|
|
14
14
|
export type { AiDocumentEditorProps, MarkdownBlockFormatKind, MarkdownBlockStyleKind, MarkdownCodeBlockItem, MarkdownCodeRunRequest, MarkdownCodeRunResult, MarkdownColorFormatRequest, MarkdownInlineFormatKind, MarkdownOutlineItem, MarkdownRewriteAction, MarkdownRewriteRequest, MarkdownTextAlignKind, MarkdownToolbarDownloadAction, } from './document-types.js';
|
|
15
15
|
export { parseMarkdown } from './markdown-parse.js';
|
|
16
16
|
export type { MarkdownParseOptions } from './markdown-parse.js';
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mermaid host 同步状态工具。
|
|
3
|
+
*
|
|
4
|
+
* Mermaid 图表通过 {@html} 插入占位节点后异步渲染。流式输出时,HTML 占位可能不变,
|
|
5
|
+
* 但源码正文会持续变化,因此必须用源码签名判断是否需要重渲染。
|
|
6
|
+
*/
|
|
7
|
+
export interface MermaidHostRenderActionInput {
|
|
8
|
+
/** 当前 host 上记录的渲染状态。 */
|
|
9
|
+
status?: string;
|
|
10
|
+
/** 当前 host 上一次渲染使用的源码签名。 */
|
|
11
|
+
renderedSignature?: string;
|
|
12
|
+
/** 本次 Markdown 解析得到的最新源码签名。 */
|
|
13
|
+
nextSignature: string;
|
|
14
|
+
}
|
|
15
|
+
export type MermaidHostRenderAction = {
|
|
16
|
+
shouldRender: false;
|
|
17
|
+
} | {
|
|
18
|
+
shouldRender: true;
|
|
19
|
+
token: string;
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* 为 Mermaid block 生成稳定签名。
|
|
23
|
+
*
|
|
24
|
+
* 签名同时包含 block id 与源码正文,避免流式阶段占位 HTML 未变化时漏掉源码更新。
|
|
25
|
+
*/
|
|
26
|
+
export declare function createMermaidSourceSignature(blockId: string, code: string): string;
|
|
27
|
+
/**
|
|
28
|
+
* 判断 Mermaid host 是否需要发起一次新的异步渲染。
|
|
29
|
+
*/
|
|
30
|
+
export declare function getMermaidHostRenderAction(input: MermaidHostRenderActionInput): MermaidHostRenderAction;
|
|
31
|
+
/**
|
|
32
|
+
* 判断异步 Mermaid 渲染结果是否仍对应当前 host 源码。
|
|
33
|
+
*/
|
|
34
|
+
export declare function isCurrentMermaidRenderToken(renderToken: string, currentSignature: string | undefined): boolean;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mermaid host 同步状态工具。
|
|
3
|
+
*
|
|
4
|
+
* Mermaid 图表通过 {@html} 插入占位节点后异步渲染。流式输出时,HTML 占位可能不变,
|
|
5
|
+
* 但源码正文会持续变化,因此必须用源码签名判断是否需要重渲染。
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* 为 Mermaid block 生成稳定签名。
|
|
9
|
+
*
|
|
10
|
+
* 签名同时包含 block id 与源码正文,避免流式阶段占位 HTML 未变化时漏掉源码更新。
|
|
11
|
+
*/
|
|
12
|
+
export function createMermaidSourceSignature(blockId, code) {
|
|
13
|
+
return JSON.stringify([blockId, code]);
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* 判断 Mermaid host 是否需要发起一次新的异步渲染。
|
|
17
|
+
*/
|
|
18
|
+
export function getMermaidHostRenderAction(input) {
|
|
19
|
+
const status = input.status?.trim();
|
|
20
|
+
const renderedSignature = input.renderedSignature?.trim();
|
|
21
|
+
if ((status === 'ready' || status === 'rendering' || status === 'error')
|
|
22
|
+
&& renderedSignature === input.nextSignature) {
|
|
23
|
+
return { shouldRender: false };
|
|
24
|
+
}
|
|
25
|
+
return {
|
|
26
|
+
shouldRender: true,
|
|
27
|
+
token: input.nextSignature,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* 判断异步 Mermaid 渲染结果是否仍对应当前 host 源码。
|
|
32
|
+
*/
|
|
33
|
+
export function isCurrentMermaidRenderToken(renderToken, currentSignature) {
|
|
34
|
+
return renderToken === currentSignature;
|
|
35
|
+
}
|
|
@@ -12,3 +12,8 @@ export declare function isMermaidLanguage(language: string | undefined | null):
|
|
|
12
12
|
* 把 mermaid 源码渲染为 SVG 字符串;语法错误时抛出由调用方兜底展示。
|
|
13
13
|
*/
|
|
14
14
|
export declare function renderMermaidDiagram(code: string): Promise<string>;
|
|
15
|
+
/**
|
|
16
|
+
* Mermaid 11.x 会在 SVG 内写入很长的 style 标签内容;在 contenteditable 预览区里,
|
|
17
|
+
* 部分浏览器会把这些 CSS 当成正文显示。这里保留 SVG 图形,剥离内联样式文本。
|
|
18
|
+
*/
|
|
19
|
+
export declare function stripMermaidSvgStyleElements(svg: string): string;
|
|
@@ -40,3 +40,10 @@ export async function renderMermaidDiagram(code) {
|
|
|
40
40
|
const { svg } = await mermaid.render(renderId, code);
|
|
41
41
|
return svg;
|
|
42
42
|
}
|
|
43
|
+
/**
|
|
44
|
+
* Mermaid 11.x 会在 SVG 内写入很长的 style 标签内容;在 contenteditable 预览区里,
|
|
45
|
+
* 部分浏览器会把这些 CSS 当成正文显示。这里保留 SVG 图形,剥离内联样式文本。
|
|
46
|
+
*/
|
|
47
|
+
export function stripMermaidSvgStyleElements(svg) {
|
|
48
|
+
return svg.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, '');
|
|
49
|
+
}
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import type { DataAttributes, Size } from '../../../types.js'
|
|
8
8
|
import { uiM } from '../../../messages.js'
|
|
9
9
|
import { cn, getDataAttributes } from '../../../utils.js'
|
|
10
|
+
import { getFormControlSizeClasses } from '../../control-size.js'
|
|
10
11
|
import BareButton from '../../primitives/BareButton.svelte'
|
|
11
12
|
import BareInput from '../../primitives/BareInput.svelte'
|
|
12
13
|
import Progress from '../../primitives/Progress.svelte'
|
|
@@ -76,19 +77,7 @@
|
|
|
76
77
|
|
|
77
78
|
const dataAttributes = $derived(getDataAttributes(restProps))
|
|
78
79
|
let showPassword = $state(false)
|
|
79
|
-
|
|
80
|
-
// 容器高度(与 Input 组件一致)
|
|
81
|
-
const containerHeight = $derived(
|
|
82
|
-
size === 'xs'
|
|
83
|
-
? 'h-8'
|
|
84
|
-
: size === 'sm'
|
|
85
|
-
? 'h-9'
|
|
86
|
-
: size === 'lg'
|
|
87
|
-
? 'h-12'
|
|
88
|
-
: size === 'xl'
|
|
89
|
-
? 'h-14'
|
|
90
|
-
: 'h-10',
|
|
91
|
-
)
|
|
80
|
+
const sizeClasses = $derived(getFormControlSizeClasses(size))
|
|
92
81
|
|
|
93
82
|
// 图标大小
|
|
94
83
|
const iconSize = $derived(
|
|
@@ -152,7 +141,7 @@
|
|
|
152
141
|
<!-- 输入框容器 -->
|
|
153
142
|
<div class={cn(
|
|
154
143
|
'flex items-center w-full rounded-lg border bg-base-100',
|
|
155
|
-
|
|
144
|
+
sizeClasses.height,
|
|
156
145
|
error
|
|
157
146
|
? 'border-error/60 focus-within:ring-2 focus-within:ring-error/15'
|
|
158
147
|
: 'border-base-content/15 focus-within:border-primary/50 focus-within:ring-2 focus-within:ring-primary/10',
|
|
@@ -163,7 +152,7 @@
|
|
|
163
152
|
<BareInput
|
|
164
153
|
type={showPassword ? 'text' : 'password'}
|
|
165
154
|
{id}
|
|
166
|
-
class='flex-1 h-full
|
|
155
|
+
class={cn('flex-1 h-full bg-transparent border-none outline-none placeholder:text-base-content/35', sizeClasses.spacing)}
|
|
167
156
|
bind:value
|
|
168
157
|
bind:inputRef={inputRef}
|
|
169
158
|
{placeholder}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@h-ai/ui",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.1.0-alpha.
|
|
4
|
+
"version": "0.1.0-alpha.34",
|
|
5
5
|
"description": "Hai Framework UI component library for Svelte 5 Runes.",
|
|
6
6
|
"license": "Apache-2.0",
|
|
7
7
|
"svelte": "./dist/index.js",
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
"mermaid": "^11.12.0",
|
|
52
52
|
"shiki": "^4.0.2",
|
|
53
53
|
"tailwind-merge": "^3.5.0",
|
|
54
|
-
"@h-ai/core": "0.1.0-alpha.
|
|
54
|
+
"@h-ai/core": "0.1.0-alpha.34"
|
|
55
55
|
},
|
|
56
56
|
"devDependencies": {
|
|
57
57
|
"@sveltejs/package": "^2.5.7",
|