feat: add theme builder example (#6721)

This commit is contained in:
白熱
2026-03-23 12:47:08 +08:00
committed by GitHub
parent 66631d4bbf
commit 6fc34df1a6
11 changed files with 1261 additions and 0 deletions
@@ -0,0 +1,102 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { EditorMode, TokenDensity } from '../types';
import { Button } from '@univerjs/design';
import { ToolbarField, ToolbarToggleGroup } from './toolbar-controls';
export function SidebarHeader(props: {
darkMode: boolean;
editorMode: EditorMode;
tokenDensity: TokenDensity;
onDarkModeChange: (darkMode: boolean) => void;
onEditorModeChange: (mode: EditorMode) => void;
onPresetApply: (presetKey: 'default' | 'green') => void;
onTokenDensityChange: (density: TokenDensity) => void;
}) {
const {
darkMode,
editorMode,
tokenDensity,
onDarkModeChange,
onEditorModeChange,
onPresetApply,
onTokenDensityChange,
} = props;
return (
<div className="univer-p-3">
<div className="univer-flex univer-flex-wrap univer-items-center univer-justify-between univer-gap-2.5">
<h2
className="
univer-m-0 univer-text-lg univer-font-semibold univer-text-slate-950
dark:!univer-text-white
"
>
Theme Customizer
</h2>
<div className="univer-flex univer-flex-wrap univer-gap-2">
<Button size="small" onClick={() => onPresetApply('default')}>
Reset Default
</Button>
<Button size="small" onClick={() => onPresetApply('green')}>
Apply Green
</Button>
</div>
</div>
<div className="univer-mt-2.5">
<div className="univer-flex univer-flex-wrap univer-items-center univer-gap-x-4 univer-gap-y-2">
<ToolbarField label="Appearance">
<ToolbarToggleGroup
items={[
{ label: 'Light', value: 'light' },
{ label: 'Dark', value: 'dark' },
]}
value={darkMode ? 'dark' : 'light'}
onChange={(value) => onDarkModeChange(value === 'dark')}
/>
</ToolbarField>
<ToolbarField label="Mode">
<ToolbarToggleGroup
items={[
{ label: 'Token', value: 'tokens' },
{ label: 'JSON', value: 'json' },
]}
value={editorMode}
onChange={(value) => onEditorModeChange(value as EditorMode)}
/>
</ToolbarField>
{editorMode === 'tokens' && (
<ToolbarField label="Scope">
<ToolbarToggleGroup
items={[
{ label: 'Core Palette', value: 'core' },
{ label: 'Full Schema', value: 'full' },
]}
value={tokenDensity}
onChange={(value) => onTokenDensityChange(value as TokenDensity)}
/>
</ToolbarField>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,401 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Theme } from '@univerjs/themes';
import type { ReactNode } from 'react';
import type { LoopColorKey, ThemeScaleKey, ThemeShadeKey } from '../types';
import { Button, clsx, ColorPicker, Dropdown, FormLayout, Input, Select, Textarea } from '@univerjs/design';
import { useEffect, useState } from 'react';
import { COLOR_SHADE_KEYS, LOOP_COLOR_KEYS, LOOP_COLOR_OPTIONS } from '../constants';
import { normalizeHexColor } from '../theme-utils';
export function ThemeCodeBlock({ children }: { children: ReactNode }) {
return (
<pre
className={clsx(`
univer-m-0 univer-overflow-x-auto univer-rounded-xl univer-bg-slate-950 univer-p-4 univer-font-mono
univer-text-xs univer-leading-6 univer-text-slate-100
`)}
>
{children}
</pre>
);
}
export function ThemeColorField(props: {
label: string;
value: string;
onChange: (value: string) => void;
}) {
const { label, value, onChange } = props;
const [draftValue, setDraftValue] = useState(value);
useEffect(() => {
setDraftValue(value);
}, [value]);
const error = draftValue.trim().length > 0 && !normalizeHexColor(draftValue) ? 'Enter a valid HEX value, for example #466AF7.' : undefined;
function handleInputChange(nextValue: string) {
setDraftValue(nextValue);
const normalizedValue = normalizeHexColor(nextValue);
if (normalizedValue) {
onChange(normalizedValue);
}
}
function handleBlur() {
const normalizedValue = normalizeHexColor(draftValue);
setDraftValue(normalizedValue ?? value);
}
return (
<FormLayout label={label} error={error} className="univer-mb-0">
<Input
value={draftValue}
onBlur={handleBlur}
onChange={handleInputChange}
placeholder="#000000"
slot={(
<Dropdown
align="end"
side="bottom"
overlay={(
<div className="univer-p-2">
<ColorPicker
value={value}
onChange={(nextValue) => {
const normalizedValue = normalizeHexColor(nextValue);
if (normalizedValue) {
setDraftValue(normalizedValue);
onChange(normalizedValue);
}
}}
/>
</div>
)}
>
<button
type="button"
aria-label={`Choose ${label} color`}
className={clsx(`
univer-size-5 univer-cursor-pointer univer-rounded-full univer-border univer-border-solid
univer-border-slate-300 univer-bg-transparent univer-p-0
focus:univer-outline-none focus:univer-ring-2 focus:univer-ring-primary-50
`)}
style={{ backgroundColor: value }}
/>
</Dropdown>
)}
/>
</FormLayout>
);
}
export function ThemeScaleSection(props: {
title: string;
scale: ThemeScaleKey;
theme: Theme;
defaultExpanded?: boolean;
onChange: (scale: ThemeScaleKey, shade: ThemeShadeKey, value: string) => void;
}) {
const { title, scale, theme, defaultExpanded = false, onChange } = props;
const [expanded, setExpanded] = useState(defaultExpanded);
return (
<section
className={clsx(`
univer-rounded-2xl univer-bg-white
dark:!univer-bg-gray-800
`)}
>
<button
type="button"
className={clsx(`
univer-flex univer-w-full univer-items-center univer-justify-between univer-border-none
univer-bg-transparent univer-px-4 univer-py-3 univer-text-left
`)}
onClick={() => setExpanded((value) => !value)}
>
<div>
<div
className="
univer-text-sm univer-font-semibold univer-text-slate-900
dark:!univer-text-white
"
>
{title}
</div>
<div
className="
univer-mt-1 univer-text-xs univer-text-slate-500
dark:!univer-text-gray-300
"
>
{scale}
.500
{' = '}
{theme[scale][500]}
</div>
</div>
<div className="univer-flex univer-items-center univer-gap-2">
<span
className="
univer-inline-flex univer-size-6 univer-rounded-full univer-border univer-border-solid
univer-border-slate-200
"
style={{ backgroundColor: theme[scale][500] }}
/>
<span
className="
univer-text-xs univer-text-slate-500
dark:!univer-text-gray-300
"
>
{expanded ? 'Collapse' : 'Expand'}
</span>
</div>
</button>
{expanded && (
<div
className="
univer-grid univer-gap-3 univer-p-4
sm:univer-grid-cols-2
"
>
{COLOR_SHADE_KEYS.map((shade) => (
<ThemeColorField
key={shade}
label={shade}
value={theme[scale][shade]}
onChange={(value) => onChange(scale, shade, value)}
/>
))}
</div>
)}
</section>
);
}
export function ThemeRootColorsSection(props: {
theme: Theme;
onChange: (key: 'white' | 'black', value: string) => void;
}) {
const { theme, onChange } = props;
return (
<section
className={clsx(`
univer-rounded-2xl univer-bg-white univer-p-4
dark:!univer-bg-gray-800
`)}
>
<div
className="
univer-mb-3 univer-text-sm univer-font-semibold univer-text-slate-900
dark:!univer-text-white
"
>
Base Tokens
</div>
<div
className="
univer-grid univer-gap-3
sm:univer-grid-cols-2
"
>
<ThemeColorField label="white" value={theme.white} onChange={(value) => onChange('white', value)} />
<ThemeColorField label="black" value={theme.black} onChange={(value) => onChange('black', value)} />
</div>
</section>
);
}
export function ThemeLoopColorSection(props: {
theme: Theme;
onChange: (key: LoopColorKey, value: string) => void;
}) {
const { theme, onChange } = props;
return (
<section
className={clsx(`
univer-rounded-2xl univer-bg-white univer-p-4
dark:!univer-bg-gray-800
`)}
>
<div
className="
univer-mb-3 univer-text-sm univer-font-semibold univer-text-slate-900
dark:!univer-text-white
"
>
loop-color
</div>
<div
className="
univer-grid univer-gap-3
sm:univer-grid-cols-2
"
>
{LOOP_COLOR_KEYS.map((key) => (
<FormLayout key={key} label={key} className="univer-mb-0">
<Select
value={(theme['loop-color'] as Record<string, string>)[key]}
options={LOOP_COLOR_OPTIONS}
onChange={(value) => onChange(key, value)}
/>
</FormLayout>
))}
</div>
</section>
);
}
export function IntegrationExampleSection(props: {
copyLabel: string;
onCopy: () => void;
}) {
const { copyLabel, onCopy } = props;
return (
<section
className={clsx(`
univer-rounded-2xl univer-bg-slate-50 univer-p-4
dark:!univer-bg-gray-800
`)}
>
<div className="univer-mb-3 univer-flex univer-items-center univer-justify-between">
<div
className="
univer-text-sm univer-font-semibold univer-text-slate-900
dark:!univer-text-white
"
>
Integration Example
</div>
<Button size="middle" onClick={onCopy}>
{copyLabel}
</Button>
</div>
<ThemeCodeBlock>
{`import { Univer } from '@univerjs/core';
import { customTheme } from './custom-theme';
const univer = new Univer({
theme: customTheme,
locale: LocaleType.EN_US,
});`}
</ThemeCodeBlock>
</section>
);
}
export function JsonEditorPanel(props: {
copyLabel: string;
jsonDraft: string;
jsonError: string | null;
onCopy: () => void;
onFormatCurrent: () => void;
onJsonChange: (value: string) => void;
onSyncCurrent: () => void;
onViewDefault: () => void;
}) {
const { copyLabel, jsonDraft, jsonError, onCopy, onFormatCurrent, onJsonChange, onSyncCurrent, onViewDefault } = props;
return (
<section
className={clsx(`
univer-rounded-2xl univer-bg-slate-50 univer-p-4
dark:!univer-bg-gray-800
`)}
>
<div className="univer-flex univer-flex-wrap univer-items-center univer-justify-between univer-gap-3">
<div>
<div
className="
univer-text-sm univer-font-semibold univer-text-slate-900
dark:!univer-text-white
"
>
Theme JSON
</div>
<p
className="
univer-m-0 univer-mt-1 univer-text-xs univer-leading-5 univer-text-slate-500
dark:!univer-text-gray-300
"
>
A valid JSON patch is merged and applied to the running `ThemeService` immediately.
</p>
</div>
<div className="univer-flex univer-flex-wrap univer-gap-2">
<Button size="middle" onClick={onSyncCurrent}>
Sync Current Theme
</Button>
<Button size="middle" onClick={onViewDefault}>
View Default
</Button>
<Button size="middle" onClick={onCopy}>
{copyLabel}
</Button>
</div>
</div>
<div className="univer-mt-4">
<Textarea
value={jsonDraft}
spellCheck={false}
className={clsx(`
univer-h-[640px] univer-rounded-2xl univer-bg-slate-950 univer-p-4 univer-font-mono univer-text-sm
univer-leading-6 univer-text-slate-100
`, jsonError ? 'univer-border-red-500' : 'univer-border-slate-700')}
style={{
color: '#E2E8F0',
backgroundColor: '#020617',
caretColor: '#F8FAFC',
}}
onValueChange={onJsonChange}
/>
<div
className="
univer-mt-3 univer-flex univer-flex-wrap univer-items-center univer-justify-between univer-gap-3
"
>
<div
className={clsx('univer-text-xs univer-leading-5', jsonError
? 'univer-text-red-500'
: `
univer-text-slate-500
dark:!univer-text-gray-300
`)}
>
{jsonError}
</div>
<Button size="middle" variant="primary" onClick={onFormatCurrent}>
Format Current Result
</Button>
</div>
</div>
</section>
);
}
@@ -0,0 +1,51 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Theme } from '@univerjs/themes';
import type { LoopColorKey, ThemeScaleKey, ThemeShadeKey } from '../types';
import { IntegrationExampleSection, ThemeLoopColorSection, ThemeRootColorsSection, ThemeScaleSection } from './theme-sections';
export function TokenEditorPanel(props: {
copyLabel: string;
theme: Theme;
visibleScaleKeys: ThemeScaleKey[];
onCopy: () => void;
onLoopColorChange: (key: LoopColorKey, value: string) => void;
onRootColorChange: (key: 'white' | 'black', value: string) => void;
onScaleColorChange: (scale: ThemeScaleKey, shade: ThemeShadeKey, value: string) => void;
}) {
const { copyLabel, theme, visibleScaleKeys, onCopy, onLoopColorChange, onRootColorChange, onScaleColorChange } = props;
return (
<div className="univer-flex univer-flex-col univer-gap-4">
<ThemeRootColorsSection theme={theme} onChange={onRootColorChange} />
{visibleScaleKeys.map((scale, index) => (
<ThemeScaleSection
key={scale}
title={scale}
scale={scale}
theme={theme}
defaultExpanded={index < 2}
onChange={onScaleColorChange}
/>
))}
<ThemeLoopColorSection theme={theme} onChange={onLoopColorChange} />
<IntegrationExampleSection copyLabel={copyLabel} onCopy={onCopy} />
</div>
);
}
@@ -0,0 +1,80 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { ReactNode } from 'react';
import { clsx } from '@univerjs/design';
export function ToolbarField(props: { label: string; children: ReactNode }) {
const { label, children } = props;
return (
<div className="univer-inline-flex univer-items-center univer-gap-2">
<div
className="
univer-shrink-0 univer-text-[11px] univer-font-medium univer-uppercase univer-tracking-[0.06em]
univer-text-slate-500
dark:!univer-text-gray-400
"
>
{label}
</div>
{children}
</div>
);
}
export function ToolbarToggleGroup(props: {
items: Array<{ label: string; value: string }>;
value: string;
onChange: (value: string) => void;
}) {
const { items, value, onChange } = props;
return (
<div className="univer-inline-flex univer-items-center univer-gap-1">
{items.map((item) => {
const active = item.value === value;
return (
<button
key={item.value}
type="button"
className={clsx(`
univer-cursor-pointer univer-rounded-md univer-border-none univer-bg-transparent univer-px-2.5
univer-py-1 univer-text-sm univer-font-medium univer-transition-colors
`, active
? ''
: `
univer-text-slate-600
hover:univer-bg-slate-100 hover:univer-text-slate-900
dark:!univer-text-gray-300
dark:hover:!univer-bg-gray-800 dark:hover:!univer-text-white
`)}
style={active
? {
backgroundColor: 'var(--univer-primary-600)',
color: '#FFFFFF',
}
: undefined}
onClick={() => onChange(item.value)}
>
{item.label}
</button>
);
})}
</div>
);
}
@@ -0,0 +1,130 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Theme } from '@univerjs/themes';
import { LocaleType, LogLevel, ThemeService, Univer, UniverInstanceType } from '@univerjs/core';
import { clsx } from '@univerjs/design';
import { UniverDocsPlugin } from '@univerjs/docs';
import { UniverDocsUIPlugin } from '@univerjs/docs-ui';
import { UniverFormulaEnginePlugin } from '@univerjs/engine-formula';
import { UniverRenderEnginePlugin } from '@univerjs/engine-render';
import { DEFAULT_WORKBOOK_DATA_DEMO } from '@univerjs/mockdata';
import enUS from '@univerjs/mockdata/locales/en-US';
import { UniverSheetsPlugin } from '@univerjs/sheets';
import { UniverSheetsConditionalFormattingPlugin } from '@univerjs/sheets-conditional-formatting';
import { UniverSheetsConditionalFormattingUIPlugin } from '@univerjs/sheets-conditional-formatting-ui';
import { UniverSheetsDataValidationPlugin } from '@univerjs/sheets-data-validation';
import { UniverSheetsDataValidationUIPlugin } from '@univerjs/sheets-data-validation-ui';
import { UniverSheetsFilterPlugin } from '@univerjs/sheets-filter';
import { UniverSheetsFilterUIPlugin } from '@univerjs/sheets-filter-ui';
import { UniverSheetsFormulaPlugin } from '@univerjs/sheets-formula';
import { UniverSheetsFormulaUIPlugin } from '@univerjs/sheets-formula-ui';
import { UniverSheetsHyperLinkPlugin } from '@univerjs/sheets-hyper-link';
import { UniverSheetsHyperLinkUIPlugin } from '@univerjs/sheets-hyper-link-ui';
import { UniverSheetsNotePlugin } from '@univerjs/sheets-note';
import { UniverSheetsNoteUIPlugin } from '@univerjs/sheets-note-ui';
import { UniverSheetsNumfmtPlugin } from '@univerjs/sheets-numfmt';
import { UniverSheetsNumfmtUIPlugin } from '@univerjs/sheets-numfmt-ui';
import { UniverSheetsSortPlugin } from '@univerjs/sheets-sort';
import { UniverSheetsSortUIPlugin } from '@univerjs/sheets-sort-ui';
import { UniverSheetsTablePlugin } from '@univerjs/sheets-table';
import { UniverSheetsTableUIPlugin } from '@univerjs/sheets-table-ui';
import { UniverSheetsUIPlugin } from '@univerjs/sheets-ui';
import { UniverSheetsZenEditorPlugin } from '@univerjs/sheets-zen-editor';
import { UniverUIPlugin } from '@univerjs/ui';
import { useEffect, useRef } from 'react';
import { PREVIEW_CONTAINER_ID } from '../constants';
export function UniverPreview(props: { theme: Theme; darkMode: boolean }) {
const { theme, darkMode } = props;
const univerRef = useRef<Univer | null>(null);
useEffect(() => {
const univer = new Univer({
theme,
darkMode,
locale: LocaleType.EN_US,
locales: {
[LocaleType.EN_US]: enUS,
},
logLevel: LogLevel.ERROR,
});
univer.registerPlugins([
[UniverDocsPlugin],
[UniverRenderEnginePlugin],
[UniverUIPlugin, {
container: PREVIEW_CONTAINER_ID,
ribbonType: 'classic',
}],
[UniverDocsUIPlugin],
[UniverSheetsPlugin, {
autoHeightForMergedCells: true,
}],
[UniverSheetsUIPlugin],
[UniverSheetsNumfmtPlugin],
[UniverSheetsZenEditorPlugin],
[UniverFormulaEnginePlugin],
[UniverSheetsFormulaPlugin],
[UniverSheetsFormulaUIPlugin],
[UniverSheetsDataValidationPlugin],
[UniverSheetsDataValidationUIPlugin],
[UniverSheetsConditionalFormattingPlugin],
[UniverSheetsConditionalFormattingUIPlugin],
[UniverSheetsFilterPlugin],
[UniverSheetsFilterUIPlugin, { useRemoteFilterValuesGenerator: false }],
[UniverSheetsSortPlugin],
[UniverSheetsSortUIPlugin],
[UniverSheetsHyperLinkPlugin],
[UniverSheetsHyperLinkUIPlugin],
[UniverSheetsTablePlugin],
[UniverSheetsTableUIPlugin],
[UniverSheetsNotePlugin],
[UniverSheetsNoteUIPlugin],
[UniverSheetsNumfmtUIPlugin],
]);
univer.createUnit(UniverInstanceType.UNIVER_SHEET, DEFAULT_WORKBOOK_DATA_DEMO);
univerRef.current = univer;
return () => {
univer.dispose();
univerRef.current = null;
};
}, []);
useEffect(() => {
if (univerRef.current) {
univerRef.current.__getInjector().get(ThemeService).setTheme(theme);
}
}, [theme]);
useEffect(() => {
if (univerRef.current) {
univerRef.current.__getInjector().get(ThemeService).setDarkMode(darkMode);
}
}, [darkMode]);
return (
<div
id={PREVIEW_CONTAINER_ID}
className={clsx(`
univer-h-full univer-min-h-[520px] univer-w-full univer-overflow-hidden univer-rounded-[20px]
univer-bg-white
`)}
/>
);
}
@@ -0,0 +1,42 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IThemePreset, ThemeScaleKey, ThemeShadeKey, TokenDensity } from './types';
import { defaultTheme, greenTheme } from '@univerjs/themes';
export const PREVIEW_CONTAINER_ID = 'theme-customizer-preview';
export const COLOR_SHADE_KEYS: ThemeShadeKey[] = ['50', '100', '200', '300', '400', '500', '600', '700', '800', '900'];
export const COLOR_SCALE_KEYS: ThemeScaleKey[] = ['primary', 'gray', 'blue', 'red', 'orange', 'yellow', 'green', 'jiqing', 'indigo', 'purple', 'pink'];
export const CORE_SCALE_KEYS: ThemeScaleKey[] = ['primary', 'gray', 'blue', 'green', 'red'];
export const LOOP_COLOR_KEYS = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12'] as const;
export const LOOP_COLOR_OPTIONS = COLOR_SCALE_KEYS.flatMap((scale) =>
COLOR_SHADE_KEYS.map((shade) => ({
label: `${scale}.${shade}`,
value: `${scale}.${shade}`,
}))
);
export const THEME_PRESETS: IThemePreset[] = [
{ key: 'default', label: 'defaultTheme', theme: defaultTheme },
{ key: 'green', label: 'greenTheme', theme: greenTheme },
];
export const TOKEN_DENSITY_LABELS: Record<TokenDensity, string> = {
core: 'Core Palette',
full: 'Full Schema',
};
@@ -0,0 +1,128 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Theme } from '@univerjs/themes';
import type { EditorMode, LoopColorKey, ThemeScaleKey, ThemeShadeKey, TokenDensity } from '../types';
import { defaultTheme } from '@univerjs/themes';
import { useEffect, useMemo, useState } from 'react';
import { COLOR_SCALE_KEYS, CORE_SCALE_KEYS, THEME_PRESETS } from '../constants';
import { cloneTheme, formatTheme, mergeThemePatch, updateLoopColor, updateScaleColor, updateThemeRootColor } from '../theme-utils';
// eslint-disable-next-line max-lines-per-function
export function useThemeCustomizerState() {
const [theme, setTheme] = useState<Theme>(() => cloneTheme(defaultTheme));
const [editorMode, setEditorMode] = useState<EditorMode>('tokens');
const [tokenDensity, setTokenDensity] = useState<TokenDensity>('core');
const [darkMode, setDarkMode] = useState(false);
const [jsonDraft, setJsonDraft] = useState(() => formatTheme(defaultTheme));
const [jsonError, setJsonError] = useState<string | null>(null);
const [copyLabel, setCopyLabel] = useState('Copy JSON');
useEffect(() => {
document.documentElement.classList.toggle('univer-dark', darkMode);
document.body.classList.toggle('univer-dark', darkMode);
return () => {
document.documentElement.classList.remove('univer-dark');
document.body.classList.remove('univer-dark');
};
}, [darkMode]);
const visibleScaleKeys = useMemo(() => (
tokenDensity === 'core' ? CORE_SCALE_KEYS : COLOR_SCALE_KEYS
), [tokenDensity]);
function applyTheme(nextTheme: Theme) {
setTheme(nextTheme);
setJsonDraft(formatTheme(nextTheme));
setJsonError(null);
}
function handleScaleColorChange(scale: ThemeScaleKey, shade: ThemeShadeKey, value: string) {
applyTheme(updateScaleColor(theme, scale, shade, value));
}
function handleRootColorChange(key: 'white' | 'black', value: string) {
applyTheme(updateThemeRootColor(theme, key, value));
}
function handleLoopColorChange(key: LoopColorKey, value: string) {
applyTheme(updateLoopColor(theme, key, value));
}
function handlePresetApply(presetKey: 'default' | 'green') {
const preset = THEME_PRESETS.find((item) => item.key === presetKey);
if (!preset) {
return;
}
applyTheme(cloneTheme(preset.theme));
}
function handleJsonChange(value: string) {
setJsonDraft(value);
try {
const parsedValue = JSON.parse(value) as unknown;
const mergedTheme = mergeThemePatch(theme, parsedValue);
if (!mergedTheme) {
setJsonError('JSON must be an object.');
return;
}
setTheme(mergedTheme);
setJsonError(null);
} catch (error) {
setJsonError(error instanceof Error ? error.message : 'Failed to parse JSON.');
}
}
async function handleCopyTheme() {
if (!navigator.clipboard) {
return;
}
await navigator.clipboard.writeText(formatTheme(theme));
setCopyLabel('Copied');
window.setTimeout(() => {
setCopyLabel('Copy JSON');
}, 1600);
}
return {
copyLabel,
darkMode,
editorMode,
jsonDraft,
jsonError,
theme,
tokenDensity,
visibleScaleKeys,
setDarkMode,
setEditorMode,
setJsonDraft,
setTokenDensity,
handleCopyTheme,
handleJsonChange,
handleLoopColorChange,
handlePresetApply,
handleRootColorChange,
handleScaleColorChange,
};
}
+28
View File
@@ -0,0 +1,28 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ConfigProvider, render } from '@univerjs/design';
import enUS from '@univerjs/mockdata/locales/en-US';
import { ThemeCustomizerApp } from './theme-customizer-app';
import '../global.css';
render(
<ConfigProvider locale={enUS.design} mountContainer={document.body}>
<ThemeCustomizerApp />
</ConfigProvider>,
document.getElementById('app')!
);
@@ -0,0 +1,142 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { clsx } from '@univerjs/design';
import { defaultTheme } from '@univerjs/themes';
import { SidebarHeader } from './components/sidebar-header';
import { JsonEditorPanel } from './components/theme-sections';
import { TokenEditorPanel } from './components/token-editor-panel';
import { UniverPreview } from './components/univer-preview';
import { useThemeCustomizerState } from './hooks/use-theme-customizer-state';
import { cloneTheme, formatTheme } from './theme-utils';
export function ThemeCustomizerApp() {
const {
copyLabel,
darkMode,
editorMode,
jsonDraft,
jsonError,
theme,
tokenDensity,
visibleScaleKeys,
setDarkMode,
setEditorMode,
setJsonDraft,
setTokenDensity,
handleCopyTheme,
handleJsonChange,
handleLoopColorChange,
handlePresetApply,
handleRootColorChange,
handleScaleColorChange,
} = useThemeCustomizerState();
return (
<main
className={clsx(`
univer-box-border univer-h-screen univer-overflow-hidden univer-p-2.5 univer-text-slate-900
lg:univer-p-3
`, darkMode
? 'univer-bg-[linear-gradient(180deg,#0f172a_0%,#111827_100%)] univer-text-slate-100'
: 'univer-bg-[linear-gradient(180deg,#f8fafc_0%,#eef2ff_100%)]')}
>
<div className="univer-mx-auto univer-h-full univer-max-w-[1680px]">
<section
className="
univer-grid univer-h-full univer-gap-3
xl:univer-grid-cols-[400px_minmax(0,1fr)]
"
>
<aside
className={clsx(`
univer-flex univer-h-full univer-min-h-0 univer-flex-col univer-overflow-hidden
univer-rounded-[28px] univer-bg-white univer-shadow-[0_16px_48px_rgba(15,23,42,0.08)]
dark:!univer-bg-gray-900
`)}
>
<SidebarHeader
darkMode={darkMode}
editorMode={editorMode}
tokenDensity={tokenDensity}
onDarkModeChange={setDarkMode}
onEditorModeChange={setEditorMode}
onPresetApply={handlePresetApply}
onTokenDensityChange={setTokenDensity}
/>
<div className="univer-flex-1 univer-overflow-y-auto univer-p-4">
{editorMode === 'tokens'
? (
<TokenEditorPanel
copyLabel={copyLabel}
theme={theme}
visibleScaleKeys={visibleScaleKeys}
onCopy={handleCopyTheme}
onLoopColorChange={handleLoopColorChange}
onRootColorChange={handleRootColorChange}
onScaleColorChange={handleScaleColorChange}
/>
)
: (
<div className="univer-flex univer-h-full univer-flex-col univer-gap-4">
<JsonEditorPanel
copyLabel={copyLabel}
jsonDraft={jsonDraft}
jsonError={jsonError}
onCopy={handleCopyTheme}
onFormatCurrent={() => setJsonDraft(formatTheme(theme))}
onJsonChange={handleJsonChange}
onSyncCurrent={() => setJsonDraft(formatTheme(theme))}
onViewDefault={() => setJsonDraft(formatTheme(cloneTheme(defaultTheme)))}
/>
</div>
)}
</div>
</aside>
<section
className={clsx(`
univer-flex univer-h-full univer-min-h-0 univer-flex-col univer-overflow-hidden
univer-rounded-[28px] univer-bg-white univer-shadow-[0_18px_56px_rgba(15,23,42,0.16)]
dark:!univer-bg-gray-900
`)}
>
<div
className="
univer-bg-white univer-p-4
dark:!univer-bg-gray-900
"
>
<h2
className="
univer-m-0 univer-text-base univer-font-semibold univer-text-slate-950
dark:!univer-text-white
"
>
Live Preview
</h2>
</div>
<div className="univer-min-h-[640px] univer-flex-1 univer-overflow-hidden">
<UniverPreview theme={theme} darkMode={darkMode} />
</div>
</section>
</section>
</div>
</main>
);
}
@@ -0,0 +1,128 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Theme } from '@univerjs/themes';
import type { LoopColorKey, ThemeScaleKey, ThemeShadeKey } from './types';
import { COLOR_SCALE_KEYS, COLOR_SHADE_KEYS, LOOP_COLOR_KEYS } from './constants';
export function cloneTheme(theme: Theme): Theme {
return JSON.parse(JSON.stringify(theme)) as Theme;
}
export function normalizeHexColor(value: string): string | null {
const trimmedValue = value.trim();
if (!trimmedValue) {
return null;
}
const prefixedValue = trimmedValue.startsWith('#') ? trimmedValue : `#${trimmedValue}`;
const shortHexMatch = prefixedValue.match(/^#([0-9a-fA-F]{3})$/);
if (shortHexMatch) {
const [r, g, b] = shortHexMatch[1].split('');
return `#${r}${r}${g}${g}${b}${b}`.toUpperCase();
}
if (/^#([0-9a-fA-F]{6})$/.test(prefixedValue)) {
return prefixedValue.toUpperCase();
}
return null;
}
export function formatTheme(theme: Theme): string {
return JSON.stringify(theme, null, 4);
}
export function mergeThemePatch(baseTheme: Theme, patch: unknown): Theme | null {
if (!patch || typeof patch !== 'object' || Array.isArray(patch)) {
return null;
}
const nextTheme = cloneTheme(baseTheme);
const record = patch as Record<string, unknown>;
if (typeof record.white === 'string') {
nextTheme.white = record.white;
}
if (typeof record.black === 'string') {
nextTheme.black = record.black;
}
for (const scale of COLOR_SCALE_KEYS) {
const scalePatch = record[scale];
if (!scalePatch || typeof scalePatch !== 'object' || Array.isArray(scalePatch)) {
continue;
}
const scaleRecord = scalePatch as Record<string, unknown>;
const mergedScale = { ...nextTheme[scale] } as Record<string, string>;
for (const shade of COLOR_SHADE_KEYS) {
if (typeof scaleRecord[shade] === 'string') {
mergedScale[shade] = scaleRecord[shade] as string;
}
}
nextTheme[scale] = mergedScale as Theme[ThemeScaleKey];
}
const loopColorPatch = record['loop-color'];
if (loopColorPatch && typeof loopColorPatch === 'object' && !Array.isArray(loopColorPatch)) {
const loopRecord = loopColorPatch as Record<string, unknown>;
const mergedLoopColor = { ...(nextTheme['loop-color'] as Record<string, string>) };
for (const key of LOOP_COLOR_KEYS) {
if (typeof loopRecord[key] === 'string') {
mergedLoopColor[key] = loopRecord[key] as string;
}
}
nextTheme['loop-color'] = mergedLoopColor as Theme['loop-color'];
}
return nextTheme;
}
export function updateScaleColor(theme: Theme, scale: ThemeScaleKey, shade: ThemeShadeKey, value: string): Theme {
return {
...theme,
[scale]: {
...theme[scale],
[shade]: value,
},
} as Theme;
}
export function updateThemeRootColor(theme: Theme, key: 'white' | 'black', value: string): Theme {
return {
...theme,
[key]: value,
};
}
export function updateLoopColor(theme: Theme, key: LoopColorKey, value: string): Theme {
return {
...theme,
'loop-color': {
...theme['loop-color'],
[key]: value,
},
} as Theme;
}
+29
View File
@@ -0,0 +1,29 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Theme } from '@univerjs/themes';
export type ThemeShadeKey = '50' | '100' | '200' | '300' | '400' | '500' | '600' | '700' | '800' | '900';
export type ThemeScaleKey = 'primary' | 'gray' | 'blue' | 'red' | 'orange' | 'yellow' | 'green' | 'jiqing' | 'indigo' | 'purple' | 'pink';
export type LoopColorKey = '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' | '10' | '11' | '12';
export type EditorMode = 'tokens' | 'json';
export type TokenDensity = 'core' | 'full';
export interface IThemePreset {
key: 'default' | 'green';
label: string;
theme: Theme;
}