@plumeai/plugin-sdk 0.0.0-stage → 0.1.2
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/LICENSE +21 -0
- package/bin/plume-plugin.mjs +70 -0
- package/dist/client/styles/src/index.d.ts +123 -0
- package/dist/client/ui/src/avatar/AgentAvatar.d.ts +42 -0
- package/dist/client/ui/src/avatar/AvatarPile.d.ts +36 -0
- package/dist/client/ui/src/avatar/AvatarStack.d.ts +26 -0
- package/dist/client/ui/src/avatar/art.d.ts +32 -0
- package/dist/client/ui/src/fileIcon.d.ts +33 -0
- package/dist/client/ui/src/hooks/useCopied.d.ts +16 -0
- package/dist/client/ui/src/hooks/useLiveRefresh.d.ts +22 -0
- package/dist/client/ui/src/hooks/useNarrow.d.ts +16 -0
- package/dist/client/ui/src/i18n/context.d.ts +17 -0
- package/dist/client/ui/src/i18n/index.d.ts +2 -0
- package/dist/client/ui/src/i18n/list.d.ts +20 -0
- package/dist/client/ui/src/i18n/locales.d.ts +10 -0
- package/dist/client/ui/src/i18n/messages/en.d.ts +1418 -0
- package/dist/client/ui/src/i18n/messages/index.d.ts +6 -0
- package/dist/client/ui/src/i18n/messages/zh-CN.d.ts +1263 -0
- package/dist/client/ui/src/i18n/plural.d.ts +16 -0
- package/dist/client/ui/src/i18n/translate.d.ts +43 -0
- package/dist/client/ui/src/icons/index.d.ts +5 -0
- package/dist/client/ui/src/image/AnnotateCanvas.d.ts +29 -0
- package/dist/client/ui/src/image/AnnotateToolbar.d.ts +73 -0
- package/dist/client/ui/src/image/Annotator.d.ts +154 -0
- package/dist/client/ui/src/image/annotate.d.ts +227 -0
- package/dist/client/ui/src/image/paint.d.ts +35 -0
- package/dist/client/ui/src/image/screenshot-geometry.d.ts +112 -0
- package/dist/client/ui/src/image/useBarDrag.d.ts +31 -0
- package/dist/client/ui/src/image/useMovableBar.d.ts +26 -0
- package/dist/client/ui/src/image/useToolbarPlacement.d.ts +42 -0
- package/dist/client/ui/src/index.d.ts +140 -0
- package/dist/client/ui/src/inputs/ColorField.d.ts +9 -0
- package/dist/client/ui/src/inputs/ComposerToggle.d.ts +23 -0
- package/dist/client/ui/src/inputs/DateRangeField.d.ts +25 -0
- package/dist/client/ui/src/inputs/NativeField.d.ts +5 -0
- package/dist/client/ui/src/inputs/SearchField.d.ts +21 -0
- package/dist/client/ui/src/inputs/Segmented.d.ts +23 -0
- package/dist/client/ui/src/inputs/Select.d.ts +32 -0
- package/dist/client/ui/src/inputs/ShortcutRecorder.d.ts +8 -0
- package/dist/client/ui/src/inputs/Slider.d.ts +13 -0
- package/dist/client/ui/src/inputs/TextArea.d.ts +30 -0
- package/dist/client/ui/src/inputs/Toggle.d.ts +10 -0
- package/dist/client/ui/src/inputs/WorktreeToggle.d.ts +19 -0
- package/dist/client/ui/src/inputs/day-range.d.ts +57 -0
- package/dist/client/ui/src/inputs/pickers.d.ts +40 -0
- package/dist/client/ui/src/inputs/useFieldFade.d.ts +1 -0
- package/dist/client/ui/src/keyboard.d.ts +21 -0
- package/dist/client/ui/src/layout/ApprovalFrame.d.ts +33 -0
- package/dist/client/ui/src/layout/Collapse.d.ts +9 -0
- package/dist/client/ui/src/layout/CopyMark.d.ts +19 -0
- package/dist/client/ui/src/layout/Disclosure.d.ts +66 -0
- package/dist/client/ui/src/layout/ErrorBoundary.d.ts +26 -0
- package/dist/client/ui/src/layout/PageTopRow.d.ts +19 -0
- package/dist/client/ui/src/layout/PanelEmpty.d.ts +17 -0
- package/dist/client/ui/src/layout/ResizeHandle.d.ts +24 -0
- package/dist/client/ui/src/layout/RetainedViews.d.ts +32 -0
- package/dist/client/ui/src/layout/Settings.d.ts +41 -0
- package/dist/client/ui/src/layout/Splitter.d.ts +25 -0
- package/dist/client/ui/src/lib/agent-avatar.d.ts +58 -0
- package/dist/client/ui/src/lib/code/font-stack.d.ts +12 -0
- package/dist/client/ui/src/lib/code/themes.d.ts +35 -0
- package/dist/client/ui/src/lib/date-format.d.ts +8 -0
- package/dist/client/ui/src/lib/dispatches.d.ts +8 -0
- package/dist/client/ui/src/lib/hour-style.d.ts +13 -0
- package/dist/client/ui/src/lib/number-draft.d.ts +24 -0
- package/dist/client/ui/src/lib/relative-time.d.ts +18 -0
- package/dist/client/ui/src/lib/tiling.d.ts +89 -0
- package/dist/client/ui/src/lib/typing.d.ts +19 -0
- package/dist/client/ui/src/motion/RollWords.d.ts +26 -0
- package/dist/client/ui/src/motion/RollingText.d.ts +43 -0
- package/dist/client/ui/src/motion/TypedText.d.ts +9 -0
- package/dist/client/ui/src/motion/freeze.d.ts +22 -0
- package/dist/client/ui/src/motion/held.d.ts +17 -0
- package/dist/client/ui/src/motion/loaders.d.ts +65 -0
- package/dist/client/ui/src/motion/reduced.d.ts +13 -0
- package/dist/client/ui/src/motion/ticked.d.ts +24 -0
- package/dist/client/ui/src/motion/ticker.d.ts +31 -0
- package/dist/client/ui/src/motion/tokens.d.ts +29 -0
- package/dist/client/ui/src/motion/useNow.d.ts +6 -0
- package/dist/client/ui/src/overlay/Confirm.d.ts +96 -0
- package/dist/client/ui/src/overlay/ContextMenu.d.ts +40 -0
- package/dist/client/ui/src/overlay/Dialog.d.ts +60 -0
- package/dist/client/ui/src/overlay/Menu.d.ts +90 -0
- package/dist/client/ui/src/overlay/Overlay.d.ts +17 -0
- package/dist/client/ui/src/overlay/Popover.d.ts +127 -0
- package/dist/client/ui/src/overlay/Tooltip.d.ts +14 -0
- package/dist/client/ui/src/overlay/afterimage.d.ts +20 -0
- package/dist/client/ui/src/overlay/chrome-top.d.ts +8 -0
- package/dist/client/ui/src/overlay/hover-layers.d.ts +41 -0
- package/dist/client/ui/src/overlay/portal.d.ts +21 -0
- package/dist/client/ui/src/overlay/scrims.d.ts +20 -0
- package/dist/client/ui/src/overlay/tooltip-attribute.d.ts +62 -0
- package/dist/client/ui/src/primitives/Button.d.ts +64 -0
- package/dist/client/ui/src/primitives/Caret.d.ts +25 -0
- package/dist/client/ui/src/primitives/ChoiceMark.d.ts +12 -0
- package/dist/client/ui/src/primitives/ClosableTab.d.ts +23 -0
- package/dist/client/ui/src/primitives/CountUp.d.ts +21 -0
- package/dist/client/ui/src/primitives/GitHubMark.d.ts +12 -0
- package/dist/client/ui/src/primitives/IconButton.d.ts +55 -0
- package/dist/client/ui/src/primitives/MarkNumber.d.ts +12 -0
- package/dist/client/ui/src/primitives/RowDeleteButton.d.ts +6 -0
- package/dist/client/ui/src/primitives/Skeleton.d.ts +36 -0
- package/dist/client/ui/src/primitives/TabStrip.d.ts +29 -0
- package/dist/client/ui/src/primitives/Text.d.ts +341 -0
- package/dist/client/ui/src/primitives/TimeAgo.d.ts +13 -0
- package/dist/client/ui/src/primitives/useCountUp.d.ts +23 -0
- package/dist/client/ui/src/row/FlowRow.d.ts +31 -0
- package/dist/client/ui/src/row/HoverRow.d.ts +28 -0
- package/dist/client/ui/src/scroll/BackToLatest.d.ts +19 -0
- package/dist/client/ui/src/scroll/OverlayScrollbar.d.ts +9 -0
- package/dist/client/ui/src/scroll/ScrollText.d.ts +24 -0
- package/dist/client/ui/src/scroll/Scroller.d.ts +66 -0
- package/dist/client/ui/src/scroll/Sideways.d.ts +30 -0
- package/dist/client/ui/src/scroll/fade.d.ts +2 -0
- package/dist/client/ui/src/scroll/follow.d.ts +157 -0
- package/dist/client/ui/src/scroll/measure-batch.d.ts +13 -0
- package/dist/client/ui/src/scroll/memory.d.ts +22 -0
- package/dist/client/ui/src/scroll/signature.d.ts +24 -0
- package/dist/client/ui/src/scroll/useFollowBottom.d.ts +80 -0
- package/dist/client/ui/src/scroll/useSideways.d.ts +29 -0
- package/dist/contract/src/app.d.ts +16 -0
- package/dist/contract/src/changes.d.ts +50 -0
- package/dist/contract/src/diagnostics.d.ts +86 -0
- package/dist/contract/src/index.d.ts +18 -0
- package/dist/contract/src/plugin.d.ts +1391 -0
- package/dist/contract/src/resources.d.ts +79 -0
- package/dist/contract/src/service.d.ts +806 -0
- package/dist/contract/src/ui.d.ts +1039 -0
- package/dist/contract/src/usage.d.ts +102 -0
- package/dist/core/llm/ai/anthropic-messages-request.d.ts +82 -0
- package/dist/core/llm/ai/anthropic-messages.d.ts +43 -0
- package/dist/core/llm/ai/cache-routing.d.ts +53 -0
- package/dist/core/llm/ai/chat-delta.d.ts +107 -0
- package/dist/core/llm/ai/compat-key.d.ts +19 -0
- package/dist/core/llm/ai/endpoint.d.ts +57 -0
- package/dist/core/llm/ai/failure.d.ts +181 -0
- package/dist/core/llm/ai/index.d.ts +16 -0
- package/dist/core/llm/ai/openai-chat-completions-request.d.ts +49 -0
- package/dist/core/llm/ai/openai-chat-completions.d.ts +37 -0
- package/dist/core/llm/ai/openai-responses-request.d.ts +63 -0
- package/dist/core/llm/ai/openai-responses.d.ts +11 -0
- package/dist/core/llm/ai/prefix-fingerprint.d.ts +20 -0
- package/dist/core/llm/ai/reasoning-compat.d.ts +69 -0
- package/dist/core/llm/ai/request-params-compat.d.ts +39 -0
- package/dist/core/llm/ai/retry.d.ts +130 -0
- package/dist/core/llm/ai/sanitize-history.d.ts +23 -0
- package/dist/core/llm/ai/think-tag.d.ts +37 -0
- package/dist/core/llm/ai/thinking-options.d.ts +35 -0
- package/dist/core/llm/ai/tool-names.d.ts +17 -0
- package/dist/core/llm/ai/tool-pairing-compat.d.ts +53 -0
- package/dist/core/llm/ai/tool-result-images-compat.d.ts +24 -0
- package/dist/core/llm/ai/tool-schema.d.ts +20 -0
- package/dist/core/llm/ai/usage-fields.d.ts +30 -0
- package/dist/core/llm/ai/vendors/index.d.ts +20 -0
- package/dist/core/llm/ai/vendors/thinking.d.ts +39 -0
- package/dist/core/llm/ai/vendors/types.d.ts +47 -0
- package/dist/core/llm/config/image-models.d.ts +34 -0
- package/dist/core/llm/config/retry-policy.d.ts +35 -0
- package/dist/core/llm/images/chat-images.d.ts +11 -0
- package/dist/core/llm/images/dimensions.d.ts +15 -0
- package/dist/core/llm/images/generate.d.ts +15 -0
- package/dist/core/llm/images/openai-images.d.ts +11 -0
- package/dist/core/llm/images/quota.d.ts +9 -0
- package/dist/core/llm/images/shape.d.ts +44 -0
- package/dist/core/llm/images/types.d.ts +118 -0
- package/dist/core/llm/model-catalog-format.d.ts +50 -0
- package/dist/core/llm/model-catalog.d.ts +51 -0
- package/dist/core/llm/types/message.d.ts +450 -0
- package/dist/core/llm/types/provider.d.ts +169 -0
- package/dist/core/llm/types/tool.d.ts +23 -0
- package/dist/core/llm/types.d.ts +10 -0
- package/dist/core/llm/utils/pricing.d.ts +18 -0
- package/dist/core/llm/utils/record.d.ts +2 -0
- package/dist/core/llm/utils/sse.d.ts +69 -0
- package/dist/infra/rpc/src/endpoint.d.ts +36 -0
- package/dist/infra/rpc/src/index.d.ts +2 -0
- package/dist/infra/rpc/src/rpc.d.ts +54 -0
- package/dist/theme.css +336 -0
- package/dist/ui.d.ts +2 -0
- package/lib/build.mjs +143 -0
- package/lib/check.mjs +23 -0
- package/lib/dev.mjs +52 -0
- package/lib/icons.mjs +2 -0
- package/lib/init.mjs +109 -0
- package/lib/install.mjs +80 -0
- package/lib/link.mjs +44 -0
- package/lib/lint/i18n.mjs +149 -0
- package/lib/lint/imports.mjs +83 -0
- package/lib/lint/index.mjs +54 -0
- package/lib/lint/styles.mjs +160 -0
- package/lib/manifest.d.mts +9 -0
- package/lib/manifest.mjs +99 -0
- package/lib/targets.mjs +60 -0
- package/package.json +34 -4
- package/skill/SKILL.md +150 -0
- package/skill/references/components.md +2830 -0
- package/skill/references/logic.md +526 -0
- package/skill/references/style-i18n.md +70 -0
- package/skill/references/ui.md +532 -0
- package/template/AGENTS.md +17 -0
- package/template/CLAUDE.md +1 -0
- package/template/README.md +12 -0
- package/template/gitignore +2 -0
- package/template/src/index.ts +28 -0
- package/template/src/messages.ts +16 -0
- package/template/src/types.ts +8 -0
- package/template/src/ui.tsx +30 -0
- package/template/tsconfig.json +7 -0
- package/tsconfig.json +25 -0
- package/README.md +0 -3
|
@@ -0,0 +1,2830 @@
|
|
|
1
|
+
# 组件和类名组合
|
|
2
|
+
|
|
3
|
+
<!-- 由 `node scripts/check-plugins.mjs --fix` 从源码生成,不要手改。名单在 packages/client/ui/plugin-api.json。 -->
|
|
4
|
+
|
|
5
|
+
界面入口能用的 `@plume/ui` 组件和 `@plume/styles` 类名组合,就是这一页列出的这些;`@plume/ui` 的其他导出是产品内部用的,`pnpm check:plugins` 会拦。签名和注释原样摘自源码。
|
|
6
|
+
|
|
7
|
+
怎么选:文字一律用 `Text`(字号档位自带行高、字距和字重,不在 CSS 里写字号);按钮、输入框、开关、菜单、对话框用对应组件;工具卡片的外框用 `Card`。组件不够时用下面的类名组合,再不够才写自己的样式(见 [style-i18n.md](style-i18n.md))。
|
|
8
|
+
|
|
9
|
+
## 文字
|
|
10
|
+
|
|
11
|
+
### `Text`
|
|
12
|
+
|
|
13
|
+
源码:`packages/client/ui/src/primitives/Text.tsx`
|
|
14
|
+
|
|
15
|
+
```tsx
|
|
16
|
+
/** 一段文字:`size` 选字号档位(每档带着自己的行高、字距和默认字重),`tone` 选颜色。 */
|
|
17
|
+
export function Text({
|
|
18
|
+
size = "label",
|
|
19
|
+
tone = "default",
|
|
20
|
+
weight,
|
|
21
|
+
as = "span",
|
|
22
|
+
mono = false,
|
|
23
|
+
numeric = false,
|
|
24
|
+
className = "",
|
|
25
|
+
children,
|
|
26
|
+
...rest
|
|
27
|
+
}: {
|
|
28
|
+
size?: keyof typeof SIZES;
|
|
29
|
+
tone?: keyof typeof TONES;
|
|
30
|
+
/** 覆盖这一档平时的字重。 */
|
|
31
|
+
weight?: keyof typeof WEIGHTS;
|
|
32
|
+
/** 渲染成什么元素。默认 `span`,能放进一句话里。 */
|
|
33
|
+
as?: "span" | "p" | "div" | "h1" | "h2" | "h3" | "label" | "code";
|
|
34
|
+
/** 等宽字体,给正文里引用的标识符和路径。 */
|
|
35
|
+
mono?: boolean;
|
|
36
|
+
/**
|
|
37
|
+
* 等宽数字。
|
|
38
|
+
*
|
|
39
|
+
* 给原地变化的数——token 数、计时、diff 统计。比例数字每跳一下字就左右挪,读起来是闪烁。
|
|
40
|
+
*/
|
|
41
|
+
numeric?: boolean;
|
|
42
|
+
className?: string;
|
|
43
|
+
children?: React.ReactNode;
|
|
44
|
+
} & React.HTMLAttributes<HTMLElement>)
|
|
45
|
+
|
|
46
|
+
const SIZES = {
|
|
47
|
+
/** 页面标题——一屏一个。 */
|
|
48
|
+
display: "text-display",
|
|
49
|
+
/** 一屏里大分区的标题。 */
|
|
50
|
+
heading: "text-heading",
|
|
51
|
+
/** 小节和卡片的标题。 */
|
|
52
|
+
title: "text-title",
|
|
53
|
+
/** 正文:消息、描述,一句一句读的东西。 */
|
|
54
|
+
body: "text-body",
|
|
55
|
+
/** 控件、行、表格单元——界面的工作字号。 */
|
|
56
|
+
label: "text-label",
|
|
57
|
+
/** 标签下面的次要细节。 */
|
|
58
|
+
detail: "text-detail",
|
|
59
|
+
/** 时间戳、计数、角标。 */
|
|
60
|
+
caption: "text-caption",
|
|
61
|
+
} as const;
|
|
62
|
+
|
|
63
|
+
const TONES = {
|
|
64
|
+
default: "text-ink",
|
|
65
|
+
muted: "text-ink-muted",
|
|
66
|
+
faint: "text-ink-faint",
|
|
67
|
+
danger: "text-danger",
|
|
68
|
+
accent: "text-accent",
|
|
69
|
+
} as const;
|
|
70
|
+
|
|
71
|
+
const WEIGHTS = {
|
|
72
|
+
normal: "",
|
|
73
|
+
medium: "font-medium",
|
|
74
|
+
semibold: "font-semibold",
|
|
75
|
+
} as const;
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## 按钮
|
|
79
|
+
|
|
80
|
+
### `Button`
|
|
81
|
+
|
|
82
|
+
源码:`packages/client/ui/src/primitives/Button.tsx`
|
|
83
|
+
|
|
84
|
+
```tsx
|
|
85
|
+
export function Button({
|
|
86
|
+
children,
|
|
87
|
+
icon,
|
|
88
|
+
onClick,
|
|
89
|
+
variant = "ghost",
|
|
90
|
+
size = "md",
|
|
91
|
+
disabled,
|
|
92
|
+
loading,
|
|
93
|
+
label,
|
|
94
|
+
ariaLabel,
|
|
95
|
+
menu,
|
|
96
|
+
expanded,
|
|
97
|
+
pressed,
|
|
98
|
+
className = "",
|
|
99
|
+
type = "button",
|
|
100
|
+
...data
|
|
101
|
+
}: DataAttributes & {
|
|
102
|
+
/**
|
|
103
|
+
* 只有图标的按钮不传。
|
|
104
|
+
*
|
|
105
|
+
* 图标旁边再写一个字是把同一件事说两遍,一排这样的按钮会把工具栏变成一句话。图标说得清意思的地方,
|
|
106
|
+
* `label` 同时作悬停提示和无障碍名——读屏的人什么都没丢,丢的只是眼睛看到的那几个字。
|
|
107
|
+
*/
|
|
108
|
+
children?: ReactNode;
|
|
109
|
+
icon?: ReactNode;
|
|
110
|
+
onClick?: (event: MouseEvent<HTMLButtonElement>) => void;
|
|
111
|
+
variant?: ButtonVariant;
|
|
112
|
+
size?: ButtonSize;
|
|
113
|
+
disabled?: boolean;
|
|
114
|
+
/**
|
|
115
|
+
* 忙碌。按钮不能按、变淡,宽度不变。
|
|
116
|
+
*
|
|
117
|
+
* 每个异步操作各自做这件事时,做法都略有不同——有的把文字换成转圈,整行跟着跳;有的按钮还能按,
|
|
118
|
+
* 能被按两次。宽度不变是因为:一个看着它的时候在缩的按钮,回头找的时候可能就找不到了。
|
|
119
|
+
*/
|
|
120
|
+
loading?: boolean;
|
|
121
|
+
/** 悬停提示;没有可见文字时也是无障碍名。 */
|
|
122
|
+
label?: string;
|
|
123
|
+
/** 可见文字本身说不清按钮做什么时的无障碍名——`中文` 是一个值,不是一个动作。 */
|
|
124
|
+
ariaLabel?: string;
|
|
125
|
+
/** 会打开一个菜单;值是它现在开没开。普通按钮不传。 */
|
|
126
|
+
menu?: boolean;
|
|
127
|
+
/** 原地展开或收起一段——`aria-expanded`,但不像 `menu` 那样声明弹出。 */
|
|
128
|
+
expanded?: boolean;
|
|
129
|
+
/** 一排选择里选中的那个——筛选、视图。不是一组里的按钮不传。 */
|
|
130
|
+
pressed?: boolean;
|
|
131
|
+
className?: string;
|
|
132
|
+
type?: "button" | "submit";
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
export type DataAttributes = { [key: `data-${string}`]: string | boolean | undefined };
|
|
136
|
+
|
|
137
|
+
export type ButtonVariant = "primary" | "ghost" | "subtle" | "soft" | "danger";
|
|
138
|
+
|
|
139
|
+
export type ButtonSize = "md" | "sm" | "xs";
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
### `IconButton`
|
|
143
|
+
|
|
144
|
+
源码:`packages/client/ui/src/primitives/IconButton.tsx`
|
|
145
|
+
|
|
146
|
+
```tsx
|
|
147
|
+
/**
|
|
148
|
+
* 标签是一个图标的按钮。
|
|
149
|
+
*
|
|
150
|
+
* 各个工具栏各写各的,会长出五种高度、三种圆角,按下的反馈从没有到缩小 10% 都有。缩小是有意去掉的——
|
|
151
|
+
* 一个在接触那一刻从指针底下跳开的控件读起来不稳,在这么小的尺寸上那是一下晃动而不是按下。按下用底色
|
|
152
|
+
* 深一档表示,真的按钮就是这样:它待在原地,只是换了状态。
|
|
153
|
+
*
|
|
154
|
+
* 标签同时是悬停提示和无障碍名,调用方没法只给一个——只有图标的工具栏就是这样变得读屏读不出来的。
|
|
155
|
+
* 类名组合在 `@plume/styles` 的 `iconButton`。
|
|
156
|
+
*/
|
|
157
|
+
export function IconButton({
|
|
158
|
+
label,
|
|
159
|
+
ariaLabel,
|
|
160
|
+
icon,
|
|
161
|
+
onClick,
|
|
162
|
+
active,
|
|
163
|
+
disabled,
|
|
164
|
+
emphasis,
|
|
165
|
+
badge,
|
|
166
|
+
explainDisabled,
|
|
167
|
+
tone = "default",
|
|
168
|
+
size = "md",
|
|
169
|
+
tipSide = "bottom",
|
|
170
|
+
menu,
|
|
171
|
+
expanded,
|
|
172
|
+
className = "",
|
|
173
|
+
...data
|
|
174
|
+
}: DataAttributes & {
|
|
175
|
+
label: string;
|
|
176
|
+
/**
|
|
177
|
+
* 无障碍名要比悬停提示多说一点时用。
|
|
178
|
+
*
|
|
179
|
+
* 一列同样的按钮在读屏里是同一个词念了一遍又一遍;无障碍名带上是哪一行(`归档会话「a」`),
|
|
180
|
+
* 悬停提示仍然短,因为指针已经在那一行上了。不传时 `label` 两样都是,这是通常的情况。
|
|
181
|
+
*/
|
|
182
|
+
ariaLabel?: string;
|
|
183
|
+
icon: React.ReactNode;
|
|
184
|
+
onClick: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
|
185
|
+
/** 按住 / 开着,给「区分大小写」这类开关。 */
|
|
186
|
+
active?: boolean;
|
|
187
|
+
disabled?: boolean;
|
|
188
|
+
/**
|
|
189
|
+
* 一排里现在值得按的那一个。
|
|
190
|
+
*
|
|
191
|
+
* 满色的字,对比一排平时淡灰的字,这是界面里已经用来表示「就是这个」的对比。不是实心或彩色按钮:
|
|
192
|
+
* 长出 primary 按钮的工具栏就不再是工具栏了。
|
|
193
|
+
*/
|
|
194
|
+
emphasis?: boolean;
|
|
195
|
+
/** 角上的计数。零和 null 都画成什么都没有。 */
|
|
196
|
+
badge?: number | null;
|
|
197
|
+
/**
|
|
198
|
+
* 禁用时也显示悬停提示,因为标签说明的正是为什么禁用。
|
|
199
|
+
*
|
|
200
|
+
* 默认关,对大多数工具栏默认是对的:一个因为显而易见的原因变灰的控件,再冒一个泡重复它的名字
|
|
201
|
+
* 没有意义。原因本身才是重点的地方就不对了——「当前分支没有上游」正是悬停在一个按不动的拉取按钮上
|
|
202
|
+
* 的人想知道的,没有这个他们只能得到一片沉默,自己去猜。
|
|
203
|
+
*/
|
|
204
|
+
explainDisabled?: boolean;
|
|
205
|
+
tone?: "default" | "danger";
|
|
206
|
+
/**
|
|
207
|
+
* `xs` 20px,是窗格标题行和一行小字的高度;圆角跟着降到 6px,这个尺寸的按钮都这样。`composer`
|
|
208
|
+
* 跟着主输入框自己的控件高度走,那个高度变了,它那一排仍然是一行。
|
|
209
|
+
*/
|
|
210
|
+
size?: "xs" | "sm" | "md" | "composer";
|
|
211
|
+
tipSide?: "top" | "bottom";
|
|
212
|
+
/** 会打开一个菜单;值是它现在开没开。和 `Button` 的一样。 */
|
|
213
|
+
menu?: boolean;
|
|
214
|
+
/** 原地展开或收起一段。和 `Button` 的一样。 */
|
|
215
|
+
expanded?: boolean;
|
|
216
|
+
className?: string;
|
|
217
|
+
})
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
## 输入
|
|
221
|
+
|
|
222
|
+
### `Input`
|
|
223
|
+
|
|
224
|
+
源码:`packages/client/ui/src/inputs/NativeField.tsx`
|
|
225
|
+
|
|
226
|
+
```tsx
|
|
227
|
+
/** 不带样式的原生控件:布局、ref、选区和撤销历史都留在调用方手里。 */
|
|
228
|
+
export function Input(props: ComponentPropsWithRef<"input">)
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
### `Textarea`
|
|
232
|
+
|
|
233
|
+
源码:`packages/client/ui/src/inputs/NativeField.tsx`
|
|
234
|
+
|
|
235
|
+
```tsx
|
|
236
|
+
/** 同 `Input`,多行。要带框、滚动条和渐隐的多行框用 `TextArea`。 */
|
|
237
|
+
export function Textarea(props: ComponentPropsWithRef<"textarea">)
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
### `TextArea`
|
|
241
|
+
|
|
242
|
+
源码:`packages/client/ui/src/inputs/TextArea.tsx`
|
|
243
|
+
|
|
244
|
+
```tsx
|
|
245
|
+
/**
|
|
246
|
+
* 写很多字的地方,一种写法。
|
|
247
|
+
*
|
|
248
|
+
* 各处的多行输入框各写各的类名串,会长出好几种圆角、内衬和底色——而它们要人做的是同一件事。
|
|
249
|
+
* 样子由 `.plume-textarea-shell` / `.plume-textarea` 出(见 `@plume/styles` 的 `ui/controls.css`),
|
|
250
|
+
* 调用方只说高度和位置。
|
|
251
|
+
*
|
|
252
|
+
* 三样东西是一起来的,缺一件那个框就回到从前:
|
|
253
|
+
*
|
|
254
|
+
* - **边界在外壳上**。虚化收的是元素画出的一切,描边和字同在一个元素上,滚到顶时框的上下边会跟
|
|
255
|
+
* 着字一起淡掉,像破了个口子。
|
|
256
|
+
* - **滑块是自己画的**。原生滚动条在全局被关掉了(见 `OverlayScrollbar` 的开头),不补一个,一
|
|
257
|
+
* 篇长草稿滚起来没有任何东西说它有多长。
|
|
258
|
+
* - **两头化开**。硬切的半行字读起来像渲染坏了,见 `useFieldFade`。
|
|
259
|
+
*
|
|
260
|
+
* 主输入框、消息编辑框这类框里还站着别的东西(工具栏、附件条、那排按钮)的不走这里,外壳是各自的。
|
|
261
|
+
* 共享的是同一个 `useFieldFade` 和同一条遮罩。
|
|
262
|
+
*/
|
|
263
|
+
export function TextArea({
|
|
264
|
+
value,
|
|
265
|
+
onChange,
|
|
266
|
+
shell = "",
|
|
267
|
+
className = "",
|
|
268
|
+
fieldRef,
|
|
269
|
+
resizable = false,
|
|
270
|
+
...rest
|
|
271
|
+
}: {
|
|
272
|
+
value: string;
|
|
273
|
+
onChange: (value: string) => void;
|
|
274
|
+
/** 外壳上的额外类。高度、外边距、宽度——和这个框在页面上待在哪儿有关的事,调用方说了算。 */
|
|
275
|
+
shell?: string;
|
|
276
|
+
/** 字那一层的额外类:等宽、字号、`h-[180px]` 这类只跟可写区域有关的。 */
|
|
277
|
+
className?: string;
|
|
278
|
+
fieldRef?: React.RefObject<HTMLTextAreaElement | null>;
|
|
279
|
+
/** 能不能用鼠标拉高。只给写长文的地方,比如一份系统提示词。 */
|
|
280
|
+
resizable?: boolean;
|
|
281
|
+
} & Omit<ComponentPropsWithoutRef<"textarea">, "value" | "onChange" | "className" | "ref">)
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
### `Select`
|
|
285
|
+
|
|
286
|
+
源码:`packages/client/ui/src/inputs/Select.tsx`
|
|
287
|
+
|
|
288
|
+
```tsx
|
|
289
|
+
export function Select<T extends string>(props: {
|
|
290
|
+
value: T;
|
|
291
|
+
onChange: (value: T) => void;
|
|
292
|
+
options: { value: T; label: string; detail?: string; icon?: React.ReactNode }[];
|
|
293
|
+
ariaLabel?: string;
|
|
294
|
+
})
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
### `InlineSelect`
|
|
298
|
+
|
|
299
|
+
源码:`packages/client/ui/src/inputs/Select.tsx`
|
|
300
|
+
|
|
301
|
+
```tsx
|
|
302
|
+
/** 设置行里用的紧凑行内下拉,像「Zed ˅」那样只有字和箭头。 */
|
|
303
|
+
export function InlineSelect<T extends string>(props: {
|
|
304
|
+
value: T;
|
|
305
|
+
onChange: (value: T) => void;
|
|
306
|
+
options: { value: T; label: string; detail?: string; icon?: React.ReactNode }[];
|
|
307
|
+
ariaLabel?: string;
|
|
308
|
+
quiet?: boolean;
|
|
309
|
+
set?: boolean;
|
|
310
|
+
/** 给面板标题行而不是设置行——见 `Dropdown` 的 `size`。 */
|
|
311
|
+
compact?: boolean;
|
|
312
|
+
})
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
### `Toggle`
|
|
316
|
+
|
|
317
|
+
源码:`packages/client/ui/src/inputs/Toggle.tsx`
|
|
318
|
+
|
|
319
|
+
```tsx
|
|
320
|
+
export function Toggle({
|
|
321
|
+
checked,
|
|
322
|
+
onChange,
|
|
323
|
+
ariaLabel,
|
|
324
|
+
disabled,
|
|
325
|
+
}: {
|
|
326
|
+
checked: boolean;
|
|
327
|
+
onChange: (checked: boolean) => void;
|
|
328
|
+
ariaLabel?: string;
|
|
329
|
+
disabled?: boolean;
|
|
330
|
+
})
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
### `ComposerToggle`
|
|
334
|
+
|
|
335
|
+
源码:`packages/client/ui/src/inputs/ComposerToggle.tsx`
|
|
336
|
+
|
|
337
|
+
```tsx
|
|
338
|
+
export function ComposerToggle({
|
|
339
|
+
on,
|
|
340
|
+
onChange,
|
|
341
|
+
icon,
|
|
342
|
+
label,
|
|
343
|
+
tip,
|
|
344
|
+
size = "composer",
|
|
345
|
+
...data
|
|
346
|
+
}: {
|
|
347
|
+
on: boolean;
|
|
348
|
+
onChange: (on: boolean) => void;
|
|
349
|
+
/** 16px 的图标,颜色跟着文字走。 */
|
|
350
|
+
icon: ReactNode;
|
|
351
|
+
/** 开着时图标旁边的字。 */
|
|
352
|
+
label: string;
|
|
353
|
+
/** 悬停提示和无障碍名:说点了会怎样。 */
|
|
354
|
+
tip: string;
|
|
355
|
+
/** `sm` 放在 `Button size="sm"` 旁边:26px;主输入框的控件跟着 `--plume-composer-control`。 */
|
|
356
|
+
size?: "composer" | "sm";
|
|
357
|
+
} & { [key: `data-${string}`]: string | undefined })
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
### `WorktreeToggle`
|
|
361
|
+
|
|
362
|
+
源码:`packages/client/ui/src/inputs/WorktreeToggle.tsx`
|
|
363
|
+
|
|
364
|
+
```tsx
|
|
365
|
+
export function WorktreeToggle({
|
|
366
|
+
on,
|
|
367
|
+
onChange,
|
|
368
|
+
size = "composer",
|
|
369
|
+
}: {
|
|
370
|
+
on: boolean;
|
|
371
|
+
onChange: (on: boolean) => void;
|
|
372
|
+
/** `sm` 放在 `Button size="sm"` 旁边,比如建议卡片上:26px,主输入框的控件是 28px。 */
|
|
373
|
+
size?: "composer" | "sm";
|
|
374
|
+
})
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
### `Segmented`
|
|
378
|
+
|
|
379
|
+
源码:`packages/client/ui/src/inputs/Segmented.tsx`
|
|
380
|
+
|
|
381
|
+
```tsx
|
|
382
|
+
/**
|
|
383
|
+
* 一排互斥的选项,选中的那个下面垫着一块会滑过去的底。
|
|
384
|
+
*
|
|
385
|
+
* 一块共用的底从这里滑到那里,滑动本身就说明这几个选项是同一排里的几个位置,不是几个开关。
|
|
386
|
+
* 位置是量出来的,不按等分算:中英混排的标签不一样宽。量在 layout effect 里,赶在这一帧画出来之前——晚一帧的话,
|
|
387
|
+
* 第一次渲染就能看见那块底从左上角飞过来。
|
|
388
|
+
*
|
|
389
|
+
* 圆角外框 8px、滑块和每一格 6px:内衬 2px,两道圆弧才同心。
|
|
390
|
+
*/
|
|
391
|
+
export function Segmented<T extends string>({
|
|
392
|
+
value,
|
|
393
|
+
onChange,
|
|
394
|
+
options,
|
|
395
|
+
}: {
|
|
396
|
+
value: T;
|
|
397
|
+
onChange: (value: T) => void;
|
|
398
|
+
/**
|
|
399
|
+
* 给了 `icon` 这一格就画成图标,`label` 变成它的提示和无障碍名字。
|
|
400
|
+
* `label` 无论如何都要给:只有图标、没有名字的控件,读屏读不出,别人也猜不着。
|
|
401
|
+
*/
|
|
402
|
+
options: { value: T; label: string; icon?: ReactNode }[];
|
|
403
|
+
})
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
### `TabStrip`
|
|
407
|
+
|
|
408
|
+
源码:`packages/client/ui/src/primitives/TabStrip.tsx`
|
|
409
|
+
|
|
410
|
+
```tsx
|
|
411
|
+
export function TabStrip<T extends string>({
|
|
412
|
+
items,
|
|
413
|
+
value,
|
|
414
|
+
onChange,
|
|
415
|
+
label,
|
|
416
|
+
className = "",
|
|
417
|
+
}: {
|
|
418
|
+
items: readonly TabStripItem<T>[];
|
|
419
|
+
value: T;
|
|
420
|
+
onChange: (value: T) => void;
|
|
421
|
+
/** 标签列表的无障碍名。 */
|
|
422
|
+
label: string;
|
|
423
|
+
className?: string;
|
|
424
|
+
})
|
|
425
|
+
|
|
426
|
+
export interface TabStripItem<T extends string> {
|
|
427
|
+
id: T;
|
|
428
|
+
label: string;
|
|
429
|
+
/** 跟在文字后面,比文字淡。不传或 `undefined` 什么都不画。 */
|
|
430
|
+
count?: number;
|
|
431
|
+
icon?: ReactNode;
|
|
432
|
+
}
|
|
433
|
+
```
|
|
434
|
+
|
|
435
|
+
### `SearchField`
|
|
436
|
+
|
|
437
|
+
源码:`packages/client/ui/src/inputs/SearchField.tsx`
|
|
438
|
+
|
|
439
|
+
```tsx
|
|
440
|
+
/**
|
|
441
|
+
* 搜索框。
|
|
442
|
+
*
|
|
443
|
+
* 侧栏的会话搜索、审阅面板的文件筛选、文件树的筛选共用这一个。各写各的话,每个都有自己的高度、
|
|
444
|
+
* 自己的描边、自己对放大镜在框里还是框旁的看法——一个只有一个答案的问题,得到三个答案。
|
|
445
|
+
*
|
|
446
|
+
* 两种尺寸,因为两种场合确实不同:独占一行的框(`comfortable`),和塞进面板标题行、周围的行都很矮
|
|
447
|
+
* 的框(`compact`)。
|
|
448
|
+
*/
|
|
449
|
+
export function SearchField({
|
|
450
|
+
value,
|
|
451
|
+
onChange,
|
|
452
|
+
placeholder,
|
|
453
|
+
size = "compact",
|
|
454
|
+
autoFocus,
|
|
455
|
+
onEscape,
|
|
456
|
+
onEnter,
|
|
457
|
+
className = "",
|
|
458
|
+
}: {
|
|
459
|
+
value: string;
|
|
460
|
+
onChange: (next: string) => void;
|
|
461
|
+
placeholder?: string;
|
|
462
|
+
size?: "compact" | "comfortable";
|
|
463
|
+
autoFocus?: boolean;
|
|
464
|
+
/** Esc 先清空,已经空了才关闭,筛选框要的就是这样。 */
|
|
465
|
+
onEscape?: () => void;
|
|
466
|
+
/** 输入法组字结束后的 Enter——上屏候选词的那个 Enter 不算。 */
|
|
467
|
+
onEnter?: () => void;
|
|
468
|
+
className?: string;
|
|
469
|
+
})
|
|
470
|
+
```
|
|
471
|
+
|
|
472
|
+
### `NumberField`
|
|
473
|
+
|
|
474
|
+
源码:`packages/client/ui/src/inputs/pickers.tsx`
|
|
475
|
+
|
|
476
|
+
```tsx
|
|
477
|
+
/**
|
|
478
|
+
* 带自己画的步进按钮的数字框。
|
|
479
|
+
*
|
|
480
|
+
* 用 `type="text"` 加数字键盘提示,不用 `type="number"`:后者的上下箭头每个浏览器画得都不一样,而且它认为不合法的
|
|
481
|
+
* 输入一律报成空字符串,`1e5`、`--3` 看着像收下了,到手却是「什么也没敲」。
|
|
482
|
+
*
|
|
483
|
+
* 存的值不是每敲一下就改:聚焦时框里留一份草稿,下限是 11 时「1」才能留到变成「16」。永远到不了范围内的字符进不来,
|
|
484
|
+
* 加减按钮停在同样的边界上。拒掉的按键不提示。
|
|
485
|
+
*/
|
|
486
|
+
export function NumberField({
|
|
487
|
+
value,
|
|
488
|
+
onChange,
|
|
489
|
+
min = Number.NEGATIVE_INFINITY,
|
|
490
|
+
max = Number.POSITIVE_INFINITY,
|
|
491
|
+
step = 1,
|
|
492
|
+
width = 76,
|
|
493
|
+
label,
|
|
494
|
+
name,
|
|
495
|
+
}: {
|
|
496
|
+
value: number;
|
|
497
|
+
onChange: (value: number) => void;
|
|
498
|
+
min?: number;
|
|
499
|
+
max?: number;
|
|
500
|
+
step?: number;
|
|
501
|
+
width?: number;
|
|
502
|
+
label: string;
|
|
503
|
+
name?: string;
|
|
504
|
+
}): JSX.Element
|
|
505
|
+
```
|
|
506
|
+
|
|
507
|
+
### `Slider`
|
|
508
|
+
|
|
509
|
+
源码:`packages/client/ui/src/inputs/Slider.tsx`
|
|
510
|
+
|
|
511
|
+
```tsx
|
|
512
|
+
/**
|
|
513
|
+
* 轨道和把手自己画的滑块。原生 range 叠在最上面、完全透明,拖动、点轨道跳过去、方向键都是浏览器自己的,
|
|
514
|
+
* 画出来的部分只跟着 value 走。
|
|
515
|
+
*/
|
|
516
|
+
export function Slider({
|
|
517
|
+
value,
|
|
518
|
+
onChange,
|
|
519
|
+
min = 0,
|
|
520
|
+
max = 100,
|
|
521
|
+
step = 1,
|
|
522
|
+
label,
|
|
523
|
+
width = 180,
|
|
524
|
+
}: {
|
|
525
|
+
value: number;
|
|
526
|
+
onChange: (value: number) => void;
|
|
527
|
+
min?: number;
|
|
528
|
+
max?: number;
|
|
529
|
+
step?: number;
|
|
530
|
+
label: string;
|
|
531
|
+
width?: number | string;
|
|
532
|
+
})
|
|
533
|
+
```
|
|
534
|
+
|
|
535
|
+
### `ColorField`
|
|
536
|
+
|
|
537
|
+
源码:`packages/client/ui/src/inputs/ColorField.tsx`
|
|
538
|
+
|
|
539
|
+
```tsx
|
|
540
|
+
/**
|
|
541
|
+
* 那枚色块。里面的输入框是色块的一部分(底色就是它编辑的颜色),不是一个单独的输入框,所以不用 Input 的外观。
|
|
542
|
+
*/
|
|
543
|
+
export function ColorField({
|
|
544
|
+
value,
|
|
545
|
+
onChange,
|
|
546
|
+
label,
|
|
547
|
+
readOnly = false,
|
|
548
|
+
}: {
|
|
549
|
+
value: string;
|
|
550
|
+
onChange: (value: string) => void;
|
|
551
|
+
label: string;
|
|
552
|
+
readOnly?: boolean;
|
|
553
|
+
})
|
|
554
|
+
```
|
|
555
|
+
|
|
556
|
+
### `TimeField`
|
|
557
|
+
|
|
558
|
+
源码:`packages/client/ui/src/inputs/pickers.tsx`
|
|
559
|
+
|
|
560
|
+
```tsx
|
|
561
|
+
/**
|
|
562
|
+
* 时间,用两个列表挑,不用浏览器的时钟。
|
|
563
|
+
*
|
|
564
|
+
* `<input type="time">` 的分段、分隔符和弹出的面板都归平台:macOS 上是步进器,Windows 上是下拉。它还按系统区域解析和
|
|
565
|
+
* 显示,同一个任务在一台机器上读作 `09:00`,在另一台上读作 `9:00 AM`,存的却是同一串字。
|
|
566
|
+
*
|
|
567
|
+
* 两个列表把这些都去掉,顺便去掉了打字:要的只是某个小时的某一分,00:00–23:59 之外没有别的值要表达。
|
|
568
|
+
*/
|
|
569
|
+
export function TimeField({
|
|
570
|
+
value,
|
|
571
|
+
onChange,
|
|
572
|
+
label,
|
|
573
|
+
}: {
|
|
574
|
+
/** `HH:mm`,24 小时制。 */
|
|
575
|
+
value: string;
|
|
576
|
+
onChange: (value: string) => void;
|
|
577
|
+
label: string;
|
|
578
|
+
}): JSX.Element
|
|
579
|
+
```
|
|
580
|
+
|
|
581
|
+
### `ShortcutRecorder`
|
|
582
|
+
|
|
583
|
+
源码:`packages/client/ui/src/inputs/ShortcutRecorder.tsx`
|
|
584
|
+
|
|
585
|
+
```tsx
|
|
586
|
+
/**
|
|
587
|
+
* 录一组快捷键:点一下进入录制,按下的那组键就是值(Electron accelerator 的写法)。Esc 放弃,⌫ / Delete 清空。
|
|
588
|
+
*/
|
|
589
|
+
export function ShortcutRecorder({ value, onChange, placeholder }: { value?: string; onChange: (shortcut: string) => void; placeholder?: string })
|
|
590
|
+
```
|
|
591
|
+
|
|
592
|
+
### `useFieldFade`
|
|
593
|
+
|
|
594
|
+
源码:`packages/client/ui/src/inputs/useFieldFade.ts`
|
|
595
|
+
|
|
596
|
+
```tsx
|
|
597
|
+
export function useFieldFade(field: React.RefObject<HTMLElement | null>, host: React.RefObject<HTMLElement | null>): void
|
|
598
|
+
```
|
|
599
|
+
|
|
600
|
+
### `ChoiceMark`
|
|
601
|
+
|
|
602
|
+
源码:`packages/client/ui/src/primitives/ChoiceMark.tsx`
|
|
603
|
+
|
|
604
|
+
```tsx
|
|
605
|
+
/**
|
|
606
|
+
* 自己画的选中标记。
|
|
607
|
+
*
|
|
608
|
+
* 原生复选框带着宿主系统的样子:macOS 上是 Aqua,Windows 上是 Fluent,哪个都和周围 ink / accent 的
|
|
609
|
+
* 卡片对不上。一个标记,两种形状。
|
|
610
|
+
*/
|
|
611
|
+
export function ChoiceMark({
|
|
612
|
+
checked,
|
|
613
|
+
kind,
|
|
614
|
+
indeterminate = false,
|
|
615
|
+
className = "",
|
|
616
|
+
}: {
|
|
617
|
+
checked: boolean;
|
|
618
|
+
kind: "checkbox" | "radio";
|
|
619
|
+
indeterminate?: boolean;
|
|
620
|
+
className?: string;
|
|
621
|
+
})
|
|
622
|
+
```
|
|
623
|
+
|
|
624
|
+
## 弹层、菜单、对话框
|
|
625
|
+
|
|
626
|
+
### `Popover`
|
|
627
|
+
|
|
628
|
+
源码:`packages/client/ui/src/overlay/Popover.tsx`
|
|
629
|
+
|
|
630
|
+
```tsx
|
|
631
|
+
export function Popover({
|
|
632
|
+
anchor,
|
|
633
|
+
onClose,
|
|
634
|
+
children,
|
|
635
|
+
placement = "top",
|
|
636
|
+
align = "end",
|
|
637
|
+
width,
|
|
638
|
+
header,
|
|
639
|
+
footer,
|
|
640
|
+
aside,
|
|
641
|
+
maxHeight,
|
|
642
|
+
role = "menu",
|
|
643
|
+
label,
|
|
644
|
+
className = "",
|
|
645
|
+
bodyClassName = "",
|
|
646
|
+
surface: kind = "menu",
|
|
647
|
+
onMouseEnter,
|
|
648
|
+
onMouseLeave,
|
|
649
|
+
}: PopoverProps)
|
|
650
|
+
|
|
651
|
+
export interface PopoverProps {
|
|
652
|
+
/** 弹层挂在哪个元素上,右键菜单则是一个点。 */
|
|
653
|
+
anchor: Anchor;
|
|
654
|
+
onClose: () => void;
|
|
655
|
+
children: React.ReactNode;
|
|
656
|
+
/**
|
|
657
|
+
* 优先放在哪一侧。地方不够时自动翻转。
|
|
658
|
+
*
|
|
659
|
+
* `right` 把菜单放在触发元素旁边而不是下面,这对列表里的一行很重要:往下掉的菜单会盖住你正要拿来
|
|
660
|
+
* 对比的那几行。
|
|
661
|
+
*/
|
|
662
|
+
placement?: "top" | "bottom" | "right";
|
|
663
|
+
/** 弹层的哪条边和锚点对齐。 */
|
|
664
|
+
align?: "start" | "end" | "center";
|
|
665
|
+
width?: PopoverWidth;
|
|
666
|
+
/**
|
|
667
|
+
* 钉在滚动正文上面——搜索框、标题。
|
|
668
|
+
*
|
|
669
|
+
* 不放进 `children`,因为它不能滚动:一用就离开屏幕的筛选框,要改它还得滚回去。
|
|
670
|
+
*/
|
|
671
|
+
header?: React.ReactNode;
|
|
672
|
+
/** 钉在它下面,给不管列表多长都要够得着的动作。 */
|
|
673
|
+
footer?: React.ReactNode;
|
|
674
|
+
/**
|
|
675
|
+
* 钉在它左边——比如一列分类。
|
|
676
|
+
*
|
|
677
|
+
* 是槽位,理由和另外两个一样:跟着被筛选的列表一起滚走的筛选,要改还得滚回去。它也不能作为 sticky
|
|
678
|
+
* 的一列放在 `children` 里,因为滚动区的内容盒会裁掉横向溢出,而祖先上的任何 `overflow` 都会让
|
|
679
|
+
* `sticky` 粘在那个祖先上而不是视口上。
|
|
680
|
+
*/
|
|
681
|
+
aside?: React.ReactNode;
|
|
682
|
+
/** 给否则会长满窗口的面一个上限——见 `MENU_MAX_HEIGHT`。 */
|
|
683
|
+
maxHeight?: number;
|
|
684
|
+
/**
|
|
685
|
+
* 这个面是什么,给读窗口而不是看窗口的东西。
|
|
686
|
+
*
|
|
687
|
+
* 写死成 `menu` 对大多数是对的,对不是菜单的就错了:重命名表单、一段读数、一个滑块。被播报成菜单项
|
|
688
|
+
* 的输入框,比什么都不播报更糟。
|
|
689
|
+
*/
|
|
690
|
+
role?: "menu" | "listbox" | "dialog" | "group";
|
|
691
|
+
/** 触发元素没说清时,给这个面一个名字。 */
|
|
692
|
+
label?: string;
|
|
693
|
+
className?: string;
|
|
694
|
+
/** 加在滚动正文上,给自己给内容加内衬的面。 */
|
|
695
|
+
bodyClassName?: string;
|
|
696
|
+
/**
|
|
697
|
+
* 一列行,还是一样东西。
|
|
698
|
+
*
|
|
699
|
+
* `menu` 是每个菜单要的内衬:圆角需要地方,行不能跑到滚动条底下,渐隐的边说明列表在那之外还有。
|
|
700
|
+
* 这三样对只装一块自己画底色的面板的面都是错的——比如一段 diff,它的代码底色会在离卡片右边 25px、
|
|
701
|
+
* 离顶 6px 处就停了,缝里露出浮层的底。面板读起来像嵌在一张它没填满的卡片里。
|
|
702
|
+
*
|
|
703
|
+
* `panel` 把整个正文交给内容,让叠加的滑块浮在上面,这正是叠加滚动条的用途。
|
|
704
|
+
*/
|
|
705
|
+
surface?: "menu" | "panel";
|
|
706
|
+
/**
|
|
707
|
+
* 把悬停交给整个面,给悬停打开的弹层用。
|
|
708
|
+
*
|
|
709
|
+
* 调用方如果挂在自己传的 `header` 和 `children` 上,描边、滚动区自己的外边距、滚动条那一条和滑块都在
|
|
710
|
+
* 处理范围之外:指针从这些地方进入弹层,在调用方看来仍然在外面——待执行的关闭就跑了。抓住悬停预览的
|
|
711
|
+
* 滚动条会把它关掉。
|
|
712
|
+
*/
|
|
713
|
+
onMouseEnter?: () => void;
|
|
714
|
+
onMouseLeave?: () => void;
|
|
715
|
+
}
|
|
716
|
+
```
|
|
717
|
+
|
|
718
|
+
### `ContextMenu`
|
|
719
|
+
|
|
720
|
+
源码:`packages/client/ui/src/overlay/ContextMenu.tsx`
|
|
721
|
+
|
|
722
|
+
```tsx
|
|
723
|
+
/**
|
|
724
|
+
* 面本身。
|
|
725
|
+
*
|
|
726
|
+
* 每一行都会关掉它,在这里处理而不是每一项各自记得:右键菜单里没有不是动作的行,点完还开着的那个会是
|
|
727
|
+
* 应用里唯一这样的菜单。监听在冒泡阶段,所以行自己的处理已经先运行了。
|
|
728
|
+
*/
|
|
729
|
+
export function ContextMenu({
|
|
730
|
+
anchor,
|
|
731
|
+
onClose,
|
|
732
|
+
width = "default",
|
|
733
|
+
children,
|
|
734
|
+
}: {
|
|
735
|
+
anchor: { x: number; y: number } | null;
|
|
736
|
+
onClose: () => void;
|
|
737
|
+
width?: PopoverWidth;
|
|
738
|
+
children: React.ReactNode;
|
|
739
|
+
})
|
|
740
|
+
```
|
|
741
|
+
|
|
742
|
+
### `MenuItem`
|
|
743
|
+
|
|
744
|
+
源码:`packages/client/ui/src/overlay/Menu.tsx`
|
|
745
|
+
|
|
746
|
+
```tsx
|
|
747
|
+
/**
|
|
748
|
+
* 菜单里的一行。
|
|
749
|
+
*
|
|
750
|
+
* 每个菜单各自长一份的话——高度不同、圆角不同、有的干脆没有圆角——同一个手势在不同菜单里看着不一样。
|
|
751
|
+
* 视觉状态放在 `.plume-item` 里,需要别的形状的行(比如两行的选项)也能用上它们。
|
|
752
|
+
*/
|
|
753
|
+
export function MenuItem({
|
|
754
|
+
icon,
|
|
755
|
+
children,
|
|
756
|
+
detail,
|
|
757
|
+
hint,
|
|
758
|
+
trailing,
|
|
759
|
+
checked,
|
|
760
|
+
selected,
|
|
761
|
+
danger,
|
|
762
|
+
disabled,
|
|
763
|
+
title,
|
|
764
|
+
className = "",
|
|
765
|
+
onClick,
|
|
766
|
+
}: {
|
|
767
|
+
icon?: React.ReactNode;
|
|
768
|
+
children: React.ReactNode;
|
|
769
|
+
/**
|
|
770
|
+
* 解释这个选项的第二行。
|
|
771
|
+
*
|
|
772
|
+
* 在这里处理,而不是每个菜单自己排两行的行:自己排的话会长出自己的高度、内边距和悬停效果,同一个
|
|
773
|
+
* 手势在不同菜单里看着不一样。
|
|
774
|
+
*/
|
|
775
|
+
detail?: React.ReactNode;
|
|
776
|
+
/** 右对齐的注记:数量、快捷键数字、上下文大小。 */
|
|
777
|
+
hint?: React.ReactNode;
|
|
778
|
+
/** 右对齐的元素,放勾或箭头。 */
|
|
779
|
+
trailing?: React.ReactNode;
|
|
780
|
+
/**
|
|
781
|
+
* 让这一行成为开关设置:行尾一个小开关,`menuitemcheckbox` 语义。
|
|
782
|
+
*
|
|
783
|
+
* 文字仍是设置的名字——「自动更新」——状态由开关说。两样都用字写的话,「自动更新:开(点击关闭)」
|
|
784
|
+
* 是一句要解析的话,而本该扫一眼就够,还会跑出菜单的右边。
|
|
785
|
+
*/
|
|
786
|
+
checked?: boolean;
|
|
787
|
+
selected?: boolean;
|
|
788
|
+
danger?: boolean;
|
|
789
|
+
disabled?: boolean;
|
|
790
|
+
title?: string;
|
|
791
|
+
/**
|
|
792
|
+
* 加在行本身上的类,给用别的方式标记行的菜单。
|
|
793
|
+
*
|
|
794
|
+
* 比如右边那一列已经被占了、所以选中行用填充而不是打勾的菜单。选了这种方式的行得能说出来——在这里
|
|
795
|
+
* 说,高度、圆角和按下状态仍是共用的,而这正是行只住在一个地方的意义。
|
|
796
|
+
*/
|
|
797
|
+
className?: string;
|
|
798
|
+
onClick?: () => void;
|
|
799
|
+
})
|
|
800
|
+
```
|
|
801
|
+
|
|
802
|
+
### `MenuSeparator`
|
|
803
|
+
|
|
804
|
+
源码:`packages/client/ui/src/overlay/Menu.tsx`
|
|
805
|
+
|
|
806
|
+
```tsx
|
|
807
|
+
/** 分开菜单里的几组行。 */
|
|
808
|
+
export function MenuSeparator()
|
|
809
|
+
```
|
|
810
|
+
|
|
811
|
+
### `MenuLabel`
|
|
812
|
+
|
|
813
|
+
源码:`packages/client/ui/src/overlay/Menu.tsx`
|
|
814
|
+
|
|
815
|
+
```tsx
|
|
816
|
+
/** 一组行上面的小标题。 */
|
|
817
|
+
export function MenuLabel({ children }: { children: React.ReactNode })
|
|
818
|
+
```
|
|
819
|
+
|
|
820
|
+
### `Dialog`
|
|
821
|
+
|
|
822
|
+
源码:`packages/client/ui/src/overlay/Dialog.tsx`
|
|
823
|
+
|
|
824
|
+
```tsx
|
|
825
|
+
/**
|
|
826
|
+
* 里面放着 `DialogFrame` 的 `Overlay`。
|
|
827
|
+
*
|
|
828
|
+
* `actions` 和 `children` 可以是 `dismiss` 的函数,关对话框的按钮应该用它:`dismiss()` 播完退场再运行
|
|
829
|
+
* `onClose`,`dismiss(after)` 改为运行 `after`。按钮直接调用 `onClose` 会在那一帧卸掉对话框——那样
|
|
830
|
+
* 只有 Escape 和点遮罩是怎么来的就怎么走。
|
|
831
|
+
*/
|
|
832
|
+
export function Dialog({
|
|
833
|
+
onClose,
|
|
834
|
+
width = 480,
|
|
835
|
+
label,
|
|
836
|
+
returnFocus,
|
|
837
|
+
actions,
|
|
838
|
+
children,
|
|
839
|
+
...frame
|
|
840
|
+
}: {
|
|
841
|
+
onClose: () => void;
|
|
842
|
+
width?: number;
|
|
843
|
+
label?: string;
|
|
844
|
+
returnFocus?: HTMLElement;
|
|
845
|
+
actions: ReactNode | ((dismiss: Dismiss) => ReactNode);
|
|
846
|
+
children?: ReactNode | ((dismiss: Dismiss) => ReactNode);
|
|
847
|
+
} & Omit<Parameters<typeof DialogFrame>[0], "actions" | "children">)
|
|
848
|
+
```
|
|
849
|
+
|
|
850
|
+
### `DialogAction`
|
|
851
|
+
|
|
852
|
+
源码:`packages/client/ui/src/overlay/Dialog.tsx`
|
|
853
|
+
|
|
854
|
+
```tsx
|
|
855
|
+
/**
|
|
856
|
+
* 对话框底下的那一颗按钮。上面写着它会做的那件事。
|
|
857
|
+
*
|
|
858
|
+
* **写字,不画图标。** 这些按钮曾经有好几处只放一个 ✕ 和一个 ✓,名字挂在 tooltip 上。工具栏
|
|
859
|
+
* 里的图标按钮可以这么省——周围一排东西替它说明它属于哪一类;对话框底下这两颗没有这个周围,
|
|
860
|
+
* 它们是这次操作的结论本身,一个要说清按下去会发生什么,另一个要说清不按会怎样。一个勾在
|
|
861
|
+
* 「删除会话」「卸载插件」「导入 33 个模型」上长得一模一样,而读出区别的唯一办法是把鼠标停
|
|
862
|
+
* 上去等一个 tooltip——这正是一个「停下一切来问」的界面最不该要求的动作。
|
|
863
|
+
*
|
|
864
|
+
* `label` 留着,但只当 tooltip 用:可见的字已经是它的名字了。
|
|
865
|
+
*/
|
|
866
|
+
export function DialogAction({
|
|
867
|
+
children,
|
|
868
|
+
onClick,
|
|
869
|
+
tone = "secondary",
|
|
870
|
+
disabled,
|
|
871
|
+
autoFocus,
|
|
872
|
+
label,
|
|
873
|
+
className = "",
|
|
874
|
+
...rest
|
|
875
|
+
}: {
|
|
876
|
+
children: ReactNode;
|
|
877
|
+
onClick?: () => void;
|
|
878
|
+
tone?: "secondary" | "primary" | "danger";
|
|
879
|
+
disabled?: boolean;
|
|
880
|
+
autoFocus?: boolean;
|
|
881
|
+
/** 只当悬停提示。可见的字已经是按钮的名字。 */
|
|
882
|
+
label?: string;
|
|
883
|
+
className?: string;
|
|
884
|
+
} & Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children" | "onClick" | "type">)
|
|
885
|
+
```
|
|
886
|
+
|
|
887
|
+
### `Confirm`
|
|
888
|
+
|
|
889
|
+
源码:`packages/client/ui/src/overlay/Confirm.tsx`
|
|
890
|
+
|
|
891
|
+
```tsx
|
|
892
|
+
/**
|
|
893
|
+
* 放在应用模态面上的问题:居中、压着遮罩、Escape 或点遮罩关掉。
|
|
894
|
+
*
|
|
895
|
+
* 这三样都由 `Overlay` 提供,而且和应用里其它对话框拿到的方式一样——这正是通过它来问、而不是在每个
|
|
896
|
+
* 调用处各拼一个的意义。
|
|
897
|
+
*/
|
|
898
|
+
export function Confirm({ onCancel, ...options }: ConfirmOptions & { onCancel: () => void })
|
|
899
|
+
|
|
900
|
+
export interface ConfirmOptions {
|
|
901
|
+
/** 问题,说出是哪个东西。「卸载 Chrome?」——而不是「确定吗?」。 */
|
|
902
|
+
title: string;
|
|
903
|
+
/** 代价是什么,一行。标题已经说全了就省掉。 */
|
|
904
|
+
detail?: React.ReactNode;
|
|
905
|
+
/** 动词,写在做这件事的按钮上。 */
|
|
906
|
+
confirmLabel: string;
|
|
907
|
+
/** 取消按钮上的字,默认「取消」。 */
|
|
908
|
+
cancelLabel?: string;
|
|
909
|
+
/**
|
|
910
|
+
* `danger` 是默认,因为这个东西本来是给删除用的。
|
|
911
|
+
*
|
|
912
|
+
* `normal` 留给另一类问题:不是「这个删了拿不回来」,而是「以后要不要自动做这件事」。
|
|
913
|
+
* 两种都该停下一切来问,但只有前一种该是红的——把每个问题都画成危险,等于没画过危险。
|
|
914
|
+
*/
|
|
915
|
+
tone?: "danger" | "normal";
|
|
916
|
+
onConfirm: () => void;
|
|
917
|
+
/**
|
|
918
|
+
* 取消也要做点什么的时候。
|
|
919
|
+
*
|
|
920
|
+
* 绝大多数确认框的取消就是「什么都别发生」,所以这是可选的。但有一种问题不是这样:
|
|
921
|
+
* 「以后要不要自动做这件事」——那里的取消是一个回答(不要),把它当成没问过,
|
|
922
|
+
* 意思就是下次还问。
|
|
923
|
+
*/
|
|
924
|
+
onCancel?: () => void;
|
|
925
|
+
/**
|
|
926
|
+
* 同一个动词的另一种做法,比如「删除此会话」旁边的「删除 3 个会话」。
|
|
927
|
+
*
|
|
928
|
+
* 画成次要按钮,放在主按钮左边;有它的时候「取消」挪到最左,三颗按钮不挤成一排。
|
|
929
|
+
*/
|
|
930
|
+
alternate?: { label: string; onConfirm: () => void };
|
|
931
|
+
/** 说明和按钮之间的那一块,比如「不再询问」。大多数问题用不着。 */
|
|
932
|
+
children?: React.ReactNode;
|
|
933
|
+
}
|
|
934
|
+
```
|
|
935
|
+
|
|
936
|
+
### `useConfirmer`
|
|
937
|
+
|
|
938
|
+
源码:`packages/client/ui/src/overlay/Confirm.tsx`
|
|
939
|
+
|
|
940
|
+
```tsx
|
|
941
|
+
/**
|
|
942
|
+
* 给满是删除按钮的页面用的一个确认。
|
|
943
|
+
*
|
|
944
|
+
* 每颗按钮交出自己的问题和自己的后果;面、两颗按钮的措辞、取消是安全的那个,这些在这里定。否则一个
|
|
945
|
+
* 有六行可删除内容的页面要带着同一份状态的六个副本。
|
|
946
|
+
*/
|
|
947
|
+
export function useConfirmer()
|
|
948
|
+
```
|
|
949
|
+
|
|
950
|
+
### `useConfirmGate`
|
|
951
|
+
|
|
952
|
+
源码:`packages/client/ui/src/overlay/Confirm.tsx`
|
|
953
|
+
|
|
954
|
+
```tsx
|
|
955
|
+
/**
|
|
956
|
+
* 同一个问题,可以 await。
|
|
957
|
+
*
|
|
958
|
+
* 给在循环里问的调用方——粘贴五个文件,其中两个重名——回答得先回来,下一步才能定。写成 promise,
|
|
959
|
+
* 读起来就是普通的顺序代码,而不是每个分支一个回调。
|
|
960
|
+
*/
|
|
961
|
+
export function useConfirmGate()
|
|
962
|
+
```
|
|
963
|
+
|
|
964
|
+
### `Tooltip`
|
|
965
|
+
|
|
966
|
+
源码:`packages/client/ui/src/overlay/Tooltip.tsx`
|
|
967
|
+
|
|
968
|
+
```tsx
|
|
969
|
+
/**
|
|
970
|
+
* 应用的悬停提示,写成 JSX 包装,比属性读起来顺。
|
|
971
|
+
*
|
|
972
|
+
* 它只给子元素打标记——气泡本身由 `tooltip.ts` 里那一个文档级监听器画。所以两种写法表现完全一样,
|
|
973
|
+
* 这很重要,因为第三方库自己建的按钮只能靠属性够到。
|
|
974
|
+
*
|
|
975
|
+
* 取代浏览器的 `title`:那个改不了样式,要等一秒半左右,不跟应用的字体和主题,在 macOS 上画成一个
|
|
976
|
+
* 系统面板,看着像属于另一个程序。
|
|
977
|
+
*/
|
|
978
|
+
export function Tooltip({ label, children, side = "bottom" }: { label: string; children: React.ReactElement; side?: "top" | "bottom" })
|
|
979
|
+
```
|
|
980
|
+
|
|
981
|
+
### `usePopover`
|
|
982
|
+
|
|
983
|
+
源码:`packages/client/ui/src/overlay/Popover.tsx`
|
|
984
|
+
|
|
985
|
+
```tsx
|
|
986
|
+
/** 记下锚点元素和打开状态,每个弹层触发器都要的这一对。 */
|
|
987
|
+
export function usePopover()
|
|
988
|
+
```
|
|
989
|
+
|
|
990
|
+
### `MenuBody`
|
|
991
|
+
|
|
992
|
+
源码:`packages/client/ui/src/overlay/Menu.tsx`
|
|
993
|
+
|
|
994
|
+
```tsx
|
|
995
|
+
/**
|
|
996
|
+
* 每个菜单的内容所在的盒子。
|
|
997
|
+
*
|
|
998
|
+
* 行四周的留白在滚动区上(`--plume-menu-inset`),不在这里。这里再加一层内衬会叠在那圈外边距上,
|
|
999
|
+
* 上下就成了左右的两倍。
|
|
1000
|
+
*/
|
|
1001
|
+
export function MenuBody({
|
|
1002
|
+
children,
|
|
1003
|
+
insetIcons = false,
|
|
1004
|
+
className = "",
|
|
1005
|
+
}: {
|
|
1006
|
+
children: React.ReactNode;
|
|
1007
|
+
/** 每行都留出图标列,给只有部分行有图标的菜单用。 */
|
|
1008
|
+
insetIcons?: boolean;
|
|
1009
|
+
className?: string;
|
|
1010
|
+
})
|
|
1011
|
+
```
|
|
1012
|
+
|
|
1013
|
+
### `MenuSearch`
|
|
1014
|
+
|
|
1015
|
+
源码:`packages/client/ui/src/overlay/Menu.tsx`
|
|
1016
|
+
|
|
1017
|
+
```tsx
|
|
1018
|
+
/**
|
|
1019
|
+
* 列出超过一屏的菜单顶上的筛选框。
|
|
1020
|
+
*
|
|
1021
|
+
* 每个菜单各写一份的话,同样的高度、同样的放大镜、同样的占位文案,每多一份就多一次走样的机会。
|
|
1022
|
+
* 给 `Popover` 的 `header` 槽位用,列表滚动时它不动;它下面那条线属于那个槽位,不在这里画。
|
|
1023
|
+
*
|
|
1024
|
+
* Escape 先清空再关闭,和 `SearchField` 一样:第一次按撤销输入,只有没东西可撤销时按才关掉菜单。
|
|
1025
|
+
*/
|
|
1026
|
+
export function MenuSearch({
|
|
1027
|
+
value,
|
|
1028
|
+
onChange,
|
|
1029
|
+
placeholder,
|
|
1030
|
+
autoFocus = true,
|
|
1031
|
+
onKeyDown,
|
|
1032
|
+
}: {
|
|
1033
|
+
value: string;
|
|
1034
|
+
onChange: (value: string) => void;
|
|
1035
|
+
placeholder?: string;
|
|
1036
|
+
autoFocus?: boolean;
|
|
1037
|
+
/** 给焦点留在输入框里、由它来移动高亮(方向键和 Enter)的菜单用。 */
|
|
1038
|
+
onKeyDown?: (event: React.KeyboardEvent<HTMLInputElement>) => void;
|
|
1039
|
+
})
|
|
1040
|
+
```
|
|
1041
|
+
|
|
1042
|
+
### `MENU_MAX_HEIGHT`
|
|
1043
|
+
|
|
1044
|
+
源码:`packages/client/ui/src/overlay/Popover.tsx`
|
|
1045
|
+
|
|
1046
|
+
```tsx
|
|
1047
|
+
/**
|
|
1048
|
+
* 列东西的菜单最高能长到多高。
|
|
1049
|
+
*
|
|
1050
|
+
* 交给窗口决定的话,高屏幕上的一个列表菜单会从输入框一直铺到屏幕顶——技术上没错,但四十行的列表没人
|
|
1051
|
+
* 读到底。会滚动的菜单出于同一个理由各挑一个上限(280、300、320),这里把这个理由说一次。
|
|
1052
|
+
*/
|
|
1053
|
+
export const MENU_MAX_HEIGHT = 340;
|
|
1054
|
+
```
|
|
1055
|
+
|
|
1056
|
+
### `Overlay`
|
|
1057
|
+
|
|
1058
|
+
源码:`packages/client/ui/src/overlay/Overlay.tsx`
|
|
1059
|
+
|
|
1060
|
+
```tsx
|
|
1061
|
+
/** Escape、点遮罩、取消和完成共用一套生命周期,嵌套的对话框也一样。 */
|
|
1062
|
+
export function Overlay({
|
|
1063
|
+
children,
|
|
1064
|
+
onClose,
|
|
1065
|
+
align = "center",
|
|
1066
|
+
width = 460,
|
|
1067
|
+
label,
|
|
1068
|
+
returnFocus,
|
|
1069
|
+
}: {
|
|
1070
|
+
children: React.ReactNode | ((dismiss: Dismiss) => React.ReactNode);
|
|
1071
|
+
onClose: () => void;
|
|
1072
|
+
align?: "center" | "bottom";
|
|
1073
|
+
width?: number;
|
|
1074
|
+
label?: string;
|
|
1075
|
+
returnFocus?: HTMLElement;
|
|
1076
|
+
})
|
|
1077
|
+
|
|
1078
|
+
export type Dismiss = (after?: () => void) => void;
|
|
1079
|
+
```
|
|
1080
|
+
|
|
1081
|
+
## 卡片和代码
|
|
1082
|
+
|
|
1083
|
+
### `Card`
|
|
1084
|
+
|
|
1085
|
+
源码:`packages/client/ui/src/index.tsx`
|
|
1086
|
+
|
|
1087
|
+
```tsx
|
|
1088
|
+
export function Card({ title, subtitle, status, tone = "default", children }: CardProps)
|
|
1089
|
+
|
|
1090
|
+
export interface CardProps {
|
|
1091
|
+
title: ReactNode;
|
|
1092
|
+
/** 标题右边的简短说明,单行,超出省略。 */
|
|
1093
|
+
subtitle?: ReactNode;
|
|
1094
|
+
/** 右上角的状态文字。 */
|
|
1095
|
+
status?: ReactNode;
|
|
1096
|
+
tone?: "default" | "error" | "warning";
|
|
1097
|
+
children?: ReactNode;
|
|
1098
|
+
}
|
|
1099
|
+
```
|
|
1100
|
+
|
|
1101
|
+
### `CodeBlock`
|
|
1102
|
+
|
|
1103
|
+
源码:`packages/client/ui/src/index.tsx`
|
|
1104
|
+
|
|
1105
|
+
```tsx
|
|
1106
|
+
export function CodeBlock({ children }: { children: string })
|
|
1107
|
+
```
|
|
1108
|
+
|
|
1109
|
+
### `DiffView`
|
|
1110
|
+
|
|
1111
|
+
源码:`packages/client/ui/src/index.tsx`
|
|
1112
|
+
|
|
1113
|
+
```tsx
|
|
1114
|
+
export function DiffView({ before, after }: { before: string; after: string })
|
|
1115
|
+
```
|
|
1116
|
+
|
|
1117
|
+
## 审批卡
|
|
1118
|
+
|
|
1119
|
+
### `ApprovalFrame`
|
|
1120
|
+
|
|
1121
|
+
源码:`packages/client/ui/src/layout/ApprovalFrame.tsx`
|
|
1122
|
+
|
|
1123
|
+
```tsx
|
|
1124
|
+
export function ApprovalFrame({
|
|
1125
|
+
requestId,
|
|
1126
|
+
icon,
|
|
1127
|
+
title,
|
|
1128
|
+
kind,
|
|
1129
|
+
expiresAt,
|
|
1130
|
+
queued = 0,
|
|
1131
|
+
children,
|
|
1132
|
+
}: {
|
|
1133
|
+
/** 这次请求的 id(`toolCallId`):换了一张卡,收起状态不带过去。 */
|
|
1134
|
+
requestId: string;
|
|
1135
|
+
/** 标题前 15px 的图标:权限请求是警告,提问是气泡。 */
|
|
1136
|
+
icon: ReactNode;
|
|
1137
|
+
title: ReactNode;
|
|
1138
|
+
/** 类型小标签(「执行命令」);提问这类不是权限的不写。 */
|
|
1139
|
+
kind?: string;
|
|
1140
|
+
/** `ApprovalRequest.expiresAt`,有就画倒计时。 */
|
|
1141
|
+
expiresAt?: number | undefined;
|
|
1142
|
+
/** 排在这张后面还在等的张数,大于 0 时写 +N。 */
|
|
1143
|
+
queued?: number;
|
|
1144
|
+
/** 正文和按钮,收起时一起收。 */
|
|
1145
|
+
children: ReactNode;
|
|
1146
|
+
})
|
|
1147
|
+
```
|
|
1148
|
+
|
|
1149
|
+
### `ApprovalExpiry`
|
|
1150
|
+
|
|
1151
|
+
源码:`packages/client/ui/src/layout/ApprovalFrame.tsx`
|
|
1152
|
+
|
|
1153
|
+
```tsx
|
|
1154
|
+
/**
|
|
1155
|
+
* 这张卡还剩多久失效,每秒走一格(「4:59 后失效」)。到期没人答,本体按拒绝收场:不说出来的截止比没有截止更糟,
|
|
1156
|
+
* 人回来时这一轮已经被一个没见过的「不」结束了。
|
|
1157
|
+
*
|
|
1158
|
+
* 单独一个组件,秒针走动不重画旁边正在打字的表单。走到零就不说了:正常情况下卡片这一刻已经收走,留下的只可能是
|
|
1159
|
+
* 一份过时的快照,「0:00 后失效」不该出现。
|
|
1160
|
+
*/
|
|
1161
|
+
export function ApprovalExpiry({ at }: { at: number })
|
|
1162
|
+
```
|
|
1163
|
+
|
|
1164
|
+
## 设置分区
|
|
1165
|
+
|
|
1166
|
+
### `SettingsSectionTitle`
|
|
1167
|
+
|
|
1168
|
+
源码:`packages/client/ui/src/layout/Settings.tsx`
|
|
1169
|
+
|
|
1170
|
+
```tsx
|
|
1171
|
+
/** 一组卡片上面的分组标题。 */
|
|
1172
|
+
export function SettingsSectionTitle({ children }: { children: React.ReactNode })
|
|
1173
|
+
```
|
|
1174
|
+
|
|
1175
|
+
### `SettingsCard`
|
|
1176
|
+
|
|
1177
|
+
源码:`packages/client/ui/src/layout/Settings.tsx`
|
|
1178
|
+
|
|
1179
|
+
```tsx
|
|
1180
|
+
/**
|
|
1181
|
+
* 其余属性原样透传,卡片可以带一个 `data-` 标记给测试量它。
|
|
1182
|
+
*
|
|
1183
|
+
* 卡片用 `float` 那张纸:设置卡片和弹出菜单是同一个颜色,两个主题下都比页面亮一档的实底。边框随之用 `line-float`,
|
|
1184
|
+
* 那是给「画在自己那张纸上」的线准备的;卡片里面的线和控件怎么跟着换,见 `controls.css` 的 `.plume-settings-card`。
|
|
1185
|
+
*/
|
|
1186
|
+
export function SettingsCard({ children, className = "", ...rest }: React.ComponentProps<"div">)
|
|
1187
|
+
```
|
|
1188
|
+
|
|
1189
|
+
### `SettingsRow`
|
|
1190
|
+
|
|
1191
|
+
源码:`packages/client/ui/src/layout/Settings.tsx`
|
|
1192
|
+
|
|
1193
|
+
```tsx
|
|
1194
|
+
/** 卡片里带标签的一行,控件靠右。 */
|
|
1195
|
+
export function SettingsRow({
|
|
1196
|
+
title,
|
|
1197
|
+
detail,
|
|
1198
|
+
control,
|
|
1199
|
+
children,
|
|
1200
|
+
}: {
|
|
1201
|
+
title: React.ReactNode;
|
|
1202
|
+
detail?: React.ReactNode;
|
|
1203
|
+
control?: React.ReactNode;
|
|
1204
|
+
children?: React.ReactNode;
|
|
1205
|
+
})
|
|
1206
|
+
```
|
|
1207
|
+
|
|
1208
|
+
### `SettingsNotice`
|
|
1209
|
+
|
|
1210
|
+
源码:`packages/client/ui/src/layout/Settings.tsx`
|
|
1211
|
+
|
|
1212
|
+
```tsx
|
|
1213
|
+
/**
|
|
1214
|
+
* 页面要告诉你的一句话:出错了(`danger`)、有东西要看一眼(`accent`),或者只是让你知道(`muted`)。
|
|
1215
|
+
*
|
|
1216
|
+
* 框和语气在这里定一次,产品的设置页和插件加的设置分区共用。安静的那种不定字色:用它的页面在里面放一行偏灰的标题、
|
|
1217
|
+
* 几行更淡的说明,这里给一种颜色会把层次压平。
|
|
1218
|
+
*/
|
|
1219
|
+
export function SettingsNotice({
|
|
1220
|
+
tone,
|
|
1221
|
+
icon: Icon,
|
|
1222
|
+
className = "",
|
|
1223
|
+
children,
|
|
1224
|
+
...rest
|
|
1225
|
+
}: React.ComponentProps<"div"> & { tone: keyof typeof NOTICE_TONES; icon?: LucideIcon })
|
|
1226
|
+
|
|
1227
|
+
const NOTICE_TONES = {
|
|
1228
|
+
danger: "border-danger/30 bg-danger/10 text-danger",
|
|
1229
|
+
accent: "border-accent/35 bg-accent/6 text-ink-muted",
|
|
1230
|
+
muted: "border-line",
|
|
1231
|
+
} as const;
|
|
1232
|
+
```
|
|
1233
|
+
|
|
1234
|
+
## 面板
|
|
1235
|
+
|
|
1236
|
+
### `PanelEmpty`
|
|
1237
|
+
|
|
1238
|
+
源码:`packages/client/ui/src/layout/PanelEmpty.tsx`
|
|
1239
|
+
|
|
1240
|
+
```tsx
|
|
1241
|
+
/**
|
|
1242
|
+
* 空面板正文的中间。
|
|
1243
|
+
*
|
|
1244
|
+
* 居中的图标、名字、一句话说这是什么。每一种标签页都是同一个形状,面板读起来是一个东西装着几种内容,
|
|
1245
|
+
* 而不是碰巧共用一个框的几块互不相干的屏。
|
|
1246
|
+
*
|
|
1247
|
+
* 单独一个文件,因为面板外壳和它里面的东西都要用它,由外壳导出的话两边会互相 import。
|
|
1248
|
+
*/
|
|
1249
|
+
export function PanelEmpty({
|
|
1250
|
+
icon: Icon,
|
|
1251
|
+
art,
|
|
1252
|
+
title,
|
|
1253
|
+
children,
|
|
1254
|
+
}: {
|
|
1255
|
+
icon: LucideIcon;
|
|
1256
|
+
/** 画在图标的位置上、比一枚线性图标更能说明这里该有什么的东西——子智能体面板放的是几张睡着的脸。 */
|
|
1257
|
+
art?: React.ReactNode;
|
|
1258
|
+
title: string;
|
|
1259
|
+
/** 可省:有些空状态的标题已经把话说完了,底下再补一句就是在解释一个不需要解释的东西。 */
|
|
1260
|
+
children?: React.ReactNode;
|
|
1261
|
+
})
|
|
1262
|
+
```
|
|
1263
|
+
|
|
1264
|
+
### `RetainedViews`
|
|
1265
|
+
|
|
1266
|
+
源码:`packages/client/ui/src/layout/RetainedViews.tsx`
|
|
1267
|
+
|
|
1268
|
+
```tsx
|
|
1269
|
+
/** 留住少数几个看过的页面;藏起来的页面暂停 effect,保留局部状态。 */
|
|
1270
|
+
export function RetainedViews<T extends string>({
|
|
1271
|
+
active,
|
|
1272
|
+
render,
|
|
1273
|
+
limit = 3,
|
|
1274
|
+
pageClassName = "plume-page-enter",
|
|
1275
|
+
slide,
|
|
1276
|
+
}: {
|
|
1277
|
+
active: T;
|
|
1278
|
+
render: (key: T) => React.ReactNode;
|
|
1279
|
+
limit?: number;
|
|
1280
|
+
/** 到达时的类。不同的页面可以换一种更长的淡入加升起。 */
|
|
1281
|
+
pageClassName?: string;
|
|
1282
|
+
/**
|
|
1283
|
+
* 标签,按标签条上的顺序:每次切换,新页面从它所在的那一侧滑进来并淡入,而不是一帧之内换掉。
|
|
1284
|
+
*
|
|
1285
|
+
* 用 Web Animations API 在布局 effect 里播,不用类。用类的话——见下面 `seen` 的注释——从 `display: none`
|
|
1286
|
+
* 回来的页面会先在终点画一帧,动画才接上。布局 effect 在页面显示之后、任何东西画出来之前跑,所以谁看到的
|
|
1287
|
+
* 第一帧都已经是滑入的第一帧。回到一个页面也播:标签条是来回切的地方,只在第一次有动画的切换,第二次读起来
|
|
1288
|
+
* 就像坏了。
|
|
1289
|
+
*/
|
|
1290
|
+
slide?: readonly T[];
|
|
1291
|
+
})
|
|
1292
|
+
```
|
|
1293
|
+
|
|
1294
|
+
### `useNarrow`
|
|
1295
|
+
|
|
1296
|
+
源码:`packages/client/ui/src/hooks/useNarrow.ts`
|
|
1297
|
+
|
|
1298
|
+
```tsx
|
|
1299
|
+
/**
|
|
1300
|
+
* 一个元素是不是窄到放不下两栏,按元素自己量。
|
|
1301
|
+
*
|
|
1302
|
+
* 面板里的内容没法问窗口自己有多宽——同一个组件既跑在 368px 的面板里,也跑在整列宽的正文里。
|
|
1303
|
+
* 这里看的是它实际拿到的那个盒子。
|
|
1304
|
+
*
|
|
1305
|
+
* 第一次测量在节点一到就做,而不是留给 observer 的首次回调。那次回调在绘制之后才到,一个从 `false`
|
|
1306
|
+
* 起步的组件会先画一帧错的布局——更糟的是,挂在这次翻转上的东西(比如列表/详情的重置)会在用户已经
|
|
1307
|
+
* 动手之后晚一拍才触发,把刚做的操作撤掉。
|
|
1308
|
+
*
|
|
1309
|
+
* 用回调 ref,不用 ref 对象。这不是风格问题:`useRef` + `useLayoutEffect` 只在 effect 第一次运行时
|
|
1310
|
+
* 挂 observer,一个先渲染了*别的*东西的组件——比如项目加载时的空状态——要么根本没挂上,要么挂到一个
|
|
1311
|
+
* 随后被换掉的节点上。两种情况下 observer 都在看一个不存在的东西,标记停在碰巧的那个值上,一个开到
|
|
1312
|
+
* 全屏的面板在一千像素宽时还在画堆叠布局。React 每次节点变化都会调用回调 ref,而节点变化正是要紧的那件事。
|
|
1313
|
+
*/
|
|
1314
|
+
export function useNarrow(threshold: number): [boolean, (node: HTMLDivElement | null) => void]
|
|
1315
|
+
```
|
|
1316
|
+
|
|
1317
|
+
### `useLiveRefresh`
|
|
1318
|
+
|
|
1319
|
+
源码:`packages/client/ui/src/hooks/useLiveRefresh.ts`
|
|
1320
|
+
|
|
1321
|
+
```tsx
|
|
1322
|
+
export function useLiveRefresh(refresh: () => void | Promise<void>, running: boolean, ms = LIVE_POLL_MS): void
|
|
1323
|
+
```
|
|
1324
|
+
|
|
1325
|
+
## 页面
|
|
1326
|
+
|
|
1327
|
+
### `PageTopRow`
|
|
1328
|
+
|
|
1329
|
+
源码:`packages/client/ui/src/layout/PageTopRow.tsx`
|
|
1330
|
+
|
|
1331
|
+
```tsx
|
|
1332
|
+
/**
|
|
1333
|
+
* 自己画顶行的页面(`ui.page` 给了 `topRow: true`)每一栏最上面那一行:坐在窗口顶行的高度上,和侧栏顶上那一行齐平。
|
|
1334
|
+
*
|
|
1335
|
+
* 整行拖得动窗口、双击能缩放;行里能点的控件(按钮、链接、输入框)自动挖成不拖动的,不用一个个标。不给整行
|
|
1336
|
+
* no-drag:那样这一栏宽的一段就既拖不动窗口、也双击不了。Windows、Linux 上窗口顶上另有一条拖窗口的带子,宿主在根元素上
|
|
1337
|
+
* 标 `data-plume-window-header`,这一行跟着改成不拖动(`app-region` 不认 `var()`,只能按选择器切):在卡片里拖窗口会带上系统标题栏的行为(右键系统菜单、拖到屏幕边贴边)。
|
|
1338
|
+
*
|
|
1339
|
+
* `start` 是贴着页面左边的那一栏:侧栏收起时红绿灯和前进、后退浮在这里,左边让出它们的宽度(宿主经
|
|
1340
|
+
* `--plume-top-row-inset` 给,侧栏开着时是 0)。两栏页面的右栏在窄窗口里换成整页时,也给 `start`。
|
|
1341
|
+
*
|
|
1342
|
+
* 高度、内边距之外的排法(间距、两端对齐)由 `className` 补;行里不折行,放不下的东西自己截断。
|
|
1343
|
+
*/
|
|
1344
|
+
export function PageTopRow({
|
|
1345
|
+
start = false,
|
|
1346
|
+
className = "",
|
|
1347
|
+
children,
|
|
1348
|
+
...rest
|
|
1349
|
+
}: { start?: boolean; className?: string; children?: ReactNode; ref?: Ref<HTMLElement> } & HTMLAttributes<HTMLElement>)
|
|
1350
|
+
```
|
|
1351
|
+
|
|
1352
|
+
## 加载和占位
|
|
1353
|
+
|
|
1354
|
+
### `ActionSpinner`
|
|
1355
|
+
|
|
1356
|
+
源码:`packages/client/ui/src/motion/loaders.tsx`
|
|
1357
|
+
|
|
1358
|
+
```tsx
|
|
1359
|
+
/**
|
|
1360
|
+
* 一段亮弧沿着一圈淡轨道跑。
|
|
1361
|
+
*
|
|
1362
|
+
* 轨道是这个记号的重点,不是装饰:亮弧单独转的话,图形的轮廓跟着弧一起走,在按钮那个方寸
|
|
1363
|
+
* 之间会读成「有东西在里面甩」。垫一圈淡的,形状就恒定是一个完整的圆,动的只有亮处。
|
|
1364
|
+
*
|
|
1365
|
+
* 轨道浓度走 `--plume-track`,默认 0.18。实心底的按钮(`bg-ink`、`bg-accent`)上 `currentColor`
|
|
1366
|
+
* 是底色的反色,0.18 基本看不见,那几处传 `onFill`——见 `ui/motion.css` 里那一档的注释。
|
|
1367
|
+
*/
|
|
1368
|
+
export function ActionSpinner({
|
|
1369
|
+
size = 14,
|
|
1370
|
+
className = "",
|
|
1371
|
+
onFill = false,
|
|
1372
|
+
}: {
|
|
1373
|
+
size?: number;
|
|
1374
|
+
className?: string;
|
|
1375
|
+
/** 画在实心底的按钮上(`bg-ink` / `bg-accent`),轨道要更浓才看得见。 */
|
|
1376
|
+
onFill?: boolean;
|
|
1377
|
+
})
|
|
1378
|
+
```
|
|
1379
|
+
|
|
1380
|
+
### `BreatheLoader`
|
|
1381
|
+
|
|
1382
|
+
源码:`packages/client/ui/src/motion/loaders.tsx`
|
|
1383
|
+
|
|
1384
|
+
```tsx
|
|
1385
|
+
/**
|
|
1386
|
+
* 一圈波纹离开静止的中心,颜色一路走过调色板。
|
|
1387
|
+
*
|
|
1388
|
+
* 给侧栏的会话行(替一个会话报状态的地方也用它,同一个会话在两处是同一个记号)。那一列可能同时有好几行在跑,而你还要用它读标题——所以这里连「亮处在走」都不
|
|
1389
|
+
* 要:什么都不旋转、什么都不位移,余光里它只是缓缓涨落的一团。换成上面那两个的话,一列里并排
|
|
1390
|
+
* 三四个各自明灭,读标题时总有东西在旁边闪。
|
|
1391
|
+
*
|
|
1392
|
+
* 颜色走完 accent → violet 的那 2.4 秒,是它说「跑了多久」的方式:扫一眼知道它活着,
|
|
1393
|
+
* 多看一会儿知道它**还**活着,而没有任何东西变快或变响。
|
|
1394
|
+
*/
|
|
1395
|
+
export function BreatheLoader({ size = 14, className = "" }: { size?: number; className?: string })
|
|
1396
|
+
```
|
|
1397
|
+
|
|
1398
|
+
### `SkeletonList`
|
|
1399
|
+
|
|
1400
|
+
源码:`packages/client/ui/src/primitives/Skeleton.tsx`
|
|
1401
|
+
|
|
1402
|
+
```tsx
|
|
1403
|
+
/** 一摞设置行,和它们代替的那个列表一致。 */
|
|
1404
|
+
export function SkeletonList({ count = 5, label = translate("common.reading") }: { count?: number; label?: string })
|
|
1405
|
+
```
|
|
1406
|
+
|
|
1407
|
+
### `SkeletonBar`
|
|
1408
|
+
|
|
1409
|
+
源码:`packages/client/ui/src/primitives/Skeleton.tsx`
|
|
1410
|
+
|
|
1411
|
+
```tsx
|
|
1412
|
+
/** 一块暗色的块。`width` 是任意 CSS 长度,一排这样的块看起来能像字而不是像条。 */
|
|
1413
|
+
export function SkeletonBar({ width, height = 10, className = "" }: { width: string; height?: number; className?: string })
|
|
1414
|
+
```
|
|
1415
|
+
|
|
1416
|
+
### `useSlowLoad`
|
|
1417
|
+
|
|
1418
|
+
源码:`packages/client/ui/src/primitives/Skeleton.tsx`
|
|
1419
|
+
|
|
1420
|
+
```tsx
|
|
1421
|
+
/**
|
|
1422
|
+
* 一次加载是否已经久到值得画占位。
|
|
1423
|
+
*
|
|
1424
|
+
* 只出现两帧的骨架是一次闪烁,闪烁读起来是故障而不是进度——缓存热的时候一个列表 59ms 就填满了,
|
|
1425
|
+
* 其中占位只待了 32ms。阈值以下什么都不显示,这是对一次没人察觉的等待最诚实的描绘。
|
|
1426
|
+
*/
|
|
1427
|
+
export function useSlowLoad(loading: boolean, delay = 220): boolean
|
|
1428
|
+
```
|
|
1429
|
+
|
|
1430
|
+
## 列表的行
|
|
1431
|
+
|
|
1432
|
+
### `HoverRow`
|
|
1433
|
+
|
|
1434
|
+
源码:`packages/client/ui/src/row/HoverRow.tsx`
|
|
1435
|
+
|
|
1436
|
+
```tsx
|
|
1437
|
+
export function HoverRow({
|
|
1438
|
+
controls,
|
|
1439
|
+
className = "",
|
|
1440
|
+
style,
|
|
1441
|
+
children,
|
|
1442
|
+
...rest
|
|
1443
|
+
}: ComponentPropsWithoutRef<"div"> & {
|
|
1444
|
+
controls: string;
|
|
1445
|
+
})
|
|
1446
|
+
```
|
|
1447
|
+
|
|
1448
|
+
### `HoverRowMark`
|
|
1449
|
+
|
|
1450
|
+
源码:`packages/client/ui/src/row/HoverRow.tsx`
|
|
1451
|
+
|
|
1452
|
+
```tsx
|
|
1453
|
+
/** 行首的图标列:每一行 git 都是同一个 12px 的框,工作树不额外缩进。 */
|
|
1454
|
+
export function HoverRowMark({ className = "", children }: { className?: string; children: ReactNode })
|
|
1455
|
+
```
|
|
1456
|
+
|
|
1457
|
+
### `HoverRowReveal`
|
|
1458
|
+
|
|
1459
|
+
源码:`packages/client/ui/src/row/HoverRow.tsx`
|
|
1460
|
+
|
|
1461
|
+
```tsx
|
|
1462
|
+
/** 按内容收缩的浮层。绝不做成定宽的一列:那样会把空着的框也画出来。 */
|
|
1463
|
+
export function HoverRowReveal({ className = "", children }: { className?: string; children: ReactNode })
|
|
1464
|
+
```
|
|
1465
|
+
|
|
1466
|
+
### `hoverSlot`
|
|
1467
|
+
|
|
1468
|
+
源码:`packages/client/ui/src/row/HoverRow.tsx`
|
|
1469
|
+
|
|
1470
|
+
```tsx
|
|
1471
|
+
/**
|
|
1472
|
+
* 控件条量好之前先写进 `--plume-row-controls` 的兜底值。
|
|
1473
|
+
*
|
|
1474
|
+
* 每个点击区约 21px(`p-1` 加 12.5 的图标)。左边 6px 对应右边的 `pr-1.5`,第一个图标的内距和
|
|
1475
|
+
* 最后一个一样。两个按钮的槽给 58px 时比置顶图标多出 17px——那就是多让出去的一段。
|
|
1476
|
+
*/
|
|
1477
|
+
export function hoverSlot(actions: 1 | 2 | 3): string
|
|
1478
|
+
```
|
|
1479
|
+
|
|
1480
|
+
### `Caret`
|
|
1481
|
+
|
|
1482
|
+
源码:`packages/client/ui/src/primitives/Caret.tsx`
|
|
1483
|
+
|
|
1484
|
+
```tsx
|
|
1485
|
+
export function Caret({
|
|
1486
|
+
open,
|
|
1487
|
+
/** 收起时箭头朝哪儿:`down` 展开到朝上(下拉),`right` 展开到朝下(树、折叠块)。 */
|
|
1488
|
+
from = "down",
|
|
1489
|
+
size = 12,
|
|
1490
|
+
strokeWidth = 2,
|
|
1491
|
+
className = "",
|
|
1492
|
+
/** 代码预览那种自带配色的表面上,颜色是算出来的而不是 token。 */
|
|
1493
|
+
style,
|
|
1494
|
+
}: {
|
|
1495
|
+
open: boolean;
|
|
1496
|
+
from?: "down" | "right";
|
|
1497
|
+
size?: number;
|
|
1498
|
+
strokeWidth?: number;
|
|
1499
|
+
className?: string;
|
|
1500
|
+
style?: React.CSSProperties;
|
|
1501
|
+
})
|
|
1502
|
+
```
|
|
1503
|
+
|
|
1504
|
+
### `MarkNumber`
|
|
1505
|
+
|
|
1506
|
+
源码:`packages/client/ui/src/primitives/MarkNumber.tsx`
|
|
1507
|
+
|
|
1508
|
+
```tsx
|
|
1509
|
+
/**
|
|
1510
|
+
* 一枚圆形序号:把两处说的同一件东西对上号。
|
|
1511
|
+
*
|
|
1512
|
+
* 正文里划线的那段字末尾挂一枚,审批卡上那条评论前面挂同一个数——人看到卡上的「2」,回到正文里
|
|
1513
|
+
* 找的就是那枚「2」,不用再读一遍引文去认。实心强调色、白字、等宽数字:两位数也不变形,深浅两套底色上
|
|
1514
|
+
* 都看得清。
|
|
1515
|
+
*/
|
|
1516
|
+
export function MarkNumber({ n, className = "", ...rest }: { n: number; className?: string } & Omit<HTMLAttributes<HTMLSpanElement>, "children">)
|
|
1517
|
+
```
|
|
1518
|
+
|
|
1519
|
+
## 过程行
|
|
1520
|
+
|
|
1521
|
+
### `FlowRow`
|
|
1522
|
+
|
|
1523
|
+
源码:`packages/client/ui/src/row/FlowRow.tsx`
|
|
1524
|
+
|
|
1525
|
+
```tsx
|
|
1526
|
+
export function FlowRow({
|
|
1527
|
+
icon,
|
|
1528
|
+
title,
|
|
1529
|
+
summary,
|
|
1530
|
+
trailing,
|
|
1531
|
+
trailFrom,
|
|
1532
|
+
running,
|
|
1533
|
+
open,
|
|
1534
|
+
onToggle,
|
|
1535
|
+
label,
|
|
1536
|
+
danger = false,
|
|
1537
|
+
...rest
|
|
1538
|
+
}: {
|
|
1539
|
+
/** 前置图标,常驻。它说的是这一行是什么,不参与展开、收起。 */
|
|
1540
|
+
icon: ReactNode;
|
|
1541
|
+
/** 这一行是什么(「自动压缩」)。不参与化开:装不下时先让出地方的永远该是摘要。 */
|
|
1542
|
+
title?: ReactNode;
|
|
1543
|
+
/** 这一行做了什么。长了两头化开、鼠标放上去自己读出来;运行中它是唯一带动效的东西。 */
|
|
1544
|
+
summary?: ReactNode;
|
|
1545
|
+
/** 靠右的附注,比如改动行数。 */
|
|
1546
|
+
trailing?: ReactNode;
|
|
1547
|
+
/** 挂上时附注从这个宽度缓过来,见 `FlowTrail`。 */
|
|
1548
|
+
trailFrom?: number | undefined;
|
|
1549
|
+
running?: boolean;
|
|
1550
|
+
/** 省略即为不可展开:不可展开的行没有箭头,也没有指针手势。 */
|
|
1551
|
+
open?: boolean | undefined;
|
|
1552
|
+
onToggle?: (() => void) | undefined;
|
|
1553
|
+
label?: string;
|
|
1554
|
+
/** 整行红字:这一行说的是一次失败(压缩失败)。 */
|
|
1555
|
+
danger?: boolean;
|
|
1556
|
+
} & Record<`data-${string}`, string | undefined>)
|
|
1557
|
+
```
|
|
1558
|
+
|
|
1559
|
+
## 文件图标
|
|
1560
|
+
|
|
1561
|
+
### `lookFor`
|
|
1562
|
+
|
|
1563
|
+
源码:`packages/client/ui/src/fileIcon.tsx`
|
|
1564
|
+
|
|
1565
|
+
```tsx
|
|
1566
|
+
export function lookFor(name: string, isDirectory: boolean, expanded = false): FileLook
|
|
1567
|
+
|
|
1568
|
+
export interface FileLook {
|
|
1569
|
+
Icon: LucideIcon;
|
|
1570
|
+
/** 不走 Tailwind:行内设上,因为这些颜色不在主题推导的色阶里。 */
|
|
1571
|
+
light: string;
|
|
1572
|
+
dark: string;
|
|
1573
|
+
}
|
|
1574
|
+
```
|
|
1575
|
+
|
|
1576
|
+
### `iconColour`
|
|
1577
|
+
|
|
1578
|
+
源码:`packages/client/ui/src/fileIcon.tsx`
|
|
1579
|
+
|
|
1580
|
+
```tsx
|
|
1581
|
+
/** 取在当前主题下读得清的那个色值。深浅色跟随系统,和 theme.css 的 `prefers-color-scheme` 一致。 */
|
|
1582
|
+
export function iconColour(look: FileLook): string
|
|
1583
|
+
|
|
1584
|
+
export interface FileLook {
|
|
1585
|
+
Icon: LucideIcon;
|
|
1586
|
+
/** 不走 Tailwind:行内设上,因为这些颜色不在主题推导的色阶里。 */
|
|
1587
|
+
light: string;
|
|
1588
|
+
dark: string;
|
|
1589
|
+
}
|
|
1590
|
+
```
|
|
1591
|
+
|
|
1592
|
+
## 滚动
|
|
1593
|
+
|
|
1594
|
+
### `Scroller`
|
|
1595
|
+
|
|
1596
|
+
源码:`packages/client/ui/src/scroll/Scroller.tsx`
|
|
1597
|
+
|
|
1598
|
+
```tsx
|
|
1599
|
+
/**
|
|
1600
|
+
* 应用里唯一的滚动面。
|
|
1601
|
+
*
|
|
1602
|
+
* 原生滚动容器做不到、而参考设计处处都有的几件事:
|
|
1603
|
+
*
|
|
1604
|
+
* - 滚动条浮在内容上,而不是预留一条槽,列表一长过它的盒子,内容不会横向重排;
|
|
1605
|
+
* - 边缘说明那个方向还藏着东西,画成边界实际的样子:内容化进空白的地方是渐隐,滑到实心的东西
|
|
1606
|
+
* 底下的地方是一道细线。见 `top`——选错了,列表看起来就像坏了。
|
|
1607
|
+
*
|
|
1608
|
+
* `overscroll-contain` 让滚到头的滚轮停在这里,而不是传给后面的窗口——否则嵌套列表一到底就会带着
|
|
1609
|
+
* 父级一起滚。
|
|
1610
|
+
*
|
|
1611
|
+
* 嵌在外层滚动面里的面(比如转录里的工具卡片、错误日志、代码输出),用 `overscroll="auto"`,
|
|
1612
|
+
* 里面滚到边界之后自然地接到外层。
|
|
1613
|
+
*/
|
|
1614
|
+
export function Scroller({
|
|
1615
|
+
children,
|
|
1616
|
+
className = "",
|
|
1617
|
+
contentClassName = "",
|
|
1618
|
+
top = "fade",
|
|
1619
|
+
fadeTop,
|
|
1620
|
+
bottom = "fade",
|
|
1621
|
+
overscroll = "contain",
|
|
1622
|
+
onScroll,
|
|
1623
|
+
onResize,
|
|
1624
|
+
onUserScroll,
|
|
1625
|
+
onHold,
|
|
1626
|
+
scrollRef,
|
|
1627
|
+
}: {
|
|
1628
|
+
children: React.ReactNode;
|
|
1629
|
+
className?: string;
|
|
1630
|
+
contentClassName?: string;
|
|
1631
|
+
/**
|
|
1632
|
+
* 滚到自己的边界之后,滚轮还传不传给外面。
|
|
1633
|
+
*
|
|
1634
|
+
* 判断标准是这一块**是页面里的一段内容,还是一块独立的面**。一段内容——设置页里的发版说明、一列
|
|
1635
|
+
* 搜索结果——读到底了还想接着往下看页面,是很自然的一件事,那就该给 `"auto"`。一块
|
|
1636
|
+
* 独立的面——弹窗、浮层、菜单、转录本身——滚到底就该停住,不然一个嵌套列表刚到底就把它背后的整页
|
|
1637
|
+
* 带跑了,那是默认的 `"contain"` 要挡的事。
|
|
1638
|
+
*
|
|
1639
|
+
* 「滚不动的面要不要拦」不用在这里操心:它一律放行,见下面 `chain` 那段。
|
|
1640
|
+
*/
|
|
1641
|
+
overscroll?: "contain" | "auto";
|
|
1642
|
+
/**
|
|
1643
|
+
* 上沿怎么收尾。
|
|
1644
|
+
*
|
|
1645
|
+
* `"fade"` 把最后几行柔化掉,不画任何东西就说出「这边还有」。上面是什么都成立——空窗格也好、
|
|
1646
|
+
* 不透明的导航也好——因为它降的是内容自己的 alpha,而不是往上面刷颜色;为什么这一点就是全部,
|
|
1647
|
+
* 见 `ui/scroll.css` 里 `.plume-fade-y` 上面那段。
|
|
1648
|
+
*
|
|
1649
|
+
* 只在确实有东西滚到底下去之后才出现,短列表完全没有边缘处理:边界在起作用时才值得画。
|
|
1650
|
+
*/
|
|
1651
|
+
top?: "fade" | "none";
|
|
1652
|
+
/** 默认值不适合这个列表时,上沿渐隐伸进去多深,单位 px。 */
|
|
1653
|
+
fadeTop?: number;
|
|
1654
|
+
/** 下沿的同一个问题。 */
|
|
1655
|
+
bottom?: "fade" | "none";
|
|
1656
|
+
onScroll?: (element: HTMLDivElement) => void;
|
|
1657
|
+
/** 内容或视口尺寸变了就调用。 */
|
|
1658
|
+
onResize?: (element: HTMLDivElement) => void;
|
|
1659
|
+
/**
|
|
1660
|
+
* 下面滑块的一次拖动,按它本来的手势报出来。
|
|
1661
|
+
*
|
|
1662
|
+
* 滑块是视口的兄弟节点,靠赋值 `scrollTop` 移动视口,于是拖动传到跟随方那里,是一个和浏览器
|
|
1663
|
+
* 夹住正在变短的转录分不开的滚动事件——而跟随方不能因为一次来路不明的位置变化就脱离(见
|
|
1664
|
+
* `follow.ts`),就永远发现不了读者在往外拉。这是唯一知道真相的组件把那个手势明说出来。
|
|
1665
|
+
*/
|
|
1666
|
+
onUserScroll?: (direction: Direction) => void;
|
|
1667
|
+
/**
|
|
1668
|
+
* 读者刚把手指按在了什么上面。
|
|
1669
|
+
*
|
|
1670
|
+
* 给跟随底部的那一方一个机会,把接下来这一次长高认成「读者点开了手里这一块」而不是「新消息
|
|
1671
|
+
* 到了」——两者在 `onResize` 里长得一模一样,而处理方式正好相反。
|
|
1672
|
+
*/
|
|
1673
|
+
onHold?: (target: EventTarget | null) => void;
|
|
1674
|
+
/** 给自己驱动滚动位置的调用方用,比如转录。 */
|
|
1675
|
+
scrollRef?: React.RefObject<HTMLDivElement | null>;
|
|
1676
|
+
})
|
|
1677
|
+
```
|
|
1678
|
+
|
|
1679
|
+
### `OverlayScrollbar`
|
|
1680
|
+
|
|
1681
|
+
源码:`packages/client/ui/src/scroll/OverlayScrollbar.tsx`
|
|
1682
|
+
|
|
1683
|
+
```tsx
|
|
1684
|
+
export function OverlayScrollbar({
|
|
1685
|
+
viewport,
|
|
1686
|
+
orientation,
|
|
1687
|
+
placement = "overlay",
|
|
1688
|
+
}: {
|
|
1689
|
+
viewport: React.RefObject<HTMLElement | null>;
|
|
1690
|
+
orientation: "vertical" | "horizontal";
|
|
1691
|
+
/**
|
|
1692
|
+
* `sticky`:轨道排在视口后面,粘在外面那个竖向滚动面的底部,落在内容在末尾预留的一条 10px 上。
|
|
1693
|
+
* 视口溢出期间带着 `data-hscroll="on"`,好让它的样式预留那一条——滑块不能盖住最后一行。只用于横向。
|
|
1694
|
+
*/
|
|
1695
|
+
placement?: "overlay" | "sticky";
|
|
1696
|
+
})
|
|
1697
|
+
```
|
|
1698
|
+
|
|
1699
|
+
### `ScrollText`
|
|
1700
|
+
|
|
1701
|
+
源码:`packages/client/ui/src/scroll/ScrollText.tsx`
|
|
1702
|
+
|
|
1703
|
+
```tsx
|
|
1704
|
+
/**
|
|
1705
|
+
* 一行字,到边上渐隐而不是以省略号结尾,所在的行被悬停时自己读出来。
|
|
1706
|
+
*
|
|
1707
|
+
* 省略号只报告标题被截了,不说截掉的是什么——每个以「帮我梳理这个项目的整体架构」开头的会话都缩成
|
|
1708
|
+
* 同一行没法用的字。渐隐说的是同一件事,但更安静,悬停滚动则真正回答了那个问题。
|
|
1709
|
+
*
|
|
1710
|
+
* 滚动只朝一个方向、连续地走。左右来回扫读起来像这一行在抽搐,而不是字在被读:眼睛得一次次重新
|
|
1711
|
+
* 找到开头。第一份后面跟着一份隐藏的副本,单向才能无缝循环——原文离开时,副本正好落在它开始的位置。
|
|
1712
|
+
*
|
|
1713
|
+
* 只有字真的读不全时才动:已经装得下的行,指针划过时不能抖。父级行带着 `plume-scroll`,悬停规则
|
|
1714
|
+
* 认的就是它。
|
|
1715
|
+
*/
|
|
1716
|
+
export function ScrollText({
|
|
1717
|
+
text,
|
|
1718
|
+
className = "",
|
|
1719
|
+
textClassName = "",
|
|
1720
|
+
}: {
|
|
1721
|
+
text: ReactNode;
|
|
1722
|
+
className?: string;
|
|
1723
|
+
/**
|
|
1724
|
+
* 加在装字形的那几个 span 上——正文和它循环用的副本——不加在盒子上。
|
|
1725
|
+
*
|
|
1726
|
+
* 给那些透过字本身画出来的效果用(`background-clip: text`、运行中的流光)。加在盒子上的话,它按
|
|
1727
|
+
* 盒子自己那一层的字形裁剪,而跑马灯的 transform 把轨道抬进了单独的一层:悬停时整行变成空白。
|
|
1728
|
+
*/
|
|
1729
|
+
textClassName?: string;
|
|
1730
|
+
})
|
|
1731
|
+
```
|
|
1732
|
+
|
|
1733
|
+
### `Sideways`
|
|
1734
|
+
|
|
1735
|
+
源码:`packages/client/ui/src/scroll/Sideways.tsx`
|
|
1736
|
+
|
|
1737
|
+
```tsx
|
|
1738
|
+
export function Sideways({
|
|
1739
|
+
children,
|
|
1740
|
+
className = "",
|
|
1741
|
+
trackRef,
|
|
1742
|
+
...rest
|
|
1743
|
+
}: {
|
|
1744
|
+
children: React.ReactNode;
|
|
1745
|
+
/** 给会滚的那一层,`overflow-x-auto` 和「我在父级里占多大」的类都写在这里。 */
|
|
1746
|
+
className?: string;
|
|
1747
|
+
/** 需要自己够到滚动容器的地方传进来——比如「把选中的标签滚进视野」。 */
|
|
1748
|
+
trackRef?: React.RefObject<HTMLDivElement | null>;
|
|
1749
|
+
} & Omit<React.HTMLAttributes<HTMLDivElement>, "className" | "children">)
|
|
1750
|
+
```
|
|
1751
|
+
|
|
1752
|
+
## 语言
|
|
1753
|
+
|
|
1754
|
+
### `useI18n`
|
|
1755
|
+
|
|
1756
|
+
源码:`packages/client/ui/src/i18n/context.tsx`
|
|
1757
|
+
|
|
1758
|
+
```tsx
|
|
1759
|
+
/** 当前界面语言和产品的文案目录。插件只用 `resolvedLocale` 选自己 `messages.ts` 里的那一份;`t()` 查的是产品的目录,插件的文字不在里面。 */
|
|
1760
|
+
export function useI18n(): I18nValue
|
|
1761
|
+
|
|
1762
|
+
interface I18nValue {
|
|
1763
|
+
locale: UiLocale;
|
|
1764
|
+
resolvedLocale: ResolvedUiLocale;
|
|
1765
|
+
t: (key: MessageKey, variables?: MessageVariables) => string;
|
|
1766
|
+
formatNumber: (value: number, options?: Intl.NumberFormatOptions) => string;
|
|
1767
|
+
formatDate: (value: Date | number, options?: Intl.DateTimeFormatOptions) => string;
|
|
1768
|
+
}
|
|
1769
|
+
```
|
|
1770
|
+
|
|
1771
|
+
## 时间
|
|
1772
|
+
|
|
1773
|
+
### `relativeTime`
|
|
1774
|
+
|
|
1775
|
+
源码:`packages/client/ui/src/lib/relative-time.ts`
|
|
1776
|
+
|
|
1777
|
+
```tsx
|
|
1778
|
+
/** 故意说得粗:列表里没人关心一次提交的确切分钟。 */
|
|
1779
|
+
export function relativeTime(iso: string, now = Date.now()): string
|
|
1780
|
+
```
|
|
1781
|
+
|
|
1782
|
+
### `TimeAgo`
|
|
1783
|
+
|
|
1784
|
+
源码:`packages/client/ui/src/primitives/TimeAgo.tsx`
|
|
1785
|
+
|
|
1786
|
+
```tsx
|
|
1787
|
+
export function TimeAgo({ iso, className = "" }: { iso: string; className?: string })
|
|
1788
|
+
```
|
|
1789
|
+
|
|
1790
|
+
## 键盘
|
|
1791
|
+
|
|
1792
|
+
### `macKeyboard`
|
|
1793
|
+
|
|
1794
|
+
源码:`packages/client/ui/src/keyboard.ts`
|
|
1795
|
+
|
|
1796
|
+
```tsx
|
|
1797
|
+
export function macKeyboard(platform = navigator.platform): boolean
|
|
1798
|
+
```
|
|
1799
|
+
|
|
1800
|
+
### `composingKey`
|
|
1801
|
+
|
|
1802
|
+
源码:`packages/client/ui/src/keyboard.ts`
|
|
1803
|
+
|
|
1804
|
+
```tsx
|
|
1805
|
+
/** 这些键归输入法和 AltGr;当成命令处理的话,打字打到一半就切了窗格。 */
|
|
1806
|
+
export function composingKey(event: KeyboardEvent): boolean
|
|
1807
|
+
```
|
|
1808
|
+
|
|
1809
|
+
### `shortcutLetter`
|
|
1810
|
+
|
|
1811
|
+
源码:`packages/client/ui/src/keyboard.ts`
|
|
1812
|
+
|
|
1813
|
+
```tsx
|
|
1814
|
+
/**
|
|
1815
|
+
* 快捷键按的是哪个字母:键帽上印的那个。
|
|
1816
|
+
*
|
|
1817
|
+
* `key` 是拉丁字母时用 `key`。QWERTY、AZERTY、QWERTZ、Dvorak 上它就是键帽上的字母,也就是快捷键的
|
|
1818
|
+
* 写法;改用 `code` 匹配的话,AZERTY 的 Ctrl+Alt+A 会变成印着 Q 的那个键,Dvorak 的 Ctrl+B 变成印着 X 的。
|
|
1819
|
+
*
|
|
1820
|
+
* 否则用 `code`。没有拉丁字母的布局(俄语、希腊语、希伯来语)没有印着 A 的键可按,用户会去按 A 所在
|
|
1821
|
+
* 位置的那个键——浏览器自己的快捷键和 CodeMirror 的键位表都是这样兜底的。macOS 的 Option 也落到这里:
|
|
1822
|
+
* 它是死键修饰,⌥S 到达时是「ß」,只有位置还说明它是 S。
|
|
1823
|
+
*/
|
|
1824
|
+
export function shortcutLetter(event: Pick<KeyboardEvent, "key" | "code">): string | null
|
|
1825
|
+
```
|
|
1826
|
+
|
|
1827
|
+
### `shortcutLabel`
|
|
1828
|
+
|
|
1829
|
+
源码:`packages/client/ui/src/keyboard.ts`
|
|
1830
|
+
|
|
1831
|
+
```tsx
|
|
1832
|
+
/** 界面文案一律用紧凑的 Mac 写法;别的键盘要把修饰键写成名字。 */
|
|
1833
|
+
export function shortcutLabel(text: string, platform = navigator.platform): string
|
|
1834
|
+
```
|
|
1835
|
+
|
|
1836
|
+
### `acceleratorLabel`
|
|
1837
|
+
|
|
1838
|
+
源码:`packages/client/ui/src/keyboard.ts`
|
|
1839
|
+
|
|
1840
|
+
```tsx
|
|
1841
|
+
/** 把 Electron 的 accelerator 写成按键文字,不把 Control、Command 和 Windows 键混为一谈。 */
|
|
1842
|
+
export function acceleratorLabel(accelerator: string, platform = navigator.platform): string
|
|
1843
|
+
```
|
|
1844
|
+
|
|
1845
|
+
## 动效
|
|
1846
|
+
|
|
1847
|
+
### `motionReduced`
|
|
1848
|
+
|
|
1849
|
+
源码:`packages/client/ui/src/motion/reduced.ts`
|
|
1850
|
+
|
|
1851
|
+
```tsx
|
|
1852
|
+
export function motionReduced(): boolean
|
|
1853
|
+
```
|
|
1854
|
+
|
|
1855
|
+
### `CountUp`
|
|
1856
|
+
|
|
1857
|
+
源码:`packages/client/ui/src/primitives/CountUp.tsx`
|
|
1858
|
+
|
|
1859
|
+
```tsx
|
|
1860
|
+
export function CountUp({
|
|
1861
|
+
value,
|
|
1862
|
+
className,
|
|
1863
|
+
/** 走动中的数怎么写。默认是四舍五入的整数。 */
|
|
1864
|
+
format = plain,
|
|
1865
|
+
bidirectional,
|
|
1866
|
+
ms,
|
|
1867
|
+
...rest
|
|
1868
|
+
}: {
|
|
1869
|
+
value: number;
|
|
1870
|
+
className?: string;
|
|
1871
|
+
format?: (shown: number) => string;
|
|
1872
|
+
/** 往下也走——见 `useCountUp`。给按更短的时段重算的读数。 */
|
|
1873
|
+
bidirectional?: boolean;
|
|
1874
|
+
ms?: number;
|
|
1875
|
+
} & Omit<React.ComponentProps<"span">, "className" | "children">)
|
|
1876
|
+
```
|
|
1877
|
+
|
|
1878
|
+
### `useRolled`
|
|
1879
|
+
|
|
1880
|
+
源码:`packages/client/ui/src/motion/RollingText.tsx`
|
|
1881
|
+
|
|
1882
|
+
```tsx
|
|
1883
|
+
/**
|
|
1884
|
+
* 这个值是否被替换过——首次绘制时是 false,从第一次变化起是 true。
|
|
1885
|
+
*
|
|
1886
|
+
* 有两件事靠它做对,而它们往相反的方向拉。
|
|
1887
|
+
*
|
|
1888
|
+
* 首次绘制不能动。挂上就滚的话,菜单一打开每个标签都落进位,叠在菜单自己的进场上——两个不同方向的动作,
|
|
1889
|
+
* 读起来像面板在你面前被拼起来。
|
|
1890
|
+
*
|
|
1891
|
+
* 可类也不能来来去去,这一点更隐蔽:给元素加上 `animation` 就会**开始**那个动画。一个在下一次无关渲染时
|
|
1892
|
+
* 退回 false 的标记,会在再下一次渲染时把类加回来,于是输入框里任何一个标签一变,所有标签都滚一遍。所以它
|
|
1893
|
+
* 是锁存的:一旦为 true 就一直是 true,此后动画完全由 `key` 变化引起的重新挂载来驱动。
|
|
1894
|
+
*
|
|
1895
|
+
* 在渲染期间调整状态,是从一个变了的 prop 推导状态的官方做法——React 丢掉这一遍、立刻重跑组件,于是类和新
|
|
1896
|
+
* key 落在同一次提交里,元素挂上时就已经在动。
|
|
1897
|
+
*/
|
|
1898
|
+
export function useRolled(value: string | number): boolean
|
|
1899
|
+
```
|
|
1900
|
+
|
|
1901
|
+
### `RollingText`
|
|
1902
|
+
|
|
1903
|
+
源码:`packages/client/ui/src/motion/RollingText.tsx`
|
|
1904
|
+
|
|
1905
|
+
```tsx
|
|
1906
|
+
/**
|
|
1907
|
+
* 一个值替换另一个值,不管发生在哪里。
|
|
1908
|
+
*
|
|
1909
|
+
* 原地变化的字——输入框里的推理强度、刚选的模型、权限模式、「展开显示」变成「收起」——不滚的话,眼睛除了
|
|
1910
|
+
* 字形不同以外收不到任何「发生了什么」的信号:在正拖着的控件上读起来像渲染瑕疵,在刚点过的控件上读起来像
|
|
1911
|
+
* 那一下点了没用。
|
|
1912
|
+
*
|
|
1913
|
+
* 动作是 `ui/motion.css` 里的 `.plume-roll`——总是往下,一连串的变化读起来是一个东西在被拨动,而不是字在闪。
|
|
1914
|
+
* 有了这个组件,用上它就是选对元素的事,而不必记住一个两步的写法(`key={value}` 加 `plume-roll`)。
|
|
1915
|
+
*
|
|
1916
|
+
* 给标签用,不给读数用。会跳的数——token 数、计时器、百分比——是在连续变化而不是被替换,每跳一下滚一次,
|
|
1917
|
+
* 一块安静的仪表就成了整轮里一直在余光里动的东西。那些用 `tabular-nums` 定住。
|
|
1918
|
+
*/
|
|
1919
|
+
export function RollingText({
|
|
1920
|
+
children,
|
|
1921
|
+
className = "",
|
|
1922
|
+
rollKey,
|
|
1923
|
+
}: {
|
|
1924
|
+
children: string | number;
|
|
1925
|
+
/** 落在外层元素上,它默认是 `inline`——需要的地方给它 `block`。 */
|
|
1926
|
+
className?: string;
|
|
1927
|
+
/** 改写什么算「换了一个值」,给带着一段总在变的部分的文字用。 */
|
|
1928
|
+
rollKey?: string | number;
|
|
1929
|
+
})
|
|
1930
|
+
```
|
|
1931
|
+
|
|
1932
|
+
### `useNow`
|
|
1933
|
+
|
|
1934
|
+
源码:`packages/client/ui/src/motion/useNow.ts`
|
|
1935
|
+
|
|
1936
|
+
```tsx
|
|
1937
|
+
/**
|
|
1938
|
+
* 此刻的时间,`active` 时每 `every` 毫秒重画一次:给秒表、倒计时这类在走的读数用,不走的时候一次也不重画。
|
|
1939
|
+
*
|
|
1940
|
+
* 变成 `active` 的那一下先对一次表:不对的话,挂上以后过了很久才开始走的读数,头一拍显示的是挂上那一刻。
|
|
1941
|
+
*/
|
|
1942
|
+
export function useNow(active: boolean, every: number): number
|
|
1943
|
+
```
|
|
1944
|
+
|
|
1945
|
+
### `Collapse`
|
|
1946
|
+
|
|
1947
|
+
源码:`packages/client/ui/src/layout/Collapse.tsx`
|
|
1948
|
+
|
|
1949
|
+
```tsx
|
|
1950
|
+
/** 收起时的内容留到尺寸过渡走完,再放掉开销大的子树。 */
|
|
1951
|
+
export function Collapse({
|
|
1952
|
+
open,
|
|
1953
|
+
children,
|
|
1954
|
+
bodyClassName,
|
|
1955
|
+
className = "",
|
|
1956
|
+
keepMounted = false,
|
|
1957
|
+
}: {
|
|
1958
|
+
open: boolean;
|
|
1959
|
+
children: ReactNode;
|
|
1960
|
+
bodyClassName?: string;
|
|
1961
|
+
className?: string;
|
|
1962
|
+
keepMounted?: boolean;
|
|
1963
|
+
})
|
|
1964
|
+
```
|
|
1965
|
+
|
|
1966
|
+
## 折叠
|
|
1967
|
+
|
|
1968
|
+
### `Disclosure`
|
|
1969
|
+
|
|
1970
|
+
源码:`packages/client/ui/src/layout/Disclosure.tsx`
|
|
1971
|
+
|
|
1972
|
+
```tsx
|
|
1973
|
+
export function Disclosure({
|
|
1974
|
+
title,
|
|
1975
|
+
count,
|
|
1976
|
+
trailing,
|
|
1977
|
+
children,
|
|
1978
|
+
variant = "section",
|
|
1979
|
+
...control
|
|
1980
|
+
}: {
|
|
1981
|
+
title: ReactNode;
|
|
1982
|
+
/** 显示在标题后面。零值得说;undefined 不说。 */
|
|
1983
|
+
count?: number;
|
|
1984
|
+
/** 属于这一段而不是它的内容的控件——一个编辑按钮、一个链接。 */
|
|
1985
|
+
trailing?: ReactNode;
|
|
1986
|
+
children: ReactNode;
|
|
1987
|
+
variant?: keyof typeof LOOK;
|
|
1988
|
+
} & Control)
|
|
1989
|
+
|
|
1990
|
+
const LOOK = {
|
|
1991
|
+
section: {
|
|
1992
|
+
root: "border-t border-line-soft first:border-t-0",
|
|
1993
|
+
head: "",
|
|
1994
|
+
button: "py-2.5",
|
|
1995
|
+
title: "text-label font-medium",
|
|
1996
|
+
body: "pb-3",
|
|
1997
|
+
},
|
|
1998
|
+
compact: {
|
|
1999
|
+
root: "",
|
|
2000
|
+
head: "",
|
|
2001
|
+
button: "py-1",
|
|
2002
|
+
title: "text-detail",
|
|
2003
|
+
body: "pl-3.5",
|
|
2004
|
+
},
|
|
2005
|
+
framed: {
|
|
2006
|
+
root: "overflow-hidden rounded-lg border border-line-soft",
|
|
2007
|
+
head: "pr-3",
|
|
2008
|
+
button: "py-2 pl-3",
|
|
2009
|
+
/*
|
|
2010
|
+
* 没有自己的字号。framed 的折叠坐在页面已经定好字号的正文里——README 按 label 字号、消息按 body
|
|
2011
|
+
* 字号——标题和周围的字差一档,读起来就像贴进来的第二份文档。
|
|
2012
|
+
*/
|
|
2013
|
+
title: "",
|
|
2014
|
+
body: "px-3 pb-3",
|
|
2015
|
+
},
|
|
2016
|
+
} as const;
|
|
2017
|
+
|
|
2018
|
+
type Control = { open: boolean; onToggle: () => void; defaultOpen?: never } | { open?: never; onToggle?: never; defaultOpen?: boolean };
|
|
2019
|
+
```
|
|
2020
|
+
|
|
2021
|
+
## 智能体头像
|
|
2022
|
+
|
|
2023
|
+
### `AgentAvatar`
|
|
2024
|
+
|
|
2025
|
+
源码:`packages/client/ui/src/avatar/AgentAvatar.tsx`
|
|
2026
|
+
|
|
2027
|
+
```tsx
|
|
2028
|
+
export function AgentAvatar({
|
|
2029
|
+
avatar,
|
|
2030
|
+
size = 20,
|
|
2031
|
+
mood = "idle",
|
|
2032
|
+
badge = null,
|
|
2033
|
+
interactive = true,
|
|
2034
|
+
host,
|
|
2035
|
+
seed = "",
|
|
2036
|
+
label,
|
|
2037
|
+
cheer = null,
|
|
2038
|
+
className = "",
|
|
2039
|
+
}: AgentAvatarProps)
|
|
2040
|
+
|
|
2041
|
+
export interface AgentAvatarProps {
|
|
2042
|
+
avatar: Avatar;
|
|
2043
|
+
/** 像素边长。 */
|
|
2044
|
+
size?: number;
|
|
2045
|
+
mood?: AvatarMood;
|
|
2046
|
+
/** 右下角的小圆点:在跑是强调色、会一圈圈往外扩;出错是红的。 */
|
|
2047
|
+
badge?: "running" | "failed" | null;
|
|
2048
|
+
/**
|
|
2049
|
+
* 悬停时眼睛跟着指针、身子弹一下。默认开。
|
|
2050
|
+
*
|
|
2051
|
+
* `host` 是往上找的那个容器:一整行的任何地方悬停都算,而不是非得对准这 20 像素。
|
|
2052
|
+
*/
|
|
2053
|
+
interactive?: boolean;
|
|
2054
|
+
host?: string;
|
|
2055
|
+
/** 名字,决定眨眼的节奏——七张脸不该踩着同一个拍子。 */
|
|
2056
|
+
seed?: string;
|
|
2057
|
+
/** 默认是装饰:名字总在旁边写着。单独出现、没有字陪着的时候才给它一个名字。 */
|
|
2058
|
+
label?: string;
|
|
2059
|
+
/** 换一个值就蹦一下——刚保存好、刚换了脸,给它一个「是我」的回应。 */
|
|
2060
|
+
cheer?: string | number | null;
|
|
2061
|
+
className?: string;
|
|
2062
|
+
}
|
|
2063
|
+
```
|
|
2064
|
+
|
|
2065
|
+
### `AvatarPile`
|
|
2066
|
+
|
|
2067
|
+
源码:`packages/client/ui/src/avatar/AvatarPile.tsx`
|
|
2068
|
+
|
|
2069
|
+
```tsx
|
|
2070
|
+
export function AvatarPile({
|
|
2071
|
+
faces,
|
|
2072
|
+
size = 22,
|
|
2073
|
+
max = 3,
|
|
2074
|
+
focus,
|
|
2075
|
+
className = "",
|
|
2076
|
+
}: {
|
|
2077
|
+
faces: PileFace[];
|
|
2078
|
+
size?: number;
|
|
2079
|
+
max?: number;
|
|
2080
|
+
/**
|
|
2081
|
+
* 要完整露出来的那一张(面板顶上:正在看的那个)。
|
|
2082
|
+
*
|
|
2083
|
+
* 它排在露出来的那几张的最后——也就是最上面,紧挨着旁边写它名字的标题。放不下的时候先让出别人的
|
|
2084
|
+
* 位置,它自己一定在。
|
|
2085
|
+
*/
|
|
2086
|
+
focus?: string;
|
|
2087
|
+
className?: string;
|
|
2088
|
+
})
|
|
2089
|
+
|
|
2090
|
+
export interface PileFace {
|
|
2091
|
+
key: string;
|
|
2092
|
+
avatar: Avatar;
|
|
2093
|
+
mood?: AvatarMood;
|
|
2094
|
+
seed: string;
|
|
2095
|
+
}
|
|
2096
|
+
```
|
|
2097
|
+
|
|
2098
|
+
### `assignAvatars`
|
|
2099
|
+
|
|
2100
|
+
源码:`packages/client/ui/src/lib/agent-avatar.ts`
|
|
2101
|
+
|
|
2102
|
+
```tsx
|
|
2103
|
+
/**
|
|
2104
|
+
* 一份名单里每个人的脸,保证互不相同。
|
|
2105
|
+
*
|
|
2106
|
+
* 写明了的先落座(定义里的 `avatar`,其次是 `fallback` 给的,内置的那几个就走这条),剩下的
|
|
2107
|
+
* **按名字排好序**再逐个算——排序是为了让结果只取决于名单里有谁,不取决于名单是按什么顺序
|
|
2108
|
+
* 送来的:设置页和提及菜单拿到的是同一批人、不同的顺序,两边必须画出同一张脸。
|
|
2109
|
+
*
|
|
2110
|
+
* 写明了的两个撞了,照写的来:那是人自己挑的。
|
|
2111
|
+
*/
|
|
2112
|
+
export function assignAvatars(
|
|
2113
|
+
agents: readonly { name: string; avatar?: string }[],
|
|
2114
|
+
fallback: (name: string) => string | undefined = () => undefined,
|
|
2115
|
+
): Map<string, Avatar>
|
|
2116
|
+
|
|
2117
|
+
export interface Avatar {
|
|
2118
|
+
shape: AvatarShape;
|
|
2119
|
+
color: AvatarColor;
|
|
2120
|
+
}
|
|
2121
|
+
```
|
|
2122
|
+
|
|
2123
|
+
### `AVATAR_SHAPES`
|
|
2124
|
+
|
|
2125
|
+
源码:`packages/client/ui/src/lib/agent-avatar.ts`
|
|
2126
|
+
|
|
2127
|
+
```tsx
|
|
2128
|
+
export const AVATAR_SHAPES = [
|
|
2129
|
+
"circle",
|
|
2130
|
+
"drop",
|
|
2131
|
+
"squircle",
|
|
2132
|
+
"blob",
|
|
2133
|
+
"cloud",
|
|
2134
|
+
"pill",
|
|
2135
|
+
"triangle",
|
|
2136
|
+
"flower",
|
|
2137
|
+
"hexagon",
|
|
2138
|
+
"ghost",
|
|
2139
|
+
"star",
|
|
2140
|
+
"bean",
|
|
2141
|
+
"arch",
|
|
2142
|
+
"diamond",
|
|
2143
|
+
"heart",
|
|
2144
|
+
"burst",
|
|
2145
|
+
] as const;
|
|
2146
|
+
```
|
|
2147
|
+
|
|
2148
|
+
### `AVATAR_COLORS`
|
|
2149
|
+
|
|
2150
|
+
源码:`packages/client/ui/src/lib/agent-avatar.ts`
|
|
2151
|
+
|
|
2152
|
+
```tsx
|
|
2153
|
+
export const AVATAR_COLORS = [
|
|
2154
|
+
"blue",
|
|
2155
|
+
"green",
|
|
2156
|
+
"pink",
|
|
2157
|
+
"teal",
|
|
2158
|
+
"violet",
|
|
2159
|
+
"brown",
|
|
2160
|
+
"orange",
|
|
2161
|
+
"red",
|
|
2162
|
+
"yellow",
|
|
2163
|
+
"indigo",
|
|
2164
|
+
"lime",
|
|
2165
|
+
"sky",
|
|
2166
|
+
"rose",
|
|
2167
|
+
"mint",
|
|
2168
|
+
"coral",
|
|
2169
|
+
"plum",
|
|
2170
|
+
] as const;
|
|
2171
|
+
```
|
|
2172
|
+
|
|
2173
|
+
### `COLOR_ART`
|
|
2174
|
+
|
|
2175
|
+
源码:`packages/client/ui/src/avatar/art.ts`
|
|
2176
|
+
|
|
2177
|
+
```tsx
|
|
2178
|
+
/**
|
|
2179
|
+
* 色值是亮暗两种主题共用的一套中等明度:眼睛是深色的,身体太暗眼睛就看不见,太浅在白底上
|
|
2180
|
+
* 就化开了。所以没有黑、没有灰、也没有嫩黄。
|
|
2181
|
+
*/
|
|
2182
|
+
export const COLOR_ART: Record<AvatarColor, string> = {
|
|
2183
|
+
blue: "#2f7cf6",
|
|
2184
|
+
green: "#1fa35c",
|
|
2185
|
+
pink: "#e3398f",
|
|
2186
|
+
teal: "#12aaa0",
|
|
2187
|
+
violet: "#8b5cf6",
|
|
2188
|
+
brown: "#8f5d36",
|
|
2189
|
+
orange: "#ff9a1f",
|
|
2190
|
+
red: "#ee4d4d",
|
|
2191
|
+
yellow: "#f2b61b",
|
|
2192
|
+
indigo: "#5d63f0",
|
|
2193
|
+
lime: "#7cbb2c",
|
|
2194
|
+
sky: "#36b3f0",
|
|
2195
|
+
rose: "#fb6f8e",
|
|
2196
|
+
mint: "#2fcf8f",
|
|
2197
|
+
coral: "#ff7b56",
|
|
2198
|
+
plum: "#b34fc8",
|
|
2199
|
+
};
|
|
2200
|
+
```
|
|
2201
|
+
|
|
2202
|
+
### `parseAvatar`
|
|
2203
|
+
|
|
2204
|
+
源码:`packages/client/ui/src/lib/agent-avatar.ts`
|
|
2205
|
+
|
|
2206
|
+
```tsx
|
|
2207
|
+
/** `cloud-violet` → `{ shape: "cloud", color: "violet" }`;认不出来的一律 `null`,由调用方兜底。 */
|
|
2208
|
+
export function parseAvatar(value: unknown): Avatar | null
|
|
2209
|
+
|
|
2210
|
+
export interface Avatar {
|
|
2211
|
+
shape: AvatarShape;
|
|
2212
|
+
color: AvatarColor;
|
|
2213
|
+
}
|
|
2214
|
+
```
|
|
2215
|
+
|
|
2216
|
+
### `formatAvatar`
|
|
2217
|
+
|
|
2218
|
+
源码:`packages/client/ui/src/lib/agent-avatar.ts`
|
|
2219
|
+
|
|
2220
|
+
```tsx
|
|
2221
|
+
export function formatAvatar(avatar: Avatar): string
|
|
2222
|
+
|
|
2223
|
+
export interface Avatar {
|
|
2224
|
+
shape: AvatarShape;
|
|
2225
|
+
color: AvatarColor;
|
|
2226
|
+
}
|
|
2227
|
+
```
|
|
2228
|
+
|
|
2229
|
+
### `freshAvatar`
|
|
2230
|
+
|
|
2231
|
+
源码:`packages/client/ui/src/lib/agent-avatar.ts`
|
|
2232
|
+
|
|
2233
|
+
```tsx
|
|
2234
|
+
/**
|
|
2235
|
+
* 给一个新智能体挑一张没人用过的脸。
|
|
2236
|
+
*
|
|
2237
|
+
* 不只是「这个组合没人用」:形状和颜色能都不撞就都不撞。内置的几个已经把圆、水滴、方块……和
|
|
2238
|
+
* 蓝、绿、粉……占了,新来的要是个绿色的圆,组合是新的,一眼看过去却像 `general` 换了件衣服。
|
|
2239
|
+
* 两样都新的挑不出来,退一步只要形状新,再退一步只要组合新。
|
|
2240
|
+
*/
|
|
2241
|
+
export function freshAvatar(taken: Iterable<Avatar>, random: () => number = Math.random): Avatar
|
|
2242
|
+
|
|
2243
|
+
export interface Avatar {
|
|
2244
|
+
shape: AvatarShape;
|
|
2245
|
+
color: AvatarColor;
|
|
2246
|
+
}
|
|
2247
|
+
```
|
|
2248
|
+
|
|
2249
|
+
## 字体
|
|
2250
|
+
|
|
2251
|
+
### `withHanFallback`
|
|
2252
|
+
|
|
2253
|
+
源码:`packages/client/ui/src/lib/code/font-stack.ts`
|
|
2254
|
+
|
|
2255
|
+
```tsx
|
|
2256
|
+
export function withHanFallback(stack: string): string
|
|
2257
|
+
```
|
|
2258
|
+
|
|
2259
|
+
## 图片标注
|
|
2260
|
+
|
|
2261
|
+
### `useAnnotator`
|
|
2262
|
+
|
|
2263
|
+
源码:`packages/client/ui/src/image/Annotator.tsx`
|
|
2264
|
+
|
|
2265
|
+
```tsx
|
|
2266
|
+
export function useAnnotator(src: string | RawPixels | null, options?: AnnotatorOptions): Annotator
|
|
2267
|
+
|
|
2268
|
+
export interface RawPixels {
|
|
2269
|
+
pixels: Uint8Array;
|
|
2270
|
+
width: number;
|
|
2271
|
+
height: number;
|
|
2272
|
+
}
|
|
2273
|
+
|
|
2274
|
+
export interface AnnotatorOptions {
|
|
2275
|
+
/**
|
|
2276
|
+
* 一个随图片变化的值,哪怕字节没有变。
|
|
2277
|
+
*
|
|
2278
|
+
* 截图浮层是一张长期存在的页面——显示、隐藏,而不是建了又拆——所以 `src` 是唯一能告诉它**新的**一次
|
|
2279
|
+
* 截图开始了的东西。两次截图之间屏幕没变的话,编码出来的 PNG 逐字节相同,`src` 也就相同,第一次画上
|
|
2280
|
+
* 的标记在第二次还会在。
|
|
2281
|
+
*/
|
|
2282
|
+
session?: number | string;
|
|
2283
|
+
/**
|
|
2284
|
+
* 这张图的数值属于哪个色彩空间。
|
|
2285
|
+
*
|
|
2286
|
+
* 只有截图会说这件事,因为只有它拿到的是显示器帧缓冲的原始数值——一台 Display P3 的机器上,
|
|
2287
|
+
* 屏幕上的纯红在那串数里是 234,51,35。默认按 sRGB 读,那是每一张解码出来的图片的答案(PNG
|
|
2288
|
+
* 自带 profile,浏览器已经替我们转好了),也是没有色彩管理时的安全答案。
|
|
2289
|
+
*/
|
|
2290
|
+
colorSpace?: PredefinedColorSpace;
|
|
2291
|
+
}
|
|
2292
|
+
|
|
2293
|
+
export interface Annotator {
|
|
2294
|
+
tool: Tool;
|
|
2295
|
+
setTool: (tool: Tool) => void;
|
|
2296
|
+
colour: string;
|
|
2297
|
+
setColour: (colour: string) => void;
|
|
2298
|
+
/** 文字底下垫什么;undefined 就是什么都不垫。 */
|
|
2299
|
+
backdrop: string | undefined;
|
|
2300
|
+
setBackdrop: (backdrop: string | undefined) => void;
|
|
2301
|
+
weight: number;
|
|
2302
|
+
setWeight: (weight: number) => void;
|
|
2303
|
+
undo: () => void;
|
|
2304
|
+
redo: () => void;
|
|
2305
|
+
clear: () => void;
|
|
2306
|
+
canUndo: boolean;
|
|
2307
|
+
canRedo: boolean;
|
|
2308
|
+
dirty: boolean;
|
|
2309
|
+
/** 选中的是哪个标记,或者 null。是当前状态那张清单里的下标。 */
|
|
2310
|
+
selected: number | null;
|
|
2311
|
+
setSelected: (index: number | null) => void;
|
|
2312
|
+
removeSelected: () => void;
|
|
2313
|
+
/** 把选中的标记挪这么多个屏幕点——方向键用的。 */
|
|
2314
|
+
nudge: (dx: number, dy: number) => void;
|
|
2315
|
+
/**
|
|
2316
|
+
* 标注好的图,PNG data URL,按图片自己的尺寸;源图还没解码完时是 null。
|
|
2317
|
+
*
|
|
2318
|
+
* 重新画一遍,而不是从屏幕上那块画布读回来:那块是按屏幕的分辨率画的,图片显示得比它本身大的时候,
|
|
2319
|
+
* 像素比图片本身还多。读回来的话,一张 1408px 的图交出去的是 2200px 的副本。两边用的是同一个
|
|
2320
|
+
* `paintAll`,所以出来的就是屏幕上的样子,尺寸和进来时一样。
|
|
2321
|
+
*/
|
|
2322
|
+
render: () => string | null;
|
|
2323
|
+
/**
|
|
2324
|
+
* 把正在写的那段文字提交掉,如果有的话。返回有没有。
|
|
2325
|
+
*
|
|
2326
|
+
* 任何要把这幅画变成一张图的操作都得先调它。文字在提交之前住在一个 `<textarea>` 里,提交它的是这个
|
|
2327
|
+
* 输入框失去焦点——可工具条故意不拿焦点(`onMouseDown` → `preventDefault`),因为写字时按「粗」必须
|
|
2328
|
+
* 是改这段字的大小,而不是结束它。于是光标还在文字里就按「完成」,什么也没提交:字在屏幕上,在画布
|
|
2329
|
+
* 上面的一个输入框里,而被裁出来的是画布。出来的图上没有那段字。
|
|
2330
|
+
*
|
|
2331
|
+
* `true` 表示提交了东西、画布**还没有**重画——重画是一个 effect,要等 React 把状态变化刷出去以后才
|
|
2332
|
+
* 落下。调用方等一帧再读。
|
|
2333
|
+
*/
|
|
2334
|
+
flushText: () => boolean;
|
|
2335
|
+
// 下面是画布要用的内部件,不给调用方用。
|
|
2336
|
+
/**
|
|
2337
|
+
* `flushText` 怎么够到那段文字——它住在 `AnnotateCanvas` 里,不在这里。
|
|
2338
|
+
*
|
|
2339
|
+
* 用 ref 而不是 state:画布在写字时填上它、写完清空,没有任何东西因为它而渲染得不一样。为了这一次调用
|
|
2340
|
+
* 把整套编辑状态提到这里来,要搬好几百行。
|
|
2341
|
+
*/
|
|
2342
|
+
pendingText: React.RefObject<(() => void) | null>;
|
|
2343
|
+
shapes: Shape[];
|
|
2344
|
+
canvas: React.RefObject<HTMLCanvasElement | null>;
|
|
2345
|
+
setHistory: React.Dispatch<React.SetStateAction<History>>;
|
|
2346
|
+
image: React.RefObject<Decoded | null>;
|
|
2347
|
+
/** 这个网格大小的马赛克取样用的那张平均过的图,用到时才建,建了就缓存。 */
|
|
2348
|
+
mosaicSourceFor(block: number): HTMLCanvasElement | null;
|
|
2349
|
+
ready: boolean;
|
|
2350
|
+
/**
|
|
2351
|
+
* 这个标注器先后解码过几张图——「源图换了,重画」唯一诚实的依赖。
|
|
2352
|
+
*
|
|
2353
|
+
* `ready` 说不出这件事。新图解码时它变成 false,落地后又变回 true,所以两次截图之间它的**值**两次都是
|
|
2354
|
+
* true——要是解码快到在 React 把那个 `false` 刷出去之前就完成了,两次更新落在同一轮里,这个标志根本没
|
|
2355
|
+
* 变,盯着它的 effect 再也不会跑。`image` 也说不出:它是 ref,所以压根不能当依赖。
|
|
2356
|
+
*
|
|
2357
|
+
* 截图浮层是这件事看得出代价的地方。它在一个以 `ready` 为依赖的 effect 里用这张位图画冻结的桌面,而
|
|
2358
|
+
* 第一次截图还开着时又截了一次,五毫秒就解码完了——于是浮层一边从这一次的图里裁,一边继续显示**上一次**
|
|
2359
|
+
* 的画面,也再没发出让窗口上屏的 `ready` 握手,窗口只好等它 1500ms 的兜底超时。
|
|
2360
|
+
*/
|
|
2361
|
+
revision: number;
|
|
2362
|
+
/**
|
|
2363
|
+
* 这张图和所有从它派生出来的 canvas 用的色彩空间。
|
|
2364
|
+
*
|
|
2365
|
+
* 传下去而不是各处自己猜:裁剪出来的那张、马赛克取样的那张、放大镜里的那张,都得跟主画布同一
|
|
2366
|
+
* 个空间,否则 `drawImage` 会在它们之间做转换——一次转换就是一次色偏,而且只偏彩色不偏灰。
|
|
2367
|
+
*/
|
|
2368
|
+
colorSpace: PredefinedColorSpace;
|
|
2369
|
+
/**
|
|
2370
|
+
* 源图的原始宽度,放在 state 里而不是从 ref 上读。
|
|
2371
|
+
*
|
|
2372
|
+
* 这里的每个尺寸——线宽、字号、马赛克格子——都由它推出来,从 `image.current` 推的话,推导就没有诚实的
|
|
2373
|
+
* 依赖:ref 当不了依赖,拿 `ready` 标志顶替它是一句谎话,lint 拒绝它是对的。
|
|
2374
|
+
*/
|
|
2375
|
+
width: number;
|
|
2376
|
+
/** 现在画一笔马赛克会用的网格。已经画上的标记各自带着自己的。 */
|
|
2377
|
+
block: number;
|
|
2378
|
+
/**
|
|
2379
|
+
* 在图片适配后的尺寸下,一个屏幕点盖住多少个图片像素:工具条给出的尺寸是屏幕尺寸,标记存的是图片像素,
|
|
2380
|
+
* 靠它换算。由画布报上来——只有画布知道图片在屏幕上有多大。
|
|
2381
|
+
*/
|
|
2382
|
+
unit: number;
|
|
2383
|
+
setUnit: (unit: number) => void;
|
|
2384
|
+
}
|
|
2385
|
+
```
|
|
2386
|
+
|
|
2387
|
+
### `AnnotateCanvas`
|
|
2388
|
+
|
|
2389
|
+
源码:`packages/client/ui/src/image/AnnotateCanvas.tsx`
|
|
2390
|
+
|
|
2391
|
+
```tsx
|
|
2392
|
+
/**
|
|
2393
|
+
* @param className 替换画布自己的尺寸和外框,给需要把它摆得分毫不差的调用方。看图器要 `STAGE_FIT`
|
|
2394
|
+
* ——按图片自己的像素定大小、以窗口为上限;截图浮层要的正相反,一块钉在它已经选好的矩形上的画布。
|
|
2395
|
+
* 不传的话画布按位图尺寸以 CSS 像素排版,在 Retina 屏上是它该盖住的区域的两倍。
|
|
2396
|
+
*/
|
|
2397
|
+
export function AnnotateCanvas({ annotator, zoom, className, style }: { annotator: Annotator; zoom: number; className?: string; style?: React.CSSProperties })
|
|
2398
|
+
```
|
|
2399
|
+
|
|
2400
|
+
### `AnnotateToolbar`
|
|
2401
|
+
|
|
2402
|
+
源码:`packages/client/ui/src/image/AnnotateToolbar.tsx`
|
|
2403
|
+
|
|
2404
|
+
```tsx
|
|
2405
|
+
export function AnnotateToolbar({
|
|
2406
|
+
annotator,
|
|
2407
|
+
onCancel,
|
|
2408
|
+
onSave,
|
|
2409
|
+
onPin,
|
|
2410
|
+
onDownload,
|
|
2411
|
+
onGrab,
|
|
2412
|
+
grabbing = false,
|
|
2413
|
+
canReplace,
|
|
2414
|
+
delivers = false,
|
|
2415
|
+
saveLabel,
|
|
2416
|
+
cancelLabel,
|
|
2417
|
+
requireDirty = true,
|
|
2418
|
+
style,
|
|
2419
|
+
propertiesSide = "above",
|
|
2420
|
+
}: {
|
|
2421
|
+
annotator: Annotator;
|
|
2422
|
+
onCancel: () => void;
|
|
2423
|
+
onSave: () => void;
|
|
2424
|
+
/**
|
|
2425
|
+
* 把这张图留在桌面上、浮在一切之上,而不是交出去。
|
|
2426
|
+
*
|
|
2427
|
+
* 只在有意义的地方给——截图浮层——因为把一张已经开在窗口里的图的一块置顶,是给正在看的东西复制一份。
|
|
2428
|
+
*/
|
|
2429
|
+
onPin?: () => void;
|
|
2430
|
+
/** 把这张图写进下载目录。理由同 `onPin`。 */
|
|
2431
|
+
onDownload?: () => void;
|
|
2432
|
+
/**
|
|
2433
|
+
* 这一条要挪窝了:从哪儿按下的,按下时它在哪儿。
|
|
2434
|
+
*
|
|
2435
|
+
* 挪到哪儿是调用方的事——它飘在一张截好的屏幕上,得留在屏幕里——所以这里只说「开始了」,外加
|
|
2436
|
+
* 两个位置。带上 `origin` 而不是让调用方自己去 DOM 里量:这一条的位置只有它自己最清楚,而且
|
|
2437
|
+
* 按住按钮触发的那一次是从定时器里发出的,那会儿 React 的合成事件早就没有 `currentTarget` 了。
|
|
2438
|
+
*
|
|
2439
|
+
* 不传就是不能挪。
|
|
2440
|
+
*/
|
|
2441
|
+
onGrab?: (grab: { from: { x: number; y: number }; origin: { x: number; y: number } }) => void;
|
|
2442
|
+
/** 正在挪,好让整条和它上面每个控件都显示同一个光标。 */
|
|
2443
|
+
grabbing?: boolean;
|
|
2444
|
+
/**
|
|
2445
|
+
* 当前工具的属性气泡往哪边开,以这条工具条为准。
|
|
2446
|
+
*
|
|
2447
|
+
* `above` 开在工具条上面,`below` 开在下面。由调用方决定,因为只有调用方知道工具条摆在被标注的东西的
|
|
2448
|
+
* 哪一边:气泡得往远离它的方向开,否则就把它盖住了。默认 `above`,看图器就是这样——工具条一开始贴着
|
|
2449
|
+
* 舞台底边。
|
|
2450
|
+
*/
|
|
2451
|
+
propertiesSide?: "above" | "below";
|
|
2452
|
+
/** 保存能不能替换原图,还是只能另出一份副本。 */
|
|
2453
|
+
canReplace: boolean;
|
|
2454
|
+
/**
|
|
2455
|
+
* 副本是交出去接着用,而不是导出。
|
|
2456
|
+
*
|
|
2457
|
+
* 对话里的一张图被标注,是为了让它照着重画,所以副本进那个对话的输入框:按钮是带勾的「完成」,而不是
|
|
2458
|
+
* 一个下载箭头,许诺一个谁也没要的文件。
|
|
2459
|
+
*/
|
|
2460
|
+
delivers?: boolean;
|
|
2461
|
+
/** 覆盖保存按钮上的字。不传就按 `canReplace` 的意思说。 */
|
|
2462
|
+
saveLabel?: string;
|
|
2463
|
+
cancelLabel?: string;
|
|
2464
|
+
/**
|
|
2465
|
+
* 保存是不是要求先画过东西。
|
|
2466
|
+
*
|
|
2467
|
+
* 对一张已经存在的图是 true——保存一份没动过的副本只会多出一个一模一样的文件,所以在有东西可存之前
|
|
2468
|
+
* 按钮是死的。对截图浮层是 false:那个按钮就是确认这次截图的方式,而一块没有标注的区域是最常见的需求。
|
|
2469
|
+
*/
|
|
2470
|
+
requireDirty?: boolean;
|
|
2471
|
+
style?: React.CSSProperties;
|
|
2472
|
+
})
|
|
2473
|
+
```
|
|
2474
|
+
|
|
2475
|
+
### `useToolbarPlacement`
|
|
2476
|
+
|
|
2477
|
+
源码:`packages/client/ui/src/image/useToolbarPlacement.ts`
|
|
2478
|
+
|
|
2479
|
+
```tsx
|
|
2480
|
+
/**
|
|
2481
|
+
* `bounds` 可以是 null。
|
|
2482
|
+
*
|
|
2483
|
+
* 组件在拿到第一帧数据之前就早返回了,而 hook 必须在那之前调用——所以这里收一个可能还不存在的
|
|
2484
|
+
* 屏幕尺寸。它为 null 时 `selection` 必然也是 null(没有截图就没有选区),下面那两处算位置的
|
|
2485
|
+
* 地方都走不到。
|
|
2486
|
+
*/
|
|
2487
|
+
export function useToolbarPlacement(selection: Rect | null, bounds: { width: number; height: number } | null): ToolbarPlacement
|
|
2488
|
+
|
|
2489
|
+
export interface ToolbarPlacement {
|
|
2490
|
+
/** 画在哪。`null` 表示还没有选区,也就没有工具条。 */
|
|
2491
|
+
at: ToolbarAt;
|
|
2492
|
+
/** 正在被拖动——调用方拿它改光标。 */
|
|
2493
|
+
dragging: boolean;
|
|
2494
|
+
/** 挂在工具条那个元素上的 ref 回调。 */
|
|
2495
|
+
measure: (el: HTMLDivElement | null) => void;
|
|
2496
|
+
/** 按住把手时调用,带上按下的点和当时工具条的位置。 */
|
|
2497
|
+
startDrag: (grab: { from: Point; origin: Point }) => void;
|
|
2498
|
+
/** 新一轮截图开始时调用:忘掉用户上一轮把它拖到了哪。 */
|
|
2499
|
+
reset: () => void;
|
|
2500
|
+
}
|
|
2501
|
+
```
|
|
2502
|
+
|
|
2503
|
+
## 选区几何
|
|
2504
|
+
|
|
2505
|
+
### `HANDLES`
|
|
2506
|
+
|
|
2507
|
+
源码:`packages/client/ui/src/image/screenshot-geometry.ts`
|
|
2508
|
+
|
|
2509
|
+
```tsx
|
|
2510
|
+
/**
|
|
2511
|
+
* 八个方向,按 CSS 光标的叫法命名。
|
|
2512
|
+
*
|
|
2513
|
+
* 角在前,这样顺着这张表做的命中测试在角和边重叠的地方优先认角——它们总是重叠,而指针瞄的就是角。
|
|
2514
|
+
*/
|
|
2515
|
+
export const HANDLES = ["nw", "ne", "se", "sw", "n", "e", "s", "w"] as const;
|
|
2516
|
+
```
|
|
2517
|
+
|
|
2518
|
+
### `HANDLE_CURSOR`
|
|
2519
|
+
|
|
2520
|
+
源码:`packages/client/ui/src/image/screenshot-geometry.ts`
|
|
2521
|
+
|
|
2522
|
+
```tsx
|
|
2523
|
+
/** 每个手柄该显示的光标:它的名字加上 CSS 要的后缀。 */
|
|
2524
|
+
export const HANDLE_CURSOR: Record<Handle, string> = {
|
|
2525
|
+
nw: "nwse-resize",
|
|
2526
|
+
se: "nwse-resize",
|
|
2527
|
+
ne: "nesw-resize",
|
|
2528
|
+
sw: "nesw-resize",
|
|
2529
|
+
n: "ns-resize",
|
|
2530
|
+
s: "ns-resize",
|
|
2531
|
+
e: "ew-resize",
|
|
2532
|
+
w: "ew-resize",
|
|
2533
|
+
};
|
|
2534
|
+
|
|
2535
|
+
export type Handle = (typeof HANDLES)[number];
|
|
2536
|
+
```
|
|
2537
|
+
|
|
2538
|
+
### `handlePoint`
|
|
2539
|
+
|
|
2540
|
+
源码:`packages/client/ui/src/image/screenshot-geometry.ts`
|
|
2541
|
+
|
|
2542
|
+
```tsx
|
|
2543
|
+
/** 手柄在哪,表示为选区边上的一个点。 */
|
|
2544
|
+
export function handlePoint(rect: Rect, handle: Handle): Point
|
|
2545
|
+
|
|
2546
|
+
export interface Rect {
|
|
2547
|
+
x: number;
|
|
2548
|
+
y: number;
|
|
2549
|
+
width: number;
|
|
2550
|
+
height: number;
|
|
2551
|
+
}
|
|
2552
|
+
|
|
2553
|
+
export type Handle = (typeof HANDLES)[number];
|
|
2554
|
+
|
|
2555
|
+
export interface Point {
|
|
2556
|
+
x: number;
|
|
2557
|
+
y: number;
|
|
2558
|
+
}
|
|
2559
|
+
```
|
|
2560
|
+
|
|
2561
|
+
### `hitHandle`
|
|
2562
|
+
|
|
2563
|
+
源码:`packages/client/ui/src/image/screenshot-geometry.ts`
|
|
2564
|
+
|
|
2565
|
+
```tsx
|
|
2566
|
+
/**
|
|
2567
|
+
* 指针在哪个手柄上,没有就是 null。
|
|
2568
|
+
*
|
|
2569
|
+
* `tolerance` 是屏幕像素的半径,而不是手柄画出来的大小:目标得比记号大,理由和滚动条一样——八个像素见方
|
|
2570
|
+
* 的一块,手落不准。
|
|
2571
|
+
*/
|
|
2572
|
+
export function hitHandle(rect: Rect, at: Point, tolerance: number): Handle | null
|
|
2573
|
+
|
|
2574
|
+
export interface Rect {
|
|
2575
|
+
x: number;
|
|
2576
|
+
y: number;
|
|
2577
|
+
width: number;
|
|
2578
|
+
height: number;
|
|
2579
|
+
}
|
|
2580
|
+
|
|
2581
|
+
export interface Point {
|
|
2582
|
+
x: number;
|
|
2583
|
+
y: number;
|
|
2584
|
+
}
|
|
2585
|
+
|
|
2586
|
+
export type Handle = (typeof HANDLES)[number];
|
|
2587
|
+
```
|
|
2588
|
+
|
|
2589
|
+
### `insideRect`
|
|
2590
|
+
|
|
2591
|
+
源码:`packages/client/ui/src/image/screenshot-geometry.ts`
|
|
2592
|
+
|
|
2593
|
+
```tsx
|
|
2594
|
+
/** 点是否在选区里——在里面按下是移动,而不是重新拖一个。 */
|
|
2595
|
+
export function insideRect(rect: Rect, at: Point): boolean
|
|
2596
|
+
|
|
2597
|
+
export interface Rect {
|
|
2598
|
+
x: number;
|
|
2599
|
+
y: number;
|
|
2600
|
+
width: number;
|
|
2601
|
+
height: number;
|
|
2602
|
+
}
|
|
2603
|
+
|
|
2604
|
+
export interface Point {
|
|
2605
|
+
x: number;
|
|
2606
|
+
y: number;
|
|
2607
|
+
}
|
|
2608
|
+
```
|
|
2609
|
+
|
|
2610
|
+
### `rectFromPoints`
|
|
2611
|
+
|
|
2612
|
+
源码:`packages/client/ui/src/image/screenshot-geometry.ts`
|
|
2613
|
+
|
|
2614
|
+
```tsx
|
|
2615
|
+
/** 由两个角得到矩形,两个角哪个在前都行。 */
|
|
2616
|
+
export function rectFromPoints(a: Point, b: Point): Rect
|
|
2617
|
+
|
|
2618
|
+
export interface Point {
|
|
2619
|
+
x: number;
|
|
2620
|
+
y: number;
|
|
2621
|
+
}
|
|
2622
|
+
|
|
2623
|
+
export interface Rect {
|
|
2624
|
+
x: number;
|
|
2625
|
+
y: number;
|
|
2626
|
+
width: number;
|
|
2627
|
+
height: number;
|
|
2628
|
+
}
|
|
2629
|
+
```
|
|
2630
|
+
|
|
2631
|
+
### `resizeRect`
|
|
2632
|
+
|
|
2633
|
+
源码:`packages/client/ui/src/image/screenshot-geometry.ts`
|
|
2634
|
+
|
|
2635
|
+
```tsx
|
|
2636
|
+
/**
|
|
2637
|
+
* 把一个手柄拖到某点之后的选区。
|
|
2638
|
+
*
|
|
2639
|
+
* 写成「挪动这个手柄管的那几条边,再规范化」,所以拖过对边时矩形会翻过来,而不是得到负的宽度——这是人在
|
|
2640
|
+
* 其他任何地方改大小时预期的样子,`rectFromPoints` 本来就是这么写的。
|
|
2641
|
+
*/
|
|
2642
|
+
export function resizeRect(rect: Rect, handle: Handle, to: Point): Rect
|
|
2643
|
+
|
|
2644
|
+
export interface Rect {
|
|
2645
|
+
x: number;
|
|
2646
|
+
y: number;
|
|
2647
|
+
width: number;
|
|
2648
|
+
height: number;
|
|
2649
|
+
}
|
|
2650
|
+
|
|
2651
|
+
export type Handle = (typeof HANDLES)[number];
|
|
2652
|
+
|
|
2653
|
+
export interface Point {
|
|
2654
|
+
x: number;
|
|
2655
|
+
y: number;
|
|
2656
|
+
}
|
|
2657
|
+
```
|
|
2658
|
+
|
|
2659
|
+
### `moveRect`
|
|
2660
|
+
|
|
2661
|
+
源码:`packages/client/ui/src/image/screenshot-geometry.ts`
|
|
2662
|
+
|
|
2663
|
+
```tsx
|
|
2664
|
+
/** 选区平移一段,保持完整、保持在屏幕里。 */
|
|
2665
|
+
export function moveRect(rect: Rect, dx: number, dy: number, within: { width: number; height: number }): Rect
|
|
2666
|
+
|
|
2667
|
+
export interface Rect {
|
|
2668
|
+
x: number;
|
|
2669
|
+
y: number;
|
|
2670
|
+
width: number;
|
|
2671
|
+
height: number;
|
|
2672
|
+
}
|
|
2673
|
+
```
|
|
2674
|
+
|
|
2675
|
+
### `clampRect`
|
|
2676
|
+
|
|
2677
|
+
源码:`packages/client/ui/src/image/screenshot-geometry.ts`
|
|
2678
|
+
|
|
2679
|
+
```tsx
|
|
2680
|
+
/** 选区裁到屏幕以内,给拉过了边的改大小用。 */
|
|
2681
|
+
export function clampRect(rect: Rect, within: { width: number; height: number }): Rect
|
|
2682
|
+
|
|
2683
|
+
export interface Rect {
|
|
2684
|
+
x: number;
|
|
2685
|
+
y: number;
|
|
2686
|
+
width: number;
|
|
2687
|
+
height: number;
|
|
2688
|
+
}
|
|
2689
|
+
```
|
|
2690
|
+
|
|
2691
|
+
## `@plume/styles` 的类名组合
|
|
2692
|
+
|
|
2693
|
+
`import { text, card } from "@plume/styles"`,拼进 `className`。产品的 Tailwind 扫描这个包,这些类一定会生成。
|
|
2694
|
+
|
|
2695
|
+
### `text`
|
|
2696
|
+
|
|
2697
|
+
```ts
|
|
2698
|
+
export const text = {
|
|
2699
|
+
/** 次要说明:路径、状态、空状态。 */
|
|
2700
|
+
faint: "text-caption text-ink-faint",
|
|
2701
|
+
error: "text-detail text-danger",
|
|
2702
|
+
link: "text-accent hover:underline",
|
|
2703
|
+
};
|
|
2704
|
+
```
|
|
2705
|
+
|
|
2706
|
+
### `field`
|
|
2707
|
+
|
|
2708
|
+
```ts
|
|
2709
|
+
/** 单行输入框和下拉框:无描边的一块 card 底,悬停、聚焦时深一档。高度只有 control 这一档。 */
|
|
2710
|
+
export const field =
|
|
2711
|
+
"h-control min-w-0 rounded-field bg-card px-4 text-label text-ink outline-none transition-colors placeholder:text-ink-faint hover:bg-card-hover focus:bg-card-hover";
|
|
2712
|
+
```
|
|
2713
|
+
|
|
2714
|
+
### `fieldLabel`
|
|
2715
|
+
|
|
2716
|
+
```ts
|
|
2717
|
+
/** 包住输入框的 label:上面一行小字,下面是控件。 */
|
|
2718
|
+
export const fieldLabel = "flex flex-col gap-1.5 text-caption font-medium text-ink-muted";
|
|
2719
|
+
```
|
|
2720
|
+
|
|
2721
|
+
### `textarea`
|
|
2722
|
+
|
|
2723
|
+
```ts
|
|
2724
|
+
/** 多行输入框:没有框,放进自己画框的容器里(主输入框那种),外观由容器出。 */
|
|
2725
|
+
export const textarea = "min-w-0 resize-none bg-transparent text-body text-ink outline-none placeholder:text-ink-faint";
|
|
2726
|
+
```
|
|
2727
|
+
|
|
2728
|
+
### `button`
|
|
2729
|
+
|
|
2730
|
+
```ts
|
|
2731
|
+
/**
|
|
2732
|
+
* 按钮:五种外观、三档高度。焦点环是全局的(见渲染进程的 styles.css);按下不缩放,底色深一档。
|
|
2733
|
+
* 组件见 @plume/ui 的 Button,一般不直接拼这些。
|
|
2734
|
+
*/
|
|
2735
|
+
export const button = {
|
|
2736
|
+
/** 禁用时不关掉指针事件:悬停提示要能解释它为什么不能按。 */
|
|
2737
|
+
base: "flex shrink-0 cursor-pointer items-center whitespace-nowrap transition-[background-color,border-color,opacity]",
|
|
2738
|
+
/** md 32 是默认,对话框和表单底部的按钮;sm 26 给面板里的行;xs 20 给跟在一行小字后面的动作,圆角随之降到 6px。 */
|
|
2739
|
+
size: {
|
|
2740
|
+
md: "h-8 rounded-lg text-label",
|
|
2741
|
+
sm: "h-6.5 rounded-lg text-label",
|
|
2742
|
+
xs: "h-5 rounded-md text-caption",
|
|
2743
|
+
},
|
|
2744
|
+
/** 只有图标时是正方形,图标居中;有字时字从左边起。 */
|
|
2745
|
+
square: { md: "w-8 justify-center", sm: "w-6.5 justify-center", xs: "w-5 justify-center" },
|
|
2746
|
+
pad: { md: "gap-1.5 px-3", sm: "gap-1.5 px-3", xs: "gap-1 px-1.5" },
|
|
2747
|
+
variant: {
|
|
2748
|
+
/** 一处只有一个,要按的那个:前景色压在底色上,不引入新的颜色也读得出是实心的。 */
|
|
2749
|
+
primary: "bg-ink text-shell font-medium hover:opacity-90 disabled:opacity-40",
|
|
2750
|
+
/** 描边,平常的按钮,放在 primary 旁边。 */
|
|
2751
|
+
ghost: "border border-line text-ink hover:border-ink-faint hover:bg-card-hover active:bg-elevated disabled:opacity-45",
|
|
2752
|
+
/** 没有描边,一排动作不想在面板上画出格子时用。 */
|
|
2753
|
+
subtle: "text-ink-muted hover:bg-card-hover hover:text-ink active:bg-elevated disabled:opacity-45",
|
|
2754
|
+
/** 有底没描边:放在 primary 旁边、指针没过来之前就要读得出是按钮的一排选择。 */
|
|
2755
|
+
soft: "bg-card text-ink hover:bg-card-hover active:bg-elevated disabled:opacity-45",
|
|
2756
|
+
/** 破坏性操作。只在悬停时上色:一排红按钮没人会读。 */
|
|
2757
|
+
danger: "border border-line text-danger hover:border-danger/50 hover:bg-danger/10 active:bg-danger/15 disabled:opacity-45",
|
|
2758
|
+
} satisfies Record<ButtonVariant, string>,
|
|
2759
|
+
/** 一组选择里选中的那个:悬停态一直保持。只有 ghost 和 subtle 有。 */
|
|
2760
|
+
pressed: {
|
|
2761
|
+
ghost: "border border-ink-faint bg-card-hover text-ink disabled:opacity-45",
|
|
2762
|
+
subtle: "bg-card-hover text-ink disabled:opacity-45",
|
|
2763
|
+
} as Partial<Record<ButtonVariant, string>>,
|
|
2764
|
+
/** 忙碌:和 disabled 分开,读起来是“在做”,不是“不能按”。宽度不变。 */
|
|
2765
|
+
loading: "opacity-60",
|
|
2766
|
+
};
|
|
2767
|
+
```
|
|
2768
|
+
|
|
2769
|
+
### `iconButton`
|
|
2770
|
+
|
|
2771
|
+
```ts
|
|
2772
|
+
/** 图标按钮:平时是淡的字色,悬停才有底。按下底色深一档,不缩放。组件见 @plume/ui 的 IconButton。 */
|
|
2773
|
+
export const iconButton = {
|
|
2774
|
+
/** 禁用时不关掉指针事件:`explainDisabled` 要靠悬停提示说明为什么不能按。 */
|
|
2775
|
+
base: "relative flex shrink-0 items-center justify-center transition-[color,background-color,opacity] disabled:opacity-40",
|
|
2776
|
+
/**
|
|
2777
|
+
* xs 20 是标题行和一行小字的高度,圆角随之降到 6px。composer 跟着主输入框工具栏的控件边长走,
|
|
2778
|
+
* 那个尺寸变了,这一排仍然是一行。
|
|
2779
|
+
*/
|
|
2780
|
+
size: { xs: "size-5 rounded-md", sm: "size-5.5 rounded-lg", md: "size-6.5 rounded-lg", composer: "plume-composer-control plume-composer-icon rounded-lg" },
|
|
2781
|
+
tone: {
|
|
2782
|
+
default: "text-ink-faint hover:bg-card-hover hover:text-ink active:bg-elevated",
|
|
2783
|
+
danger: "text-ink-faint hover:bg-danger/10 hover:text-danger active:bg-danger/15",
|
|
2784
|
+
},
|
|
2785
|
+
/** 开着的开关,比如“区分大小写”。 */
|
|
2786
|
+
active: "bg-card-hover text-ink active:bg-elevated",
|
|
2787
|
+
/** 一排里现在值得按的那个:满色的字,不是实心按钮。 */
|
|
2788
|
+
emphasis: "text-ink hover:bg-card-hover active:bg-elevated",
|
|
2789
|
+
};
|
|
2790
|
+
```
|
|
2791
|
+
|
|
2792
|
+
### `card`
|
|
2793
|
+
|
|
2794
|
+
```ts
|
|
2795
|
+
export const card = {
|
|
2796
|
+
frame: "overflow-hidden rounded-lg border bg-card text-detail",
|
|
2797
|
+
head: "flex min-w-0 items-center gap-2 px-3.5 py-2",
|
|
2798
|
+
title: "shrink-0 font-medium",
|
|
2799
|
+
subtitle: "min-w-0 flex-1 truncate font-mono text-caption text-ink-muted",
|
|
2800
|
+
status: "ml-auto shrink-0 text-caption text-ink-faint",
|
|
2801
|
+
body: "border-t border-line-soft px-3.5 py-2.5",
|
|
2802
|
+
/** 按语气换边框和标题行的底色。 */
|
|
2803
|
+
tone: {
|
|
2804
|
+
default: { frame: "border-line", head: "" },
|
|
2805
|
+
error: { frame: "border-danger/40", head: "bg-danger/10" },
|
|
2806
|
+
warning: { frame: "border-warning/40", head: "bg-warning/10" },
|
|
2807
|
+
},
|
|
2808
|
+
};
|
|
2809
|
+
```
|
|
2810
|
+
|
|
2811
|
+
### `actions`
|
|
2812
|
+
|
|
2813
|
+
```ts
|
|
2814
|
+
/** 卡片底部一行:左边是说明,右边是按钮。 */
|
|
2815
|
+
export const actions = {
|
|
2816
|
+
row: "mt-2.5 flex items-center gap-2",
|
|
2817
|
+
note: "flex-1 text-caption text-ink-muted",
|
|
2818
|
+
};
|
|
2819
|
+
```
|
|
2820
|
+
|
|
2821
|
+
### `code`
|
|
2822
|
+
|
|
2823
|
+
```ts
|
|
2824
|
+
export const code = {
|
|
2825
|
+
block: `${PRE} px-2.5 py-2 break-words whitespace-pre-wrap`,
|
|
2826
|
+
diff: `${PRE} py-1.5`,
|
|
2827
|
+
diffLine: "px-2.5 break-words whitespace-pre-wrap",
|
|
2828
|
+
diffKind: { same: "", add: "bg-diff-add", remove: "bg-diff-remove" },
|
|
2829
|
+
};
|
|
2830
|
+
```
|