Skip to content

Commit 6280911

Browse files
committed
refactor(components)!: Code 由 withDecorate 取代 withCopyButton
withDecorate 可以自定义装饰器,withCopyButton 成为一种装饰器的实现。 BREAKING CHANGE 删除了不再需要的 withCopyButton
1 parent e691547 commit 6280911

11 files changed

Lines changed: 295 additions & 44 deletions

File tree

apps/docs/src/components/base.tsx

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,3 +138,62 @@ export function arraySelector<T extends string | number>(
138138

139139
return [elem, signal[0], signal[1]];
140140
}
141+
142+
/**
143+
* 可多选的下拉框
144+
*/
145+
export function arrayMultipleSelector<T extends string | number>(
146+
label: string,
147+
array: ReadonlyArray<T> | ReadonlyMap<T, string>,
148+
preset?: Array<T>,
149+
appendUndefined = false,
150+
): [Component, Accessor<Array<T> | undefined>, Setter<Array<T> | undefined>] {
151+
const signal = createSignal<Array<T> | undefined>(preset);
152+
153+
let options: Array<Choice.Option<T>>;
154+
155+
if (Array.isArray(array)) {
156+
options = array.map(item => {
157+
return {
158+
type: 'item',
159+
value: item,
160+
label: item,
161+
};
162+
});
163+
} else {
164+
const m = array as ReadonlyMap<T, string>;
165+
options = Array.from(m.entries()).map(([key, val]) => {
166+
return {
167+
type: 'item',
168+
value: key,
169+
label: val,
170+
};
171+
});
172+
}
173+
174+
if (appendUndefined) {
175+
options.push({
176+
type: 'item',
177+
value: undefined,
178+
label: 'undefined',
179+
});
180+
}
181+
182+
const name = createUniqueId(); // 保证一组 radio 一个独立的名称
183+
184+
const elem = () => {
185+
const l = useLocale();
186+
return (
187+
<Choice.Root
188+
closable
189+
multiple
190+
layout="horizontal"
191+
placeholder={l.t(label)}
192+
accessor={Form.fieldAccessor(name, signal)}
193+
options={options}
194+
/>
195+
);
196+
};
197+
198+
return [elem, signal[0], signal[1]];
199+
}

apps/docs/src/components/demo/code/api.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@
88
types:
99
- Code.RootProps
1010
- Code.RootRef
11+
1112
- Code.Highlighter
1213
- Code.buildHighlighter
1314
- Code.highlight
15+
16+
- Code.decorates
17+
- Code.registerDecorate
18+
- Code.withDecorate
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
// SPDX-FileCopyrightText: 2025-2026 caixw
2+
//
3+
// SPDX-License-Identifier: MIT
4+
5+
import { Code, type MountProps } from '@cmfx/components';
6+
import type { JSX } from 'solid-js';
7+
import { Portal } from 'solid-js/web';
8+
9+
import { arrayMultipleSelector, boolSelector, paletteSelector } from '@docs/components/base';
10+
11+
export default function (props: MountProps): JSX.Element {
12+
const [Palette, palette] = paletteSelector();
13+
const [Editable, editable] = boolSelector('_d.demo.editable');
14+
const [Decorate, decorate] = arrayMultipleSelector('装饰器', Code.decorates());
15+
16+
return (
17+
<div>
18+
<Portal mount={props.mount}>
19+
<Palette />
20+
<Editable />
21+
<Decorate />
22+
</Portal>
23+
<Code.Root editable={editable()} ln={0} wrap palette={palette()} class="h-50" lang="css" decorates={decorate()}>
24+
{`/*
25+
* SPDX-FileCopyrightText: 2025 caixw
26+
*
27+
* SPDX-License-Identifier: MIT
28+
*/
29+
30+
@reference '../style.css';
31+
32+
@layer components {
33+
.code {
34+
@apply font-mono w-full h-full overflow-auto rounded-lg relative;
35+
@apply border border-palette-bg-low;
36+
37+
.action {
38+
@apply flex justify-end absolute top-0 right-0;
39+
}
40+
}
41+
}
42+
`}
43+
</Code.Root>
44+
</div>
45+
);
46+
}

