diff --git a/examples/src/theme-customizer/components/sidebar-header.tsx b/examples/src/theme-customizer/components/sidebar-header.tsx
new file mode 100644
index 0000000000..ad5e2b1c9e
--- /dev/null
+++ b/examples/src/theme-customizer/components/sidebar-header.tsx
@@ -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 (
+
+
+
+ Theme Customizer
+
+
+
+ onPresetApply('default')}>
+ Reset Default
+
+ onPresetApply('green')}>
+ Apply Green
+
+
+
+
+
+
+
+ onDarkModeChange(value === 'dark')}
+ />
+
+
+
+ onEditorModeChange(value as EditorMode)}
+ />
+
+
+ {editorMode === 'tokens' && (
+
+ onTokenDensityChange(value as TokenDensity)}
+ />
+
+ )}
+
+
+
+ );
+}
diff --git a/examples/src/theme-customizer/components/theme-sections.tsx b/examples/src/theme-customizer/components/theme-sections.tsx
new file mode 100644
index 0000000000..cc897beec0
--- /dev/null
+++ b/examples/src/theme-customizer/components/theme-sections.tsx
@@ -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 (
+
+ {children}
+
+ );
+}
+
+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 (
+
+
+ {
+ const normalizedValue = normalizeHexColor(nextValue);
+
+ if (normalizedValue) {
+ setDraftValue(normalizedValue);
+ onChange(normalizedValue);
+ }
+ }}
+ />
+
+ )}
+ >
+
+
+ )}
+ />
+
+ );
+}
+
+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 (
+
+ setExpanded((value) => !value)}
+ >
+
+
+ {title}
+
+
+ {scale}
+ .500
+ {' = '}
+ {theme[scale][500]}
+
+
+
+
+
+ {expanded ? 'Collapse' : 'Expand'}
+
+
+
+
+ {expanded && (
+
+ {COLOR_SHADE_KEYS.map((shade) => (
+ onChange(scale, shade, value)}
+ />
+ ))}
+
+ )}
+
+ );
+}
+
+export function ThemeRootColorsSection(props: {
+ theme: Theme;
+ onChange: (key: 'white' | 'black', value: string) => void;
+}) {
+ const { theme, onChange } = props;
+
+ return (
+
+
+ Base Tokens
+
+
+ onChange('white', value)} />
+ onChange('black', value)} />
+
+
+ );
+}
+
+export function ThemeLoopColorSection(props: {
+ theme: Theme;
+ onChange: (key: LoopColorKey, value: string) => void;
+}) {
+ const { theme, onChange } = props;
+
+ return (
+
+
+ loop-color
+
+
+ {LOOP_COLOR_KEYS.map((key) => (
+
+ )[key]}
+ options={LOOP_COLOR_OPTIONS}
+ onChange={(value) => onChange(key, value)}
+ />
+
+ ))}
+
+
+ );
+}
+
+export function IntegrationExampleSection(props: {
+ copyLabel: string;
+ onCopy: () => void;
+}) {
+ const { copyLabel, onCopy } = props;
+
+ return (
+
+
+
+ Integration Example
+
+
+ {copyLabel}
+
+
+
+
+ {`import { Univer } from '@univerjs/core';
+import { customTheme } from './custom-theme';
+
+const univer = new Univer({
+ theme: customTheme,
+ locale: LocaleType.EN_US,
+});`}
+
+
+ );
+}
+
+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 (
+
+
+
+
+ Theme JSON
+
+
+ A valid JSON patch is merged and applied to the running `ThemeService` immediately.
+
+
+
+
+
+ Sync Current Theme
+
+
+ View Default
+
+
+ {copyLabel}
+
+
+
+
+
+
+
+
+ {jsonError}
+
+
+ Format Current Result
+
+
+
+
+ );
+}
diff --git a/examples/src/theme-customizer/components/token-editor-panel.tsx b/examples/src/theme-customizer/components/token-editor-panel.tsx
new file mode 100644
index 0000000000..a74d289fd2
--- /dev/null
+++ b/examples/src/theme-customizer/components/token-editor-panel.tsx
@@ -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 (
+
+
+
+ {visibleScaleKeys.map((scale, index) => (
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/examples/src/theme-customizer/components/toolbar-controls.tsx b/examples/src/theme-customizer/components/toolbar-controls.tsx
new file mode 100644
index 0000000000..6fa16d859b
--- /dev/null
+++ b/examples/src/theme-customizer/components/toolbar-controls.tsx
@@ -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 (
+
+
+ {label}
+
+ {children}
+
+ );
+}
+
+export function ToolbarToggleGroup(props: {
+ items: Array<{ label: string; value: string }>;
+ value: string;
+ onChange: (value: string) => void;
+}) {
+ const { items, value, onChange } = props;
+
+ return (
+
+ {items.map((item) => {
+ const active = item.value === value;
+
+ return (
+ onChange(item.value)}
+ >
+ {item.label}
+
+ );
+ })}
+
+ );
+}
diff --git a/examples/src/theme-customizer/components/univer-preview.tsx b/examples/src/theme-customizer/components/univer-preview.tsx
new file mode 100644
index 0000000000..9b13214602
--- /dev/null
+++ b/examples/src/theme-customizer/components/univer-preview.tsx
@@ -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(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 (
+
+ );
+}
diff --git a/examples/src/theme-customizer/constants.ts b/examples/src/theme-customizer/constants.ts
new file mode 100644
index 0000000000..60c5027adb
--- /dev/null
+++ b/examples/src/theme-customizer/constants.ts
@@ -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 = {
+ core: 'Core Palette',
+ full: 'Full Schema',
+};
diff --git a/examples/src/theme-customizer/hooks/use-theme-customizer-state.ts b/examples/src/theme-customizer/hooks/use-theme-customizer-state.ts
new file mode 100644
index 0000000000..49a4a76c16
--- /dev/null
+++ b/examples/src/theme-customizer/hooks/use-theme-customizer-state.ts
@@ -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(() => cloneTheme(defaultTheme));
+ const [editorMode, setEditorMode] = useState('tokens');
+ const [tokenDensity, setTokenDensity] = useState('core');
+ const [darkMode, setDarkMode] = useState(false);
+ const [jsonDraft, setJsonDraft] = useState(() => formatTheme(defaultTheme));
+ const [jsonError, setJsonError] = useState(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,
+ };
+}
diff --git a/examples/src/theme-customizer/main.tsx b/examples/src/theme-customizer/main.tsx
new file mode 100644
index 0000000000..07bfc70f26
--- /dev/null
+++ b/examples/src/theme-customizer/main.tsx
@@ -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(
+
+
+ ,
+ document.getElementById('app')!
+);
diff --git a/examples/src/theme-customizer/theme-customizer-app.tsx b/examples/src/theme-customizer/theme-customizer-app.tsx
new file mode 100644
index 0000000000..9890ad11bf
--- /dev/null
+++ b/examples/src/theme-customizer/theme-customizer-app.tsx
@@ -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 (
+
+
+
+
+
+
+
+ {editorMode === 'tokens'
+ ? (
+
+ )
+ : (
+
+ setJsonDraft(formatTheme(theme))}
+ onJsonChange={handleJsonChange}
+ onSyncCurrent={() => setJsonDraft(formatTheme(theme))}
+ onViewDefault={() => setJsonDraft(formatTheme(cloneTheme(defaultTheme)))}
+ />
+
+ )}
+
+
+
+
+
+
+ Live Preview
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/examples/src/theme-customizer/theme-utils.ts b/examples/src/theme-customizer/theme-utils.ts
new file mode 100644
index 0000000000..69592c58b6
--- /dev/null
+++ b/examples/src/theme-customizer/theme-utils.ts
@@ -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;
+
+ 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;
+ const mergedScale = { ...nextTheme[scale] } as Record;
+
+ 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;
+ const mergedLoopColor = { ...(nextTheme['loop-color'] as Record) };
+
+ 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;
+}
diff --git a/examples/src/theme-customizer/types.ts b/examples/src/theme-customizer/types.ts
new file mode 100644
index 0000000000..83577bc9cc
--- /dev/null
+++ b/examples/src/theme-customizer/types.ts
@@ -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;
+}