@h-ai/ui 0.1.0-alpha.33 → 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 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
- 'min-h-10 rounded-lg border bg-base-100 flex flex-wrap gap-1.5 items-center py-1.5 pl-2.5 pr-9 transition-[border-color,box-shadow] duration-150',
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 text-sm outline-none placeholder:text-base-content/40'
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
- 'h-10 w-full rounded-lg border bg-base-100 pl-3 pr-9 text-sm outline-none transition-[border-color,box-shadow] duration-150 placeholder:text-base-content/40',
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 class="input flex items-center gap-0.5 pr-2 {error ? 'input-error' : ''}">
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 text-sm tabular-nums focus:bg-primary focus:text-primary-content focus:outline-none data-[type=literal]:px-0 data-[type=literal]:text-base-content/50'
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, getInputSizeClass } from '../../utils.js'
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
- 'input flex flex-wrap items-center gap-1 min-h-[2.5rem] h-auto py-1',
38
- getInputSizeClass(size),
39
- disabled && 'input-disabled',
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
- class={containerClass}
84
- onclick={() => inputElement?.focus()}
85
- onkeydown={e => e.key === 'Enter' && inputElement?.focus()}
86
- role='textbox'
87
- tabindex='-1'
88
- >
89
- {#each tags as tag, index (index)}
90
- <Tag
91
- text={tag}
92
- size='sm'
93
- closable={!disabled}
94
- onclose={() => removeTag(index)}
95
- />
96
- {/each}
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
- {#if canAddMore && !disabled}
99
- <BareInput
100
- type='text'
101
- class='flex-1 min-w-[100px] bg-transparent border-none outline-none text-sm'
102
- bind:value={inputValue}
103
- bind:inputRef={inputElement}
104
- placeholder={displayPlaceholder}
105
- {disabled}
106
- onkeydown={handleKeydown}
107
- onblur={handleBlur}
108
- />
109
- {/if}
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 containerHeight = $derived(
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
- containerHeight,
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
- inputSpacingClass,
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
- // 尺寸映射(与 Input 保持一致)
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[size] || sizeClasses.md,
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 { renderMermaidDiagram } from './mermaid-render.js'
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
- // html 是最终注入正文的内容。
423
- const html = $derived(renderResult.html)
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 html
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
- void renderMermaidHost(mermaidEl, result.content)
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
- void renderMermaidHost(host, block.code)
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(host: HTMLElement, code: string): Promise<void> {
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
- host.innerHTML = svg
1014
- host.dataset.mermaidStatus = 'ready'
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
- host.dataset.mermaidStatus = 'error'
1018
- host.innerHTML = `<div class="hai-md-mermaid-error">${escapePreviewText(error instanceof Error ? error.message : uiM('markdown_mermaid_failed'))}</div>`
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
- <!-- eslint-disable-next-line svelte/no-at-html-tags -- Markdown HTML 渲染 -->
3176
- {@html html}
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, '&amp;')
19
+ .replace(/</g, '&lt;')
20
+ .replace(/>/g, '&gt;')
21
+ .replace(/"/g, '&quot;')
22
+ .replace(/'/g, '&#39;');
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 exportHtml = buildDocumentExportHtml(exportTitle, rendered.html);
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), rendered.html);
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({ text, lang }) {
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) && !options.showCodePreviewToggle) {
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
- // result can be sync string or async promise; only sync string is used.
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
- containerHeight,
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 px-3 bg-transparent border-none outline-none text-sm placeholder:text-base-content/35'
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.33",
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.33"
54
+ "@h-ai/core": "0.1.0-alpha.34"
55
55
  },
56
56
  "devDependencies": {
57
57
  "@sveltejs/package": "^2.5.7",