apps/docs/src/components/demo/code/doc.zh-Hans.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,7 @@
1010
#### oninput
1111
@```demo-oninput```@
1212

13+
#### decorate
14+
@```demo-decorate```@
15+
1316
@```api```@

apps/docs/src/components/demo/code/index.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import IconCode from '~icons/mingcute/code-fill';
77
import type { Info } from '@docs/components/base';
88
import { default as Basic } from './basic';
99
import { default as basic } from './basic.tsx?raw';
10+
import { default as Decorate } from './decorate';
11+
import { default as decorate } from './decorate.tsx?raw';
1012
import { default as Oninput } from './oninput';
1113
import { default as oninput } from './oninput.tsx?raw';
1214
import { default as Scrollable } from './scrollable';
@@ -24,6 +26,7 @@ export default function (): Info {
2426
{ component: Basic, source: basic, id: 'basic' },
2527
{ component: Scrollable, source: scrollable, id: 'scrollable' },
2628
{ component: Oninput, source: oninput, id: 'oninput' },
29+
{ component: Decorate, source: decorate, id: 'decorate' },
2730
],
2831
};
2932
}

apps/docs/src/docs/docs.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -224,7 +224,7 @@ function Doc(props: DocProps): JSX.Element {
224224
let page: Page.RootRef;
225225

226226
onMount(() => {
227-
Code.withCopyButton(page.root());
227+
Code.withDecorate(page.root());
228228
});
229229

230230
const components = createMemo(() => {
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
// SPDX-FileCopyrightText: 2026 caixw
2+
//
3+
// SPDX-License-Identifier: MIT
4+
5+
import { getOwner, type JSX, runWithOwner } from 'solid-js';
6+
import { render } from 'solid-js/web';
7+
8+
import { Button } from '@components/button';
9+
import { ClipboardAPI } from '@components/clipboard';
10+
import styles from './style.module.css';
11+
12+
/**
13+
* 装饰器的类型
14+
*
15+
* @param pre - 代码高亮的对象,装饰器最终会被放在此元素上;
16+
* @remarks
17+
* 这是对代码高亮对象 pre 的各种修改,比如添加按钮,或是改变颜色等。
18+
*/
19+
export type Decorate = (pre: HTMLPreElement) => JSX.Element;
20+
21+
/**
22+
* 获取的有已经注册的装饰器
23+
*
24+
* @remarks
25+
* 默认情况下,支持以下装饰器:
26+
* - toolbar 在顶部显示一个工具栏;
27+
* - copyButton 在右上角显示一个复制按钮;
28+
* - border 为代码组件显示边框;
29+
*/
30+
const decorates = new Map<string, Decorate>();
31+
32+
/**
33+
* 注册装饰器
34+
* @param name - 装饰器的名称;
35+
* @param decorate - 装饰器的实例;
36+
*/
37+
export function registerDecorate(name: string, decorate: Decorate): void {
38+
if (decorates.has(name)) {
39+
throw new Error(`${name} 已经存在`);
40+
}
41+
42+
decorates.set(name, decorate);
43+
}
44+
45+
/**
46+
* 获取装饰器的名称列表
47+
*/
48+
export function getDecorates(): Array<string> {
49+
return Array.from(decorates.keys());
50+
}
51+
52+
/**
53+
* 为 elem 及其子元素中的所有 shiki 代码块添加指定的组件
54+
*
55+
* @remarks
56+
* 装饰器根据 {@link highlight} 的 decorate 参数而定。
57+
*/
58+
export function withDecorate(elem: HTMLElement): void {
59+
// 当前元素匹配
60+
if (elem.matches('[data-code]')) {
61+
mount(elem as HTMLPreElement);
62+
return;
63+
}
64+
65+
const elems = elem.querySelectorAll('[data-code]');
66+
for (const elem of elems) {
67+
mount(elem as HTMLPreElement);
68+
}
69+
}
70+
71+
/**
72+
* 根据 pre 上的 data-decorate 属性挂载相应的装饰器
73+
*/
74+
function mount(pre: HTMLPreElement) {
75+
const names = pre.dataset.decorate;
76+
if (!names) {
77+
return;
78+
}
79+
80+
const owner = getOwner();
81+
82+
for (const name of names.split(',')) {
83+
const d = decorates.get(name);
84+
if (d) {
85+
runWithOwner(owner, () => render(() => d(pre), pre));
86+
}
87+
}
88+
}
89+
90+
/************************* 注册装饰器 **********************************/
91+
92+
registerDecorate('copy-button', pre => {
93+
let clipboardRef: ClipboardAPI.RootRef;
94+
95+
return (
96+
<Button.Root class={styles.copy} square kind="flat" onclick={() => clipboardRef.writeText(pre.dataset.code ?? '')}>
97+
<ClipboardAPI.Root ref={el => (clipboardRef = el)} />
98+
</Button.Root>
99+
);
100+
});
101+
102+
registerDecorate('toolbar', pre => {
103+
let clipboardRef: ClipboardAPI.RootRef;
104+
105+
// 不需要考虑组件卸载之后如何改回原始的情况。
106+
// 因为生成的代码就已经固定了 decorate 属性,不会动态切换。
107+
pre.style.flexDirection = 'column-reverse';
108+
109+
return (
110+
<header class={styles.toolbar}>
111+
<span>{pre.dataset.lang}</span>
112+
<div class={styles.actions}>
113+
<Button.Root
114+
class={styles.btn}
115+
square
116+
kind="flat"
117+
onclick={() => clipboardRef.writeText(pre.dataset.code ?? '')}
118+
>
119+
<ClipboardAPI.Root ref={el => (clipboardRef = el)} />
120+
</Button.Root>
121+
</div>
122+
</header>
123+
);
124+
});
125+
126+
registerDecorate('border', pre => {
127+
pre.style.border = '1px solid var(--palette-border)';
128+
return '';
129+
});

packages/components/src/code/mod.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
//
33
// SPDX-License-Identifier: MIT
44

5+
export type { Decorate } from './decorate';
6+
export { getDecorates as decorates, registerDecorate, withDecorate } from './decorate';
57
export type { Props as RootProps, Ref as RootRef } from './root';
68
export { Root } from './root';
7-
export { buildHighlighter, type Highlighter, highlight, withCopyButton } from './shiki';
9+
export { buildHighlighter, type Highlighter, highlight } from './shiki';

packages/components/src/code/root.tsx

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ import { createEffect, createSignal, type JSX } from 'solid-js';
77
import { template } from 'solid-js/web';
88

99
import { type BaseProps, type BaseRef, joinClass, type RefProps } from '@components/base';
10-
import { highlight, withCopyButton } from './shiki';
10+
import { withDecorate } from './decorate';
11+
import { highlight } from './shiki';
1112

1213
export type Ref = BaseRef<HTMLElement>;
1314

@@ -63,6 +64,14 @@ export interface Props extends BaseProps, RefProps<Ref> {
6364
* @reactive
6465
*/
6566
theme?: BundledTheme;
67+
68+
/**
69+
* 装饰器名称
70+
*
71+
* @remarks
72+
* 该值可由 {@link getDecorates} 获取。
73+
*/
74+
decorates?: Array<string>;
6675
}
6776

6877
/**
@@ -77,7 +86,16 @@ export function Root(props: Props): JSX.Element {
7786

7887
createEffect(async () => {
7988
const cls = joinClass(props.palette, props.class);
80-
const pre = await highlight(props.children, props.lang, props.ln, props.wrap, cls, props.style, props.theme);
89+
const pre = await highlight(
90+
props.children,
91+
props.lang,
92+
props.ln,
93+
props.wrap,
94+
cls,
95+
props.style,
96+
props.theme,
97+
props.decorates?.join(','),
98+
);
8199
setHTML(template(pre)() as HTMLElement);
82100

83101
if (props.ref) {
@@ -102,7 +120,7 @@ export function Root(props: Props): JSX.Element {
102120
}
103121
});
104122

105-
withCopyButton(el);
123+
withDecorate(el);
106124
});
107125

108126
return <>{html()}</>;

0 commit comments

Comments
 (0)