@h-ai/ui 0.1.0-alpha.30 → 0.1.0-alpha.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/README.md +38 -3
  2. package/dist/components/compounds/Combobox.svelte +59 -20
  3. package/dist/components/compounds/MessageContainer.svelte +37 -64
  4. package/dist/components/compounds/Modal.svelte +24 -40
  5. package/dist/components/compounds/Pagination.svelte +3 -5
  6. package/dist/components/primitives/Select.svelte +293 -46
  7. package/dist/components/primitives/Textarea.svelte +28 -15
  8. package/dist/components/scenes/ai/AiDocumentEditor.svelte +34 -7
  9. package/dist/components/scenes/ai/MarkdownRenderer.svelte +46 -5
  10. package/dist/components/scenes/ai/document-parse.d.ts +2 -0
  11. package/dist/components/scenes/ai/document-parse.js +16 -17
  12. package/dist/components/scenes/ai/document-types.d.ts +4 -0
  13. package/dist/components/scenes/ai/markdown-parse.d.ts +2 -0
  14. package/dist/components/scenes/ai/markdown-parse.js +17 -20
  15. package/dist/components/scenes/ai/safe-html.d.ts +16 -0
  16. package/dist/components/scenes/ai/safe-html.js +284 -0
  17. package/dist/components/scenes/app/FeedbackModal.svelte +3 -2
  18. package/dist/components/scenes/app/FeedbackModal.svelte.d.ts +1 -0
  19. package/dist/components/scenes/app/SettingsModal.svelte +4 -2
  20. package/dist/components/scenes/app/SettingsModal.svelte.d.ts +1 -0
  21. package/dist/components/scenes/crud/CrudDeleteConfirm.svelte +3 -0
  22. package/dist/components/scenes/crud/CrudDeleteConfirm.svelte.d.ts +1 -0
  23. package/dist/components/scenes/crud/CrudDetailDrawer.svelte +4 -1
  24. package/dist/components/scenes/crud/CrudDetailDrawer.svelte.d.ts +1 -0
  25. package/dist/components/scenes/crud/CrudDetailPanel.svelte +4 -1
  26. package/dist/components/scenes/crud/CrudDetailPanel.svelte.d.ts +1 -0
  27. package/dist/components/scenes/crud/CrudEditPanel.svelte +9 -7
  28. package/dist/components/scenes/crud/CrudEditPanel.svelte.d.ts +1 -0
  29. package/dist/components/scenes/crud/CrudFilterBar.svelte +15 -13
  30. package/dist/components/scenes/crud/CrudFilterBar.svelte.d.ts +1 -0
  31. package/dist/components/scenes/types.d.ts +4 -0
  32. package/dist/styles/global.css +5 -0
  33. package/dist/types.d.ts +5 -5
  34. package/dist/utils.d.ts +13 -0
  35. package/dist/utils.js +25 -1
  36. package/package.json +2 -2
@@ -2,15 +2,15 @@
2
2
  =============================================================================
3
3
  @h-ai/ui - Select 组件
4
4
  =============================================================================
5
- 下拉选择框组件
5
+ 下拉选择框组件(全自定义实现,统一弹出层样式)
6
6
 
7
7
  使用 Svelte 5 Runes ($props, $derived, $bindable)
8
- 支持自定义验证消息(validationMessage)覆盖浏览器原生提示
8
+ 支持 clearable(可清空)和 filterable(可筛选)属性
9
9
  =============================================================================
10
10
  -->
11
11
  <script lang='ts' generics="T = string">
12
12
  import type { DataAttributes, SelectProps } from '../../types.js'
13
- import { cn, getDataAttributes, getSizeClass } from '../../utils.js'
13
+ import { cn, getDataAttributes, portal } from '../../utils.js'
14
14
 
15
15
  let {
16
16
  value = $bindable<T>(),
@@ -19,78 +19,325 @@
19
19
  size = 'md',
20
20
  disabled = false,
21
21
  required = false,
22
+ clearable = false,
23
+ filterable = false,
22
24
  error = '',
23
- validationMessage = '',
24
25
  class: className = '',
25
26
  id,
26
27
  onchange,
27
- children,
28
28
  ...restProps
29
29
  }: SelectProps<T> & DataAttributes = $props()
