@h-ai/ui 0.1.0-alpha.19 → 0.1.0-alpha.21
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 +49 -11
- package/auto-import.d.ts +22 -0
- package/auto-import.js +9 -1
- package/dist/components/primitives/IconButton.svelte +32 -6
- package/dist/components/scenes/ai/AiDocumentEditor.svelte +20 -136
- package/dist/components/scenes/ai/MarkdownRenderer.svelte +12 -10
- package/dist/components/scenes/ai/code-preview.d.ts +18 -0
- package/dist/components/scenes/ai/code-preview.js +112 -0
- package/dist/components/scenes/ai/document-types.d.ts +14 -0
- package/dist/components/scenes/crypto/EncryptedInput.svelte +2 -1
- package/dist/components/scenes/crypto/HashDisplay.svelte +3 -6
- package/dist/components/scenes/crypto/SignatureDisplay.svelte +5 -12
- package/dist/components/scenes/storage/AvatarUpload.svelte +52 -7
- package/dist/components/scenes/storage/FileUpload.svelte +88 -18
- package/dist/i18n.svelte.js +3 -2
- package/dist/internal/browser-safety.d.ts +26 -0
- package/dist/internal/browser-safety.js +58 -0
- package/dist/theme-config.d.ts +3 -2
- package/dist/theme-config.js +8 -8
- package/dist/types.d.ts +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @h-ai/ui
|
|
2
2
|
|
|
3
|
-
> 基于 Svelte 5 Runes 的多端 UI 组件库,采用 DaisyUI v5 + Tailwind CSS v4 样式 + Bits UI v2 headless 交互,内置 i18n(zh-CN / en-US
|
|
3
|
+
> 基于 Svelte 5 Runes 的多端 UI 组件库,采用 DaisyUI v5 + Tailwind CSS v4 样式 + Bits UI v2 headless 交互,内置 i18n(zh-CN / en-US),内置 15 个精选 DaisyUI 主题。
|
|
4
4
|
|
|
5
5
|
## 安装
|
|
6
6
|
|
|
@@ -84,6 +84,8 @@ export default defineConfig({
|
|
|
84
84
|
<Button variant="primary">提交</Button>
|
|
85
85
|
```
|
|
86
86
|
|
|
87
|
+
> 除 `toast`、类型导入与 `Range`(会和 DOM `Range` 构造器冲突)外,其余公开 Svelte 组件都可通过 `@h-ai/ui/auto-import` 自动注入。
|
|
88
|
+
|
|
87
89
|
## 组件架构
|
|
88
90
|
|
|
89
91
|
组件按三层划分(primitives → compounds → scenes):
|
|
@@ -93,7 +95,9 @@ components/
|
|
|
93
95
|
├── primitives/ # 原子组件(不可再分的基础 UI 单元)
|
|
94
96
|
├── compounds/ # 组合组件(由原子组件 + Bits UI headless 组合而成)
|
|
95
97
|
└── scenes/ # 场景组件(面向具体业务场景的完整 UI 流程)
|
|
98
|
+
├── ai/ # AI 文档 / 表格 / Markdown 预览
|
|
96
99
|
├── app/ # 应用级(设置/反馈/主题/语言切换)
|
|
100
|
+
├── crud/ # CRUD 页面 / 抽屉 / 过滤 / 删除确认
|
|
97
101
|
├── iam/ # 身份认证
|
|
98
102
|
├── storage/ # 存储管理
|
|
99
103
|
└── crypto/ # 加密展示
|
|
@@ -101,12 +105,12 @@ components/
|
|
|
101
105
|
|
|
102
106
|
## 组件清单
|
|
103
107
|
|
|
104
|
-
### 原子组件 Primitives(
|
|
108
|
+
### 原子组件 Primitives(20 个)
|
|
105
109
|
|
|
106
110
|
| 组件 | 描述 | 主要属性 |
|
|
107
111
|
| ---------------- | ------------ | ------------------------------------------------------------------ |
|
|
108
112
|
| `Button` | 按钮 | `variant`, `size`, `loading`, `disabled`, `outline`, `circle` |
|
|
109
|
-
| `IconButton` | 图标按钮 | `icon: string \| Snippet`, `variant`, `size`, `tooltip`, `loading` |
|
|
113
|
+
| `IconButton` | 图标按钮 | `icon: trusted SVG string \| Snippet`, `variant`, `size`, `tooltip`, `loading` |
|
|
110
114
|
| `BareButton` | 无样式按钮 | `class`, `ariaLabel`, `role`, `tabindex` |
|
|
111
115
|
| `Input` | 输入框 | `type`, `value`, `size`, `error`, `validationMessage` |
|
|
112
116
|
| `BareInput` | 无样式输入框 | `type`, `class`, `accept`, `multiple` |
|
|
@@ -126,7 +130,7 @@ components/
|
|
|
126
130
|
| `Spinner` | 加载动画 | `size`, `variant` |
|
|
127
131
|
| `Progress` | 进度条 | `value`, `max`, `variant`, `striped`, `animated` |
|
|
128
132
|
|
|
129
|
-
### 组合组件 Compounds(
|
|
133
|
+
### 组合组件 Compounds(33 个)
|
|
130
134
|
|
|
131
135
|
#### 表单
|
|
132
136
|
|
|
@@ -171,6 +175,18 @@ components/
|
|
|
171
175
|
|
|
172
176
|
> 日期值使用 `@internationalized/date` 的 `DateValue` / `CalendarDate` 类型。
|
|
173
177
|
|
|
178
|
+
#### 移动端 / 增强交互
|
|
179
|
+
|
|
180
|
+
| 组件 | 描述 | 主要属性 |
|
|
181
|
+
| ---------------- | ------------ | --------------------------------------------------- |
|
|
182
|
+
| `ActionSheet` | 底部操作面板 | `open`, `title`, `items`, `cancelText`, `onselect` |
|
|
183
|
+
| `AppBar` | 顶部导航栏 | `title`, `backHref`, `onback`, `fixed`, `safeArea` |
|
|
184
|
+
| `BottomNav` | 底部导航栏 | `items`, `active`, `centered`, `maxWidth` |
|
|
185
|
+
| `InfiniteScroll` | 无限滚动 | `loading`, `finished`, `threshold`, `onload` |
|
|
186
|
+
| `PullRefresh` | 下拉刷新 | `refreshing`, `threshold`, `onrefresh` |
|
|
187
|
+
| `SafeArea` | 安全区域容器 | `top`, `bottom`, `left`, `right` |
|
|
188
|
+
| `SwipeCell` | 滑动操作单元 | `leftActions`, `rightActions`, `threshold`, `onaction` |
|
|
189
|
+
|
|
174
190
|
#### 导航
|
|
175
191
|
|
|
176
192
|
| 组件 | 描述 | 主要属性 |
|
|
@@ -196,7 +212,7 @@ components/
|
|
|
196
212
|
| ------------ | -------- | ------------------------------------------- |
|
|
197
213
|
| `PageHeader` | 页面头部 | `title`, `description`,支持 `actions` 插槽 |
|
|
198
214
|
|
|
199
|
-
### 场景组件 Scenes(
|
|
215
|
+
### 场景组件 Scenes(30 个)
|
|
200
216
|
|
|
201
217
|
#### App 应用级(6 个)
|
|
202
218
|
|
|
@@ -209,7 +225,7 @@ components/
|
|
|
209
225
|
| `ThemeSelector` | 完整主题选择面板 | 无需 Props |
|
|
210
226
|
| `ThemeToggle` | 明/暗主题切换 | 无需 Props |
|
|
211
227
|
|
|
212
|
-
#### IAM 身份认证(
|
|
228
|
+
#### IAM 身份认证(8 个)
|
|
213
229
|
|
|
214
230
|
| 组件 | 描述 | 主要属性 |
|
|
215
231
|
| -------------------- | ---------- | ----------------------------------------------------- |
|
|
@@ -219,6 +235,7 @@ components/
|
|
|
219
235
|
| `ResetPasswordForm` | 重置密码 | `loading`, `errors`, `showCode`, `onsubmit` |
|
|
220
236
|
| `ChangePasswordForm` | 修改密码 | `loading`, `errors`, `requireOldPassword`, `onsubmit` |
|
|
221
237
|
| `PasswordInput` | 密码输入框 | `value`, `showToggle`, `showStrength`, `minLength` |
|
|
238
|
+
| `PermGuard` | 权限守卫 | `permissions`, `mode`, `fallback` |
|
|
222
239
|
| `UserProfile` | 用户资料 | `user`, `editable`, `fields`, `onsubmit` |
|
|
223
240
|
|
|
224
241
|
#### Storage 存储(4 个)
|
|
@@ -240,6 +257,27 @@ components/
|
|
|
240
257
|
|
|
241
258
|
默认算法与 `@h-ai/crypto` 对齐:加密输入使用 `SM4`,哈希展示使用 `SM3`,签名展示使用 `SM2`。
|
|
242
259
|
|
|
260
|
+
#### AI 文档与表格(4 个)
|
|
261
|
+
|
|
262
|
+
| 组件 | 描述 | 主要属性 / 能力 |
|
|
263
|
+
| ------------------------ | ---------------- | ---------------- |
|
|
264
|
+
| `MarkdownRenderer` | Markdown 渲染器 | `content`, `showCopyButton`, `enableHighlight` |
|
|
265
|
+
| `AiDocumentDownloadMenu` | 文档下载菜单 | `actions`, `ondownload` |
|
|
266
|
+
| `AiDocumentEditor` | AI 文档编辑器 | `content`, `showRunButton`, `showCodePreviewToggle`, `allowUnsafeCodePreview` |
|
|
267
|
+
| `AiTableEditor` | AI 表格编辑器 | `columns`, `rows`, `editable`, `ondownload` |
|
|
268
|
+
|
|
269
|
+
> `AiDocumentEditor` 默认只允许 Markdown 内置预览。HTML / JS / CSS 等高风险预览需要显式设置 `allowUnsafeCodePreview`,或通过 `oncoderun` 返回受控的预览结果。
|
|
270
|
+
|
|
271
|
+
#### CRUD 场景(5 个)
|
|
272
|
+
|
|
273
|
+
| 组件 | 描述 | 主要属性 / 能力 |
|
|
274
|
+
| -------------------- | ------------ | ---------------- |
|
|
275
|
+
| `CrudPage` | CRUD 主页面 | `definition`, `navAdapter`, `loading` |
|
|
276
|
+
| `CrudFilterBar` | 过滤工具栏 | `filters`, `query`, `onchange` |
|
|
277
|
+
| `CrudDetailDrawer` | 详情抽屉 | `open`, `item`, `fields` |
|
|
278
|
+
| `CrudEditDrawer` | 编辑抽屉 | `open`, `schema`, `onsubmit` |
|
|
279
|
+
| `CrudDeleteConfirm` | 删除确认框 | `open`, `item`, `onconfirm` |
|
|
280
|
+
|
|
243
281
|
## 使用示例
|
|
244
282
|
|
|
245
283
|
### Toast 通知
|
|
@@ -427,25 +465,25 @@ npm install -D @iconify/tailwind4 @iconify-json/tabler
|
|
|
427
465
|
|
|
428
466
|
### 主题切换
|
|
429
467
|
|
|
430
|
-
使用内置的主题工具函数管理
|
|
468
|
+
使用内置的主题工具函数管理 15 个精选 DaisyUI 主题:
|
|
431
469
|
|
|
432
470
|
```ts
|
|
433
471
|
import {
|
|
434
472
|
applyTheme, // 应用主题(自动持久化到 localStorage)
|
|
435
473
|
getCurrentTheme, // 获取当前主题
|
|
436
|
-
getThemeInitScript, //
|
|
474
|
+
getThemeInitScript, // 返回可注入到 HTML shell 的防闪烁脚本文本
|
|
437
475
|
isDarkTheme, // 检查是否暗色主题
|
|
438
476
|
THEME_GROUPS, // 按亮色/暗色分组
|
|
439
|
-
THEMES, // ThemeInfo[] —
|
|
477
|
+
THEMES, // ThemeInfo[] — 15 个精选主题元数据
|
|
440
478
|
} from '@h-ai/ui'
|
|
441
479
|
```
|
|
442
480
|
|
|
443
|
-
在 `app.html`
|
|
481
|
+
在 SvelteKit 的 `app.html` 中,请直接写入下面这段脚本内容(与 `getThemeInitScript()` 返回值一致):
|
|
444
482
|
|
|
445
483
|
```html
|
|
446
484
|
<head>
|
|
447
485
|
<script>
|
|
448
|
-
{
|
|
486
|
+
(function(){var t='light';try{var s=localStorage.getItem('theme');if(s)t=s}catch{}document.documentElement.setAttribute('data-theme',t)})()
|
|
449
487
|
</script>
|
|
450
488
|
</head>
|
|
451
489
|
```
|
package/auto-import.d.ts
CHANGED
|
@@ -27,6 +27,7 @@ declare global {
|
|
|
27
27
|
const Input: typeof import('@h-ai/ui')['Input']
|
|
28
28
|
const Progress: typeof import('@h-ai/ui')['Progress']
|
|
29
29
|
const Radio: typeof import('@h-ai/ui')['Radio']
|
|
30
|
+
// `Range` 与 DOM Range 构造器冲突,保持显式 import。
|
|
30
31
|
const Rating: typeof import('@h-ai/ui')['Rating']
|
|
31
32
|
const Select: typeof import('@h-ai/ui')['Select']
|
|
32
33
|
const Spinner: typeof import('@h-ai/ui')['Spinner']
|
|
@@ -39,7 +40,10 @@ declare global {
|
|
|
39
40
|
|
|
40
41
|
// compounds
|
|
41
42
|
const Accordion: typeof import('@h-ai/ui')['Accordion']
|
|
43
|
+
const ActionSheet: typeof import('@h-ai/ui')['ActionSheet']
|
|
42
44
|
const Alert: typeof import('@h-ai/ui')['Alert']
|
|
45
|
+
const AppBar: typeof import('@h-ai/ui')['AppBar']
|
|
46
|
+
const BottomNav: typeof import('@h-ai/ui')['BottomNav']
|
|
43
47
|
const Breadcrumb: typeof import('@h-ai/ui')['Breadcrumb']
|
|
44
48
|
const Calendar: typeof import('@h-ai/ui')['Calendar']
|
|
45
49
|
const Card: typeof import('@h-ai/ui')['Card']
|
|
@@ -52,13 +56,17 @@ declare global {
|
|
|
52
56
|
const Empty: typeof import('@h-ai/ui')['Empty']
|
|
53
57
|
const Form: typeof import('@h-ai/ui')['Form']
|
|
54
58
|
const FormField: typeof import('@h-ai/ui')['FormField']
|
|
59
|
+
const InfiniteScroll: typeof import('@h-ai/ui')['InfiniteScroll']
|
|
55
60
|
const Modal: typeof import('@h-ai/ui')['Modal']
|
|
56
61
|
const PageHeader: typeof import('@h-ai/ui')['PageHeader']
|
|
57
62
|
const Pagination: typeof import('@h-ai/ui')['Pagination']
|
|
58
63
|
const Popover: typeof import('@h-ai/ui')['Popover']
|
|
64
|
+
const PullRefresh: typeof import('@h-ai/ui')['PullRefresh']
|
|
59
65
|
const Result: typeof import('@h-ai/ui')['Result']
|
|
66
|
+
const SafeArea: typeof import('@h-ai/ui')['SafeArea']
|
|
60
67
|
const Skeleton: typeof import('@h-ai/ui')['Skeleton']
|
|
61
68
|
const Steps: typeof import('@h-ai/ui')['Steps']
|
|
69
|
+
const SwipeCell: typeof import('@h-ai/ui')['SwipeCell']
|
|
62
70
|
const Tabs: typeof import('@h-ai/ui')['Tabs']
|
|
63
71
|
const TagInput: typeof import('@h-ai/ui')['TagInput']
|
|
64
72
|
const Timeline: typeof import('@h-ai/ui')['Timeline']
|
|
@@ -78,6 +86,7 @@ declare global {
|
|
|
78
86
|
const ForgotPasswordForm: typeof import('@h-ai/ui')['ForgotPasswordForm']
|
|
79
87
|
const LoginForm: typeof import('@h-ai/ui')['LoginForm']
|
|
80
88
|
const PasswordInput: typeof import('@h-ai/ui')['PasswordInput']
|
|
89
|
+
const PermGuard: typeof import('@h-ai/ui')['PermGuard']
|
|
81
90
|
const RegisterForm: typeof import('@h-ai/ui')['RegisterForm']
|
|
82
91
|
const ResetPasswordForm: typeof import('@h-ai/ui')['ResetPasswordForm']
|
|
83
92
|
const UserProfile: typeof import('@h-ai/ui')['UserProfile']
|
|
@@ -92,6 +101,19 @@ declare global {
|
|
|
92
101
|
const EncryptedInput: typeof import('@h-ai/ui')['EncryptedInput']
|
|
93
102
|
const HashDisplay: typeof import('@h-ai/ui')['HashDisplay']
|
|
94
103
|
const SignatureDisplay: typeof import('@h-ai/ui')['SignatureDisplay']
|
|
104
|
+
|
|
105
|
+
// scenes - ai
|
|
106
|
+
const AiDocumentDownloadMenu: typeof import('@h-ai/ui')['AiDocumentDownloadMenu']
|
|
107
|
+
const AiDocumentEditor: typeof import('@h-ai/ui')['AiDocumentEditor']
|
|
108
|
+
const AiTableEditor: typeof import('@h-ai/ui')['AiTableEditor']
|
|
109
|
+
const MarkdownRenderer: typeof import('@h-ai/ui')['MarkdownRenderer']
|
|
110
|
+
|
|
111
|
+
// scenes - crud
|
|
112
|
+
const CrudDeleteConfirm: typeof import('@h-ai/ui')['CrudDeleteConfirm']
|
|
113
|
+
const CrudDetailDrawer: typeof import('@h-ai/ui')['CrudDetailDrawer']
|
|
114
|
+
const CrudEditDrawer: typeof import('@h-ai/ui')['CrudEditDrawer']
|
|
115
|
+
const CrudFilterBar: typeof import('@h-ai/ui')['CrudFilterBar']
|
|
116
|
+
const CrudPage: typeof import('@h-ai/ui')['CrudPage']
|
|
95
117
|
}
|
|
96
118
|
|
|
97
119
|
export {}
|
package/auto-import.js
CHANGED
|
@@ -19,7 +19,6 @@ const UI_COMPONENTS = new Set([
|
|
|
19
19
|
'Input',
|
|
20
20
|
'Progress',
|
|
21
21
|
'Radio',
|
|
22
|
-
'Range',
|
|
23
22
|
'Rating',
|
|
24
23
|
'Select',
|
|
25
24
|
'Spinner',
|
|
@@ -29,6 +28,7 @@ const UI_COMPONENTS = new Set([
|
|
|
29
28
|
'ToggleCheckbox',
|
|
30
29
|
'ToggleInput',
|
|
31
30
|
'ToggleRadio',
|
|
31
|
+
// `Range` 与 DOM Range 构造器重名,保持显式 import 更稳妥。
|
|
32
32
|
// compounds
|
|
33
33
|
'Accordion',
|
|
34
34
|
'ActionSheet',
|
|
@@ -89,8 +89,16 @@ const UI_COMPONENTS = new Set([
|
|
|
89
89
|
'HashDisplay',
|
|
90
90
|
'SignatureDisplay',
|
|
91
91
|
// scenes - ai
|
|
92
|
+
'AiDocumentDownloadMenu',
|
|
92
93
|
'AiDocumentEditor',
|
|
94
|
+
'AiTableEditor',
|
|
93
95
|
'MarkdownRenderer',
|
|
96
|
+
// scenes - crud
|
|
97
|
+
'CrudDeleteConfirm',
|
|
98
|
+
'CrudDetailDrawer',
|
|
99
|
+
'CrudEditDrawer',
|
|
100
|
+
'CrudFilterBar',
|
|
101
|
+
'CrudPage',
|
|
94
102
|
])
|
|
95
103
|
|
|
96
104
|
const TAG_REGEX = /<([A-Z][A-Za-z0-9]*)\b/g
|
|
@@ -11,6 +11,9 @@
|
|
|
11
11
|
import type { IconButtonProps } from '../../types.js'
|
|
12
12
|
import { cn, getSizeClass, getVariantClass } from '../../utils.js'
|
|
13
13
|
|
|
14
|
+
const SAFE_INLINE_SVG_REGEX = /^<svg[\s\S]*<\/svg>$/i
|
|
15
|
+
const UNSAFE_INLINE_SVG_REGEX = /<\s*(?:script|foreignObject|iframe|object|embed|link|meta|img)\b|\son[a-z]+\s*=|(?:href|xlink:href)\s*=\s*['"]\s*(?:javascript:|vbscript:|data:text\/html)/i
|
|
16
|
+
|
|
14
17
|
const {
|
|
15
18
|
icon,
|
|
16
19
|
label = '',
|
|
@@ -28,6 +31,16 @@
|
|
|
28
31
|
/** 计算最终的 aria-label */
|
|
29
32
|
const computedAriaLabel = $derived(ariaLabel || label || tooltip)
|
|
30
33
|
|
|
34
|
+
/**
|
|
35
|
+
* 字符串图标仅允许受信任的内联 SVG。
|
|
36
|
+
*
|
|
37
|
+
* 这里不尝试支持任意 HTML,避免把图标位变成通用 HTML 注入入口;
|
|
38
|
+
* 若需要复杂内容,请改用 Snippet / children。
|
|
39
|
+
*/
|
|
40
|
+
const sanitizedIcon = $derived(
|
|
41
|
+
typeof icon === 'string' ? sanitizeInlineSvg(icon) : '',
|
|
42
|
+
)
|
|
43
|
+
|
|
31
44
|
const buttonClass = $derived(
|
|
32
45
|
cn(
|
|
33
46
|
'btn btn-circle',
|
|
@@ -56,6 +69,19 @@
|
|
|
56
69
|
}
|
|
57
70
|
onclick?.(e)
|
|
58
71
|
}
|
|
72
|
+
|
|
73
|
+
function sanitizeInlineSvg(markup: string): string {
|
|
74
|
+
const trimmed = markup.trim()
|
|
75
|
+
if (!trimmed) {
|
|
76
|
+
return ''
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (!SAFE_INLINE_SVG_REGEX.test(trimmed) || UNSAFE_INLINE_SVG_REGEX.test(trimmed)) {
|
|
80
|
+
return ''
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return trimmed
|
|
84
|
+
}
|
|
59
85
|
</script>
|
|
60
86
|
|
|
61
87
|
{#if tooltip}
|
|
@@ -73,10 +99,10 @@
|
|
|
73
99
|
{@render children()}
|
|
74
100
|
{:else if typeof icon === 'function'}
|
|
75
101
|
{@render icon()}
|
|
76
|
-
{:else if
|
|
102
|
+
{:else if sanitizedIcon}
|
|
77
103
|
<span class={iconSize}>
|
|
78
|
-
<!-- eslint-disable-next-line svelte/no-at-html-tags --
|
|
79
|
-
{@html
|
|
104
|
+
<!-- eslint-disable-next-line svelte/no-at-html-tags -- 字符串图标只渲染经过白名单校验的内联 SVG -->
|
|
105
|
+
{@html sanitizedIcon}
|
|
80
106
|
</span>
|
|
81
107
|
{/if}
|
|
82
108
|
</button>
|
|
@@ -95,10 +121,10 @@
|
|
|
95
121
|
{@render children()}
|
|
96
122
|
{:else if typeof icon === 'function'}
|
|
97
123
|
{@render icon()}
|
|
98
|
-
{:else if
|
|
124
|
+
{:else if sanitizedIcon}
|
|
99
125
|
<span class={iconSize}>
|
|
100
|
-
<!-- eslint-disable-next-line svelte/no-at-html-tags --
|
|
101
|
-
{@html
|
|
126
|
+
<!-- eslint-disable-next-line svelte/no-at-html-tags -- 字符串图标只渲染经过白名单校验的内联 SVG -->
|
|
127
|
+
{@html sanitizedIcon}
|
|
102
128
|
</span>
|
|
103
129
|
{/if}
|
|
104
130
|
</button>
|
|
@@ -25,9 +25,14 @@
|
|
|
25
25
|
MarkdownTextAlignKind,
|
|
26
26
|
} from './document-types.js'
|
|
27
27
|
import { tick } from 'svelte'
|
|
28
|
+
import { writeTextToClipboard } from '../../../internal/browser-safety.js'
|
|
28
29
|
import { uiM } from '../../../messages.js'
|
|
29
30
|
import { cn } from '../../../utils.js'
|
|
30
31
|
import AiDocumentDownloadMenu from './AiDocumentDownloadMenu.svelte'
|
|
32
|
+
import {
|
|
33
|
+
createBuiltInCodePreview,
|
|
34
|
+
resolvePreviewSandbox,
|
|
35
|
+
} from './code-preview.js'
|
|
31
36
|
import { resolveDocumentMarkdownContent } from './document-download.js'
|
|
32
37
|
import { renderMarkdownDocument } from './document-parse.js'
|
|
33
38
|
import { parseMarkdown } from './markdown-parse.js'
|
|
@@ -241,6 +246,8 @@
|
|
|
241
246
|
showRunButton = false,
|
|
242
247
|
// 是否在代码块头部显示“代码/预览”切换。
|
|
243
248
|
showCodePreviewToggle = false,
|
|
249
|
+
// 是否显式允许内置 HTML / JS / CSS 预览执行高风险内容。
|
|
250
|
+
allowUnsafeCodePreview = false,
|
|
244
251
|
// 代码预览切换模式下展示在语言标签旁的提示文案。
|
|
245
252
|
codePreviewHint = '',
|
|
246
253
|
// 是否启用语法高亮。
|
|
@@ -628,17 +635,9 @@
|
|
|
628
635
|
]
|
|
629
636
|
}
|
|
630
637
|
|
|
631
|
-
const HTML_DOCUMENT_PATTERN = /<!doctype html>|<html[\s>]|<body[\s>]|<div[\s>]|<main[\s>]/i
|
|
632
|
-
|
|
633
638
|
async function copyRawContent(): Promise<boolean> {
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
return true
|
|
637
|
-
}
|
|
638
|
-
catch {
|
|
639
|
-
// clipboard API 可能被安全策略禁用;保持静默避免打断阅读。
|
|
640
|
-
return false
|
|
641
|
-
}
|
|
639
|
+
// 剪贴板能力在受限 WebView / 非安全上下文中可能缺失;这里统一静默降级。
|
|
640
|
+
return writeTextToClipboard(content)
|
|
642
641
|
}
|
|
643
642
|
|
|
644
643
|
function triggerDocumentCopiedFeedback(): void {
|
|
@@ -682,137 +681,15 @@
|
|
|
682
681
|
return
|
|
683
682
|
}
|
|
684
683
|
|
|
685
|
-
|
|
686
|
-
await navigator.clipboard.writeText(codeEl.textContent ?? '')
|
|
684
|
+
if (await writeTextToClipboard(codeEl.textContent ?? '')) {
|
|
687
685
|
updateCopyButtonState(button)
|
|
688
686
|
}
|
|
689
|
-
catch {
|
|
690
|
-
// clipboard API 可能被安全策略禁用;不抛错以免阻断其他交互。
|
|
691
|
-
}
|
|
692
687
|
}
|
|
693
688
|
|
|
694
689
|
function lookupCodeBlock(blockId: string): MarkdownCodeBlockItem | undefined {
|
|
695
690
|
return renderResult.codeBlocks.find(item => item.id === blockId)
|
|
696
691
|
}
|
|
697
692
|
|
|
698
|
-
function looksLikeHtml(code: string): boolean {
|
|
699
|
-
return HTML_DOCUMENT_PATTERN.test(code)
|
|
700
|
-
}
|
|
701
|
-
|
|
702
|
-
function buildJavaScriptPreview(code: string): string {
|
|
703
|
-
const safeCode = code.replace(/<\/script/gi, '<\\/script')
|
|
704
|
-
|
|
705
|
-
return `<!doctype html>
|
|
706
|
-
<html>
|
|
707
|
-
<head>
|
|
708
|
-
<meta charset="utf-8" />
|
|
709
|
-
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
710
|
-
<style>
|
|
711
|
-
body {
|
|
712
|
-
margin: 0;
|
|
713
|
-
padding: 16px;
|
|
714
|
-
font: 14px/1.6 system-ui, sans-serif;
|
|
715
|
-
color: #1f2937;
|
|
716
|
-
background: #f8fafc;
|
|
717
|
-
}
|
|
718
|
-
|
|
719
|
-
#app {
|
|
720
|
-
min-height: 48px;
|
|
721
|
-
}
|
|
722
|
-
</style>
|
|
723
|
-
</head>
|
|
724
|
-
<body>
|
|
725
|
-
<div id="app"></div>
|
|
726
|
-
<script>
|
|
727
|
-
${safeCode}
|
|
728
|
-
<\/script>
|
|
729
|
-
</body>
|
|
730
|
-
</html>`
|
|
731
|
-
}
|
|
732
|
-
|
|
733
|
-
function buildCssPreview(code: string): string {
|
|
734
|
-
return `<!doctype html>
|
|
735
|
-
<html>
|
|
736
|
-
<head>
|
|
737
|
-
<meta charset="utf-8" />
|
|
738
|
-
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
739
|
-
<style>${code}</style>
|
|
740
|
-
<style>
|
|
741
|
-
body {
|
|
742
|
-
margin: 0;
|
|
743
|
-
padding: 20px;
|
|
744
|
-
font: 14px/1.6 system-ui, sans-serif;
|
|
745
|
-
background: #f8fafc;
|
|
746
|
-
}
|
|
747
|
-
|
|
748
|
-
.preview-card {
|
|
749
|
-
border-radius: 16px;
|
|
750
|
-
padding: 24px;
|
|
751
|
-
border: 1px dashed #cbd5e1;
|
|
752
|
-
background: white;
|
|
753
|
-
}
|
|
754
|
-
</style>
|
|
755
|
-
</head>
|
|
756
|
-
<body>
|
|
757
|
-
<div class="preview-card">
|
|
758
|
-
<h3>CSS Preview</h3>
|
|
759
|
-
<p>This surface is provided so the stylesheet can render immediately.</p>
|
|
760
|
-
<button type="button">Action</button>
|
|
761
|
-
</div>
|
|
762
|
-
</body>
|
|
763
|
-
</html>`
|
|
764
|
-
}
|
|
765
|
-
|
|
766
|
-
/**
|
|
767
|
-
* 没有外部运行器时,组件只为可直接在浏览器里安全预览的语言提供兜底能力。
|
|
768
|
-
* 其余语言返回 undefined,让上层明确知道需要后端或沙箱参与执行。
|
|
769
|
-
*/
|
|
770
|
-
function createBuiltInCodePreview(
|
|
771
|
-
request: MarkdownCodeRunRequest,
|
|
772
|
-
): MarkdownCodeRunResult | undefined {
|
|
773
|
-
const language = request.language?.trim().toLocaleLowerCase()
|
|
774
|
-
|
|
775
|
-
if (
|
|
776
|
-
language === 'html'
|
|
777
|
-
|| language === 'htm'
|
|
778
|
-
|| language === 'xml'
|
|
779
|
-
|| language === 'svg'
|
|
780
|
-
|| (!language && looksLikeHtml(request.code))
|
|
781
|
-
) {
|
|
782
|
-
return {
|
|
783
|
-
kind: 'html',
|
|
784
|
-
title: uiM('markdown_run_preview'),
|
|
785
|
-
content: request.code,
|
|
786
|
-
}
|
|
787
|
-
}
|
|
788
|
-
|
|
789
|
-
if (language === 'javascript' || language === 'js' || language === 'mjs') {
|
|
790
|
-
return {
|
|
791
|
-
kind: 'html',
|
|
792
|
-
title: uiM('markdown_run_preview'),
|
|
793
|
-
content: buildJavaScriptPreview(request.code),
|
|
794
|
-
}
|
|
795
|
-
}
|
|
796
|
-
|
|
797
|
-
if (language === 'css') {
|
|
798
|
-
return {
|
|
799
|
-
kind: 'html',
|
|
800
|
-
title: uiM('markdown_run_preview'),
|
|
801
|
-
content: buildCssPreview(request.code),
|
|
802
|
-
}
|
|
803
|
-
}
|
|
804
|
-
|
|
805
|
-
if (language === 'markdown' || language === 'md') {
|
|
806
|
-
return {
|
|
807
|
-
kind: 'markdown',
|
|
808
|
-
title: uiM('markdown_run_preview'),
|
|
809
|
-
content: request.code,
|
|
810
|
-
}
|
|
811
|
-
}
|
|
812
|
-
|
|
813
|
-
return undefined
|
|
814
|
-
}
|
|
815
|
-
|
|
816
693
|
function formatPreviewLanguageLabel(language: string | undefined): string | null {
|
|
817
694
|
const normalized = language?.trim().toLocaleLowerCase()
|
|
818
695
|
if (!normalized) {
|
|
@@ -867,8 +744,12 @@ ${safeCode}
|
|
|
867
744
|
}
|
|
868
745
|
|
|
869
746
|
try {
|
|
870
|
-
const preview =
|
|
871
|
-
|
|
747
|
+
const preview = oncoderun
|
|
748
|
+
? await oncoderun(request)
|
|
749
|
+
: createBuiltInCodePreview(request, {
|
|
750
|
+
allowUnsafeCodePreview,
|
|
751
|
+
previewTitle: uiM('markdown_run_preview'),
|
|
752
|
+
})
|
|
872
753
|
|
|
873
754
|
if (!preview) {
|
|
874
755
|
codePreviews = {
|
|
@@ -1029,7 +910,10 @@ ${safeCode}
|
|
|
1029
910
|
: ''
|
|
1030
911
|
|
|
1031
912
|
if (result.kind === 'html') {
|
|
1032
|
-
|
|
913
|
+
const previewSandbox = escapePreviewAttribute(
|
|
914
|
+
resolvePreviewSandbox(result.allowScripts),
|
|
915
|
+
)
|
|
916
|
+
host.innerHTML = `<div class="hai-md-preview-card"><div class="hai-md-preview-head">${previewTitle}</div>${previewDesc}<iframe class="hai-md-preview-frame" sandbox="${previewSandbox}" srcdoc="${escapePreviewAttribute(result.content)}" title="${previewTitle}"></iframe></div>`
|
|
1033
917
|
return
|
|
1034
918
|
}
|
|
1035
919
|
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
-->
|
|
18
18
|
<script lang='ts'>
|
|
19
19
|
import type { MarkdownRendererProps } from '../types.js'
|
|
20
|
+
import { writeTextToClipboard } from '../../../internal/browser-safety.js'
|
|
20
21
|
import { cn } from '../../../utils.js'
|
|
21
22
|
import { parseMarkdown } from './markdown-parse.js'
|
|
22
23
|
|
|
@@ -36,7 +37,7 @@
|
|
|
36
37
|
/**
|
|
37
38
|
* 事件代理:处理代码块复制按钮点击
|
|
38
39
|
*/
|
|
39
|
-
function handleClick(e: MouseEvent) {
|
|
40
|
+
async function handleClick(e: MouseEvent) {
|
|
40
41
|
const target = e.target as HTMLElement
|
|
41
42
|
const btn = target.closest('[data-copy-code]') as HTMLButtonElement | null
|
|
42
43
|
if (!btn)
|
|
@@ -48,15 +49,16 @@
|
|
|
48
49
|
return
|
|
49
50
|
|
|
50
51
|
const text = codeEl.textContent ?? ''
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
52
|
+
if (!await writeTextToClipboard(text))
|
|
53
|
+
return
|
|
54
|
+
|
|
55
|
+
btn.classList.add('hai-md-copied')
|
|
56
|
+
btn.innerHTML = `<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>`
|
|
57
|
+
|
|
58
|
+
window.setTimeout(() => {
|
|
59
|
+
btn.classList.remove('hai-md-copied')
|
|
60
|
+
btn.innerHTML = `<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>`
|
|
61
|
+
}, 2000)
|
|
60
62
|
}
|
|
61
63
|
</script>
|
|
62
64
|
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { MarkdownCodeRunRequest, MarkdownCodeRunResult } from './document-types.js';
|
|
2
|
+
export interface BuiltInCodePreviewOptions {
|
|
3
|
+
/**
|
|
4
|
+
* 是否允许执行内置的不安全代码预览。
|
|
5
|
+
*
|
|
6
|
+
* 默认仅允许 Markdown 预览;HTML / JS / CSS 预览需要显式 opt-in,
|
|
7
|
+
* 否则组件会提示调用方提供自定义 oncoderun 或自行开启风险开关。
|
|
8
|
+
*/
|
|
9
|
+
allowUnsafeCodePreview?: boolean;
|
|
10
|
+
previewTitle: string;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* 根据运行结果构造 iframe sandbox 值。
|
|
14
|
+
*
|
|
15
|
+
* 默认不允许脚本执行;只有显式声明 `allowScripts` 时才放开 `allow-scripts`。
|
|
16
|
+
*/
|
|
17
|
+
export declare function resolvePreviewSandbox(allowScripts: boolean | undefined): string;
|
|
18
|
+
export declare function createBuiltInCodePreview(request: MarkdownCodeRunRequest, options: BuiltInCodePreviewOptions): MarkdownCodeRunResult | undefined;
|