30
30
 
31
31
  const dataAttributes = $derived(getDataAttributes(restProps))
32
- let selectRef: HTMLSelectElement | undefined = $state()
32
+ let inputRef: HTMLInputElement | undefined = $state()
33
+ let dropdownRef: HTMLDivElement | undefined = $state()
34
+ let triggerRef: HTMLDivElement | undefined = $state()
35
+ let listboxRef: HTMLUListElement | undefined = $state()
36
+ let isDropdownOpen = $state(false)
37
+ let filterText = $state('')
38
+ let isHovered = $state(false)
39
+ // 下拉层定位样式(portal 到 body,用 fixed 逃逸 overflow/stacking)
40
+ let dropdownStyle = $state('')
33
41
 
34
- const selectClass = $derived(
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
+ }
50
+
51
+ // 选项文字尺寸
52
+ const optionTextClass = $derived(
53
+ size === 'xs'
54
+ ? 'text-xs'
55
+ : size === 'lg'
56
+ ? 'text-base'
57
+ : size === 'xl'
58
+ ? 'text-lg'
59
+ : 'text-sm',
60
+ )
61
+
62
+ // 获取当前选中的标签
63
+ const selectedLabel = $derived(() => {
64
+ if (!options || !value)
65
+ return ''
66
+ const selected = options.find(opt => opt.value === value)
67
+ return selected ? selected.label : ''
68
+ })
69
+
70
+ // 过滤后的选项
71
+ const filteredOptions = $derived(() => {
72
+ if (!options)
73
+ return []
74
+ if (!filterable || !filterText)
75
+ return options
76
+ return options.filter(opt =>
77
+ opt.label.toLowerCase().includes(filterText.toLowerCase()),
78
+ )
79
+ })
80
+
81
+ const wrapperClass = $derived(
35
82
  cn(
36
- 'select w-full',
37
- getSizeClass(size, 'input'),
38
- error && 'select-error',
83
+ 'relative flex w-full min-w-0 items-center rounded-lg border bg-base-100 cursor-pointer',
84
+ sizeClasses[size] || sizeClasses.md,
85
+ error
86
+ ? 'border-error/60 focus-within:ring-2 focus-within:ring-error/15'
87
+ : isDropdownOpen
88
+ ? 'border-primary/50 ring-2 ring-primary/10'
89
+ : 'border-base-content/15 hover:border-base-content/25 focus-within:border-primary/50 focus-within:ring-2 focus-within:ring-primary/10',
90
+ 'transition-[border-color,box-shadow] duration-150',
91
+ disabled && 'opacity-50 cursor-not-allowed',
39
92
  className,
40
93
  ),
41
94
  )
42
95
 
43
- // 当 validationMessage 变化时更新自定义验证消息
96
+ const inputClass = $derived(
97
+ cn(
98
+ 'flex-1 min-w-0 bg-transparent outline-none border-none',
99
+ 'focus:outline-none focus:ring-0 focus:shadow-none',
100
+ 'placeholder:text-base-content/40',
101
+ error && 'text-error',
102
+ ),
103
+ )
104
+
105
+ // 点击外部关闭下拉框(下拉层已 portal 到 body,需同时排除 listbox)
106
+ $effect(() => {
107
+ function handleClickOutside(e: MouseEvent) {
108
+ const target = e.target as Node
109
+ const inTrigger = dropdownRef?.contains(target) ?? false
110
+ const inListbox = listboxRef?.contains(target) ?? false
111
+ if (!inTrigger && !inListbox) {
112
+ isDropdownOpen = false
113
+ filterText = ''
114
+ }
115
+ }
116
+
117
+ if (isDropdownOpen) {
118
+ document.addEventListener('click', handleClickOutside)
119
+ return () => document.removeEventListener('click', handleClickOutside)
120
+ }
121
+ })
122
+
123
+ // 计算下拉层位置(fixed,基于触发器视口坐标;空间不足时向上翻转)
124
+ function updateDropdownPosition() {
125
+ if (!triggerRef)
126
+ return
127
+ const rect = triggerRef.getBoundingClientRect()
128
+ const gap = 4
129
+ const maxHeight = 240
130
+ const spaceBelow = window.innerHeight - rect.bottom
131
+ const openUp = spaceBelow < maxHeight + gap && rect.top > spaceBelow
132
+ const vertical = openUp
133
+ ? `bottom:${Math.round(window.innerHeight - rect.top + gap)}px`
134
+ : `top:${Math.round(rect.bottom + gap)}px`
135
+ dropdownStyle = `position:fixed;left:${Math.round(rect.left)}px;width:${Math.round(rect.width)}px;${vertical};z-index:1200;`
136
+ }
137
+
138
+ // 打开时定位并跟随滚动/缩放更新
44
139
  $effect(() => {
45
- if (selectRef) {
46
- selectRef.setCustomValidity(validationMessage)
140
+ if (!isDropdownOpen)
141
+ return
142
+ updateDropdownPosition()
143
+ const scrollOpts = { passive: true, capture: true } as const
144
+ window.addEventListener('scroll', updateDropdownPosition, scrollOpts)
145
+ window.addEventListener('resize', updateDropdownPosition)
146
+ return () => {
147
+ window.removeEventListener('scroll', updateDropdownPosition, scrollOpts)
148
+ window.removeEventListener('resize', updateDropdownPosition)
47
149
  }
48
150
  })
49
151
 
50
- function handleChange(e: Event & { currentTarget: HTMLSelectElement }) {
51
- const selectedValue = e.currentTarget.value as T
52
- value = selectedValue
53
- // 选择后重置自定义验证
54
- if (validationMessage) {
55
- e.currentTarget.setCustomValidity(validationMessage)
152
+ function handleWrapperClick() {
153
+ if (disabled)
154
+ return
155
+
156
+ if (!isDropdownOpen) {
157
+ isDropdownOpen = true
158
+ }
159
+
160
+ if (filterable) {
161
+ // filterable 模式下点击后始终聚焦输入框,保持下拉可筛状态
162
+ setTimeout(() => inputRef?.focus(), 0)
163
+ }
164
+ }
165
+
166
+ function handleOptionSelect(optionValue: T, optionDisabled?: boolean) {
167
+ if (optionDisabled)
168
+ return
169
+ value = optionValue
170
+ isDropdownOpen = false
171
+ filterText = ''
172
+ onchange?.(optionValue)
173
+ }
174
+
175
+ function handleFilterInput(e: Event & { currentTarget: HTMLInputElement }) {
176
+ filterText = e.currentTarget.value
177
+ if (!isDropdownOpen) {
178
+ isDropdownOpen = true
56
179
  }
57
- onchange?.(selectedValue)
58
180
  }
59
181
 
60
- function handleInvalid(e: Event & { currentTarget: HTMLSelectElement }) {
61
- if (validationMessage) {
62
- e.currentTarget.setCustomValidity(validationMessage)
182
+ function handleInputKeydown(e: KeyboardEvent) {
183
+ if (e.key === 'Escape') {
184
+ isDropdownOpen = false
185
+ filterText = ''
186
+ inputRef?.blur()
187
+ }
188
+ else if (e.key === 'Enter') {
189
+ // 回车选中第一个匹配项
190
+ const opts = filteredOptions()
191
+ if (opts.length > 0 && !opts[0].disabled) {
192
+ handleOptionSelect(opts[0].value)
193
+ }
194
+ }
195
+ else if (e.key === 'Backspace' && filterText === '' && clearable && value) {
196
+ value = undefined as T
197
+ onchange?.(undefined as T)
63
198
  }
64
199
  }
200
+
201
+ function handleClear(e: MouseEvent) {
202
+ e.stopPropagation()
203
+ e.preventDefault()
204
+ value = undefined as T
205
+ filterText = ''
206
+ onchange?.(undefined as T)
207
+ }
208
+
209
+ // 判断是否显示清除按钮
210
+ const showClear = $derived(clearable && value !== undefined && value !== null && value !== '' && isHovered)
65
211
  </script>
66
212
 
67
- <div {...dataAttributes} class='fieldset w-full'>
68
- <select
69
- bind:this={selectRef}
70
- {id}
71
- class={selectClass}
72
- {disabled}
73
- {required}
74
- onchange={handleChange}
75
- oninvalid={handleInvalid}
213
+ <div
214
+ {...dataAttributes}
215
+ class='fieldset w-full min-w-0'
216
+ bind:this={dropdownRef}
217
+ role='combobox'
218
+ aria-expanded={isDropdownOpen}
219
+ aria-haspopup='listbox'
220
+ onmouseenter={() => isHovered = true}
221
+ onmouseleave={() => isHovered = false}
222
+ >
223
+ <div
224
+ class={wrapperClass}
225
+ bind:this={triggerRef}
226
+ onclick={handleWrapperClick}
227
+ onkeydown={(e) => {
228
+ if (e.key === 'Enter' || e.key === ' ') {
229
+ e.preventDefault()
230
+ handleWrapperClick()
231
+ }
232
+ }}
233
+ role='presentation'
76
234
  >
77
- {#if placeholder}
78
- <option value="" disabled selected={!value}>{placeholder}</option>
235
+ {#if filterable}
236
+ <!-- 可筛选模式:可输入的 input -->
237
+ <input
238
+ bind:this={inputRef}
239
+ {id}
240
+ type='text'
241
+ class={inputClass}
242
+ {disabled}
243
+ {required}
244
+ {placeholder}
245
+ value={isDropdownOpen ? filterText : selectedLabel()}
246
+ oninput={handleFilterInput}
247
+ onkeydown={handleInputKeydown}
248
+ onfocus={() => {
249
+ if (!disabled && !isDropdownOpen)
250
+ isDropdownOpen = true
251
+ }}
252
+ role='combobox'
253
+ aria-autocomplete='list'
254
+ aria-controls='{id}-listbox'
255
+ aria-expanded={isDropdownOpen}
256
+ />
257
+ {:else}
258
+ <!-- 普通模式:只读 input 显示选中值 -->
259
+ <input
260
+ {id}
261
+ type='text'
262
+ class={inputClass}
263
+ readonly
264
+ {disabled}
265
+ {required}
266
+ {placeholder}
267
+ value={selectedLabel()}
268
+ tabindex='-1'
269
+ />
79
270
  {/if}
80
- {#if children}
81
- {@render children()}
82
- {:else if options}
83
- {#each options as option (String(option.value))}
84
- <option
85
- value={option.value as string}
86
- disabled={option.disabled}
87
- selected={value === option.value}
271
+
272
+ <!-- 右侧图标区域 -->
273
+ <div class='flex items-center pr-2 shrink-0'>
274
+ {#if showClear}
275
+ <button
276
+ type='button'
277
+ class='flex items-center justify-center w-5 h-5 rounded-full hover:bg-base-content/10 transition-colors'
278
+ onclick={handleClear}
279
+ tabindex='-1'
280
+ aria-label='清空选择'
88
281
  >
89
- {option.label}
90
- </option>
91
- {/each}
282
+ <svg xmlns='http://www.w3.org/2000/svg' class='h-3.5 w-3.5' fill='none' viewBox='0 0 24 24' stroke='currentColor'>
283
+ <path stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M6 18L18 6M6 6l12 12' />
284
+ </svg>
285
+ </button>
286
+ {:else}
287
+ <svg
288
+ class={cn('h-4 w-4 text-base-content/40 transition-transform duration-200', isDropdownOpen && 'rotate-180')}
289
+ xmlns='http://www.w3.org/2000/svg'
290
+ viewBox='0 0 20 20'
291
+ fill='currentColor'
292
+ >
293
+ <path fill-rule='evenodd' d='M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z' clip-rule='evenodd' />
294
+ </svg>
295
+ {/if}
296
+ </div>
297
+
298
+ <!-- 下拉选项列表(portal 到 body,fixed 定位,逃逸 overflow/stacking 裁剪) -->
299
+ {#if isDropdownOpen}
300
+ <ul
301
+ bind:this={listboxRef}
302
+ use:portal
303
+ id='{id}-listbox'
304
+ class='max-h-60 overflow-auto rounded-xl border border-base-content/10 bg-base-100 p-1.5 shadow-xl shadow-base-content/10'
305
+ style={dropdownStyle}
306
+ role='listbox'
307
+ >
308
+ {#each filteredOptions() as option (String(option.value))}
309
+ <li
310
+ class={cn(
311
+ 'flex cursor-pointer items-center rounded-lg px-3 py-2.5 transition-all duration-150',
312
+ optionTextClass,
313
+ option.disabled ? 'opacity-40 cursor-not-allowed' : 'hover:bg-base-content/5 active:bg-base-content/8',
314
+ value === option.value && 'bg-primary/10 text-primary font-semibold',
315
+ )}
316
+ role='option'
317
+ aria-selected={value === option.value}
318
+ aria-disabled={option.disabled}
319
+ onclick={(e) => {
320
+ e.stopPropagation()
321
+ handleOptionSelect(option.value, option.disabled)
322
+ }}
323
+ onkeydown={(e) => {
324
+ if (e.key === 'Enter' || e.key === ' ') {
325
+ e.preventDefault()
326
+ handleOptionSelect(option.value, option.disabled)
327
+ }
328
+ }}
329
+ tabindex={option.disabled ? -1 : 0}
330
+ >
331
+ {option.label}
332
+ </li>
333
+ {:else}
334
+ <li class={cn('px-3 py-3 text-base-content/40 text-center', optionTextClass)}>
335
+ 无匹配选项
336
+ </li>
337
+ {/each}
338
+ </ul>
92
339
  {/if}
93
- </select>
340
+ </div>
94
341
  {#if error}
95
342
  <span class='fieldset-label text-error'>{error}</span>
96
343
  {/if}
@@ -33,9 +33,20 @@
33
33
  const dataAttributes = $derived(getDataAttributes(restProps))
34
34
  let textareaRef: HTMLTextAreaElement | undefined = $state()
35
35
 
36
+ const wrapperClass = $derived(
37
+ cn(
38
+ 'flex w-full rounded-lg border bg-base-100',
39
+ error
40
+ ? 'border-error/60 focus-within:ring-2 focus-within:ring-error/15'
41
+ : 'border-base-content/15 focus-within:border-primary/50 focus-within:ring-2 focus-within:ring-primary/10',
42
+ 'transition-[border-color,box-shadow] duration-150',
43
+ disabled && 'opacity-50 cursor-not-allowed',
44
+ ),
45
+ )
46
+
36
47
  const textareaClass = $derived(
37
48
  cn(
38
- 'textarea w-full',
49
+ 'textarea w-full bg-transparent border-none outline-none',
39
50
  getInputSizeClass(size),
40
51
  error && 'textarea-error',
41
52
  className,
@@ -74,20 +85,22 @@
74
85
  </script>
75
86
 
76
87
  <div {...dataAttributes} class='fieldset w-full'>
77
- <textarea
78
- bind:this={textareaRef}
79
- {id}
80
- {name}
81
- {placeholder}
82
- {rows}
83
- {disabled}
84
- {readonly}
85
- {required}
86
- class={textareaClass}
87
- bind:value
88
- oninput={handleInput}
89
- oninvalid={handleInvalid}
90
- ></textarea>
88
+ <div class={wrapperClass}>
89
+ <textarea
90
+ bind:this={textareaRef}
91
+ {id}
92
+ {name}
93
+ {placeholder}
94
+ {rows}
95
+ {disabled}
96
+ {readonly}
97
+ {required}
98
+ class={textareaClass}
99
+ bind:value
100
+ oninput={handleInput}
101
+ oninvalid={handleInvalid}
102
+ ></textarea>
103
+ </div>
91
104
  {#if error}
92
105
  <span class='fieldset-label text-error'>{error}</span>
93
106
  {/if}
@@ -240,6 +240,8 @@
240
240
  sourceKind = 'markdown',
241
241
  // sourceKind=code 时的默认语言提示。
242
242
  codeLanguage,
243
+ // 正文字号,number 会按 px 处理。
244
+ fontSize,
243
245
  // 外层自定义类名。
244
246
  class: className = '',
245
247
  // 是否显示代码块复制按钮。
@@ -250,6 +252,8 @@
250
252
  showCodePreviewToggle = false,
251
253
  // 是否显式允许内置 HTML / JS / CSS 预览执行高风险内容。
252
254
  allowUnsafeCodePreview = false,
255
+ // 是否按安全白名单解析原始 HTML 标签。
256
+ allowHtmlTags = false,
253
257
  // 代码预览切换模式下展示在语言标签旁的提示文案。
254
258
  codePreviewHint = '',
255
259
  // 是否启用语法高亮。
@@ -412,6 +416,7 @@
412
416
  codeViewPreviewLabel: uiM('file_list_preview'),
413
417
  codePreviewHint: normalizedCodePreviewHint,
414
418
  breaks,
419
+ allowHtmlTags,
415
420
  }),
416
421
  )
417
422
  // html 是最终注入正文的内容。
@@ -466,6 +471,25 @@
466
471
  editable ? 'hai-markdown-editable' : '',
467
472
  ),
468
473
  )
474
+ const normalizedFontSize = $derived(resolveFontSize(fontSize))
475
+ const documentStyle = $derived(
476
+ normalizedFontSize
477
+ ? `--hai-markdown-font-size:${normalizedFontSize};`
478
+ : undefined,
479
+ )
480
+
481
+ function resolveFontSize(value: AiDocumentEditorProps['fontSize']): string | undefined {
482
+ if (typeof value === 'number' && Number.isFinite(value)) {
483
+ return `${value}px`
484
+ }
485
+
486
+ if (typeof value === 'string') {
487
+ const normalized = value.trim()
488
+ return normalized || undefined
489
+ }
490
+
491
+ return undefined
492
+ }
469
493
 
470
494
  $effect(() => {
471
495
  void html
@@ -936,6 +960,7 @@
936
960
  enableHighlight,
937
961
  showCopyButton: false,
938
962
  breaks,
963
+ allowHtmlTags,
939
964
  })
940
965
 
941
966
  host.innerHTML = `<div class="hai-md-preview-card"><div class="hai-md-preview-head">${previewTitle}</div>${previewDesc}<div class="hai-md-preview-rendered">${markdownHtml}</div></div>`
@@ -3138,6 +3163,7 @@
3138
3163
  <article
3139
3164
  bind:this={previewHost}
3140
3165
  class={readerDocumentClass}
3166
+ style={documentStyle}
3141
3167
  contenteditable={editable}
3142
3168
  role='document'
3143
3169
  onmousemove={handlePreviewMouseMove}
@@ -3993,6 +4019,7 @@
3993
4019
  }
3994
4020
 
3995
4021
  .hai-markdown-document {
4022
+ font-size: var(--hai-markdown-font-size, 1rem);
3996
4023
  width: 100%;
3997
4024
  min-width: 0;
3998
4025
  max-width: 52.5rem;
@@ -4045,29 +4072,29 @@
4045
4072
  }
4046
4073
 
4047
4074
  .hai-markdown :global(h1) {
4048
- font-size: 2.2rem;
4075
+ font-size: 2.2em;
4049
4076
  padding-bottom: 0.45em;
4050
4077
  border-bottom: 1px solid oklch(var(--bc) / 0.12);
4051
4078
  }
4052
4079
 
4053
4080
  .hai-markdown :global(h2) {
4054
- font-size: 1.48rem;
4081
+ font-size: 1.48em;
4055
4082
  }
4056
4083
 
4057
4084
  .hai-markdown :global(h3) {
4058
- font-size: 1.18rem;
4085
+ font-size: 1.18em;
4059
4086
  }
4060
4087
 
4061
4088
  .hai-markdown :global(h4) {
4062
- font-size: 1.02rem;
4089
+ font-size: 1.02em;
4063
4090
  }
4064
4091
 
4065
4092
  .hai-markdown :global(h5) {
4066
- font-size: 0.95rem;
4093
+ font-size: 0.95em;
4067
4094
  }
4068
4095
 
4069
4096
  .hai-markdown :global(h6) {
4070
- font-size: 0.88rem;
4097
+ font-size: 0.88em;
4071
4098
  color: oklch(var(--bc) / 0.7);
4072
4099
  }
4073
4100
 
@@ -4264,7 +4291,7 @@
4264
4291
  font-family:
4265
4292
  ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas,
4266
4293
  'Liberation Mono', monospace;
4267
- font-size: 0.875rem;
4294
+ font-size: 0.875em;
4268
4295
  line-height: 1.45;
4269
4296
  tab-size: 2;
4270
4297
 
@@ -24,19 +24,45 @@
24
24
 
25
25
  const {
26
26
  content = '',
27
+ fontSize,
27
28
  class: className = '',
28
29
  showCopyButton = true,
29
30
  enableHighlight = true,
30
31
  breaks = true,
32
+ allowHtmlTags = false,
31
33
  ...restProps
32
34
  }: MarkdownRendererProps & DataAttributes = $props()
33
35
 
34
36
  const dataAttributes = $derived(getDataAttributes(restProps))
37
+ const normalizedFontSize = $derived(resolveFontSize(fontSize))
38
+ const markdownStyle = $derived(
39
+ normalizedFontSize
40
+ ? `--hai-markdown-font-size:${normalizedFontSize};`
41
+ : undefined,
42
+ )
35
43
  /** 解析后的 HTML */
36
44
  const html = $derived(
37
- parseMarkdown(content, { enableHighlight, showCopyButton, breaks }),
45
+ parseMarkdown(content, {
46
+ enableHighlight,
47
+ showCopyButton,
48
+ breaks,
49
+ allowHtmlTags,
50
+ }),
38
51
  )
39
52
 
53
+ function resolveFontSize(value: MarkdownRendererProps['fontSize']): string | undefined {
54
+ if (typeof value === 'number' && Number.isFinite(value)) {
55
+ return `${value}px`
56
+ }
57
+
58
+ if (typeof value === 'string') {
59
+ const normalized = value.trim()
60
+ return normalized || undefined
61
+ }
62
+
63
+ return undefined
64
+ }
65
+
40
66
  /**
41
67
  * 事件代理:处理代码块复制按钮点击
42
68
  */
@@ -65,9 +91,11 @@
65
91
  }
66
92
  </script>
67
93
 
68
- <div {...dataAttributes}
69
- class={cn('hai-markdown', className)}
70
- onclick={handleClick}
94
+ <div
95
+ {...dataAttributes}
96
+ class={cn('hai-markdown', className)}
97
+ style={markdownStyle}
98
+ onclick={handleClick}
71
99
  >
72
100
  <!-- eslint-disable-next-line svelte/no-at-html-tags -- Markdown HTML 渲染 -->
73
101
  {@html html}
@@ -81,6 +109,7 @@
81
109
  * ========================================================================= */
82
110
 
83
111
  .hai-markdown {
112
+ font-size: var(--hai-markdown-font-size, 1rem);
84
113
  line-height: 1.75;
85
114
  color: inherit;
86
115
  word-wrap: break-word;
@@ -171,6 +200,18 @@
171
200
  color: oklch(var(--bc) / 0.55);
172
201
  }
173
202
 
203
+ .hai-markdown :global(u) {
204
+ text-decoration-thickness: 0.08em;
205
+ text-underline-offset: 0.14em;
206
+ }
207
+
208
+ .hai-markdown :global(mark) {
209
+ padding: 0 0.12em;
210
+ border-radius: 0.25rem;
211
+ background: oklch(var(--wa, 0.9 0.14 90) / 0.34);
212
+ color: inherit;
213
+ }
214
+
174
215
  /* ─── 行内代码 ─── */
175
216
 
176
217
  .hai-markdown :global(:not(pre) > code) {
@@ -245,7 +286,7 @@
245
286
 
246
287
  .hai-markdown :global(.hai-md-code-block code) {
247
288
  font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace;
248
- font-size: 0.875rem;
289
+ font-size: 0.875em;
249
290
  line-height: 1.6;
250
291
  tab-size: 2;
251
292
  }
@@ -21,6 +21,8 @@ export interface MarkdownDocumentParseOptions {
21
21
  codePreviewHint?: string;
22
22
  /** Whether soft line breaks are converted to <br>. */
23
23
  breaks?: boolean;
24
+ /** Whether raw HTML tags are parsed through a safe allowlist. */
25
+ allowHtmlTags?: boolean;
24
26
  }
25
27
  export interface MarkdownRenderResult {
26
28
  /** Rendered HTML output. */