mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-21 20:16:26 +08:00
feat(core): new theme editor poc (#7810)
This commit is contained in:
@@ -17,6 +17,7 @@ import { configureQuickSearchModule } from './quicksearch';
|
||||
import { configureShareDocsModule } from './share-doc';
|
||||
import { configureTagModule } from './tag';
|
||||
import { configureTelemetryModule } from './telemetry';
|
||||
import { configureThemeEditorModule } from './theme-editor';
|
||||
|
||||
export function configureCommonModules(framework: Framework) {
|
||||
configureInfraModules(framework);
|
||||
@@ -37,4 +38,5 @@ export function configureCommonModules(framework: Framework) {
|
||||
configureOrganizeModule(framework);
|
||||
configureFavoriteModule(framework);
|
||||
configureExplorerModule(framework);
|
||||
configureThemeEditorModule(framework);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { type Framework, GlobalState } from '@toeverything/infra';
|
||||
|
||||
import { ThemeEditorService } from './services/theme-editor';
|
||||
|
||||
export { CustomThemeModifier, useCustomTheme } from './views/custom-theme';
|
||||
export { ThemeEditor } from './views/theme-editor';
|
||||
export { ThemeEditorService };
|
||||
|
||||
export function configureThemeEditorModule(framework: Framework) {
|
||||
framework.service(ThemeEditorService, [GlobalState]);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { GlobalState } from '@toeverything/infra';
|
||||
import { LiveData, Service } from '@toeverything/infra';
|
||||
import { map } from 'rxjs';
|
||||
|
||||
import type { CustomTheme } from '../types';
|
||||
|
||||
export class ThemeEditorService extends Service {
|
||||
constructor(public readonly globalState: GlobalState) {
|
||||
super();
|
||||
}
|
||||
|
||||
private readonly _key = 'custom-theme';
|
||||
|
||||
customTheme$ = LiveData.from<CustomTheme | undefined>(
|
||||
this.globalState.watch<CustomTheme>(this._key).pipe(
|
||||
map(value => {
|
||||
if (!value) return { light: {}, dark: {} };
|
||||
if (!value.light) value.light = {};
|
||||
if (!value.dark) value.dark = {};
|
||||
const removeEmpty = (obj: Record<string, string>) =>
|
||||
Object.fromEntries(Object.entries(obj).filter(([, v]) => v));
|
||||
return {
|
||||
light: removeEmpty(value.light),
|
||||
dark: removeEmpty(value.dark),
|
||||
};
|
||||
})
|
||||
),
|
||||
{ light: {}, dark: {} }
|
||||
);
|
||||
|
||||
modified$ = LiveData.computed(get => {
|
||||
const theme = get(this.customTheme$);
|
||||
const isEmptyObj = (obj: Record<string, string>) =>
|
||||
Object.keys(obj).length === 0;
|
||||
return theme && !(isEmptyObj(theme.light) && isEmptyObj(theme.dark));
|
||||
});
|
||||
|
||||
reset() {
|
||||
this.globalState.set(this._key, { light: {}, dark: {} });
|
||||
}
|
||||
|
||||
setCustomTheme(theme: CustomTheme) {
|
||||
this.globalState.set(this._key, theme);
|
||||
}
|
||||
|
||||
updateCustomTheme(mode: 'light' | 'dark', key: string, value?: string) {
|
||||
const prev: CustomTheme = this.globalState.get(this._key) ?? {
|
||||
light: {},
|
||||
dark: {},
|
||||
};
|
||||
const next = {
|
||||
...prev,
|
||||
[mode]: {
|
||||
...prev[mode],
|
||||
[key]: value,
|
||||
},
|
||||
};
|
||||
|
||||
if (!value) {
|
||||
delete next[mode][key];
|
||||
}
|
||||
|
||||
this.globalState.set(this._key, next);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export type CustomTheme = {
|
||||
light: Record<string, string>;
|
||||
dark: Record<string, string>;
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
import { IconButton, Input, Menu, MenuItem } from '@affine/component';
|
||||
import { MoreHorizontalIcon } from '@blocksuite/icons/rc';
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import * as styles from '../theme-editor.css';
|
||||
import { SimpleColorPicker } from './simple-color-picker';
|
||||
|
||||
export const ColorCell = ({
|
||||
value,
|
||||
custom,
|
||||
onValueChange,
|
||||
}: {
|
||||
value: string;
|
||||
custom?: string;
|
||||
onValueChange?: (color?: string) => void;
|
||||
}) => {
|
||||
const [inputValue, setInputValue] = useState(value);
|
||||
|
||||
const onInput = useCallback(
|
||||
(color: string) => {
|
||||
onValueChange?.(color);
|
||||
setInputValue(color);
|
||||
},
|
||||
[onValueChange]
|
||||
);
|
||||
return (
|
||||
<div className={styles.colorCell}>
|
||||
<div>
|
||||
<div data-override={!!custom} className={styles.colorCellRow}>
|
||||
<div
|
||||
className={styles.colorCellColor}
|
||||
style={{ backgroundColor: value }}
|
||||
/>
|
||||
<div className={styles.colorCellValue}>{value}</div>
|
||||
</div>
|
||||
|
||||
<div data-empty={!custom} data-custom className={styles.colorCellRow}>
|
||||
<div
|
||||
className={styles.colorCellColor}
|
||||
style={{ backgroundColor: custom }}
|
||||
/>
|
||||
<div className={styles.colorCellValue}>{custom}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Menu
|
||||
contentOptions={{ style: { background: cssVar('white') } }}
|
||||
items={
|
||||
<ul style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<SimpleColorPicker
|
||||
value={inputValue}
|
||||
setValue={onInput}
|
||||
className={styles.colorCellInput}
|
||||
/>
|
||||
<Input
|
||||
value={inputValue}
|
||||
onChange={onInput}
|
||||
placeholder="Input color"
|
||||
/>
|
||||
{custom ? (
|
||||
<MenuItem type="danger" onClick={() => onValueChange?.()}>
|
||||
Recover
|
||||
</MenuItem>
|
||||
) : null}
|
||||
</ul>
|
||||
}
|
||||
>
|
||||
<IconButton size="14" icon={<MoreHorizontalIcon />} />
|
||||
</Menu>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Empty } from '@affine/component';
|
||||
|
||||
export const ThemeEmpty = () => {
|
||||
return (
|
||||
<div
|
||||
style={{ width: 0, flex: 1, display: 'flex', justifyContent: 'center' }}
|
||||
>
|
||||
<Empty description="Select a variable to edit" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const wrapper = style({
|
||||
position: 'relative',
|
||||
borderRadius: 8,
|
||||
overflow: 'hidden',
|
||||
border: `1px solid rgba(125,125,125, 0.3)`,
|
||||
cursor: 'pointer',
|
||||
});
|
||||
|
||||
export const input = style({
|
||||
position: 'absolute',
|
||||
pointerEvents: 'none',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
top: 0,
|
||||
left: 0,
|
||||
opacity: 0,
|
||||
});
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
import clsx from 'clsx';
|
||||
import { type HTMLAttributes, useId } from 'react';
|
||||
|
||||
import * as styles from './simple-color-picker.css';
|
||||
|
||||
export const SimpleColorPicker = ({
|
||||
value,
|
||||
setValue,
|
||||
className,
|
||||
...attrs
|
||||
}: HTMLAttributes<HTMLDivElement> & {
|
||||
value: string;
|
||||
setValue: (value: string) => void;
|
||||
}) => {
|
||||
const id = useId();
|
||||
return (
|
||||
<label htmlFor={id}>
|
||||
<div
|
||||
style={{ backgroundColor: value }}
|
||||
className={clsx(styles.wrapper, className)}
|
||||
{...attrs}
|
||||
>
|
||||
<input
|
||||
className={styles.input}
|
||||
type="color"
|
||||
name={id}
|
||||
id={id}
|
||||
value={value}
|
||||
onChange={e => setValue(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const row = style({
|
||||
background: 'rgba(125,125,125,0.1)',
|
||||
borderRadius: 4,
|
||||
fontSize: cssVar('fontXs'),
|
||||
padding: '4px 8px',
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Input } from '@affine/component';
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import * as styles from './string-cell.css';
|
||||
|
||||
export const StringCell = ({
|
||||
value,
|
||||
custom,
|
||||
onValueChange,
|
||||
}: {
|
||||
value: string;
|
||||
custom?: string;
|
||||
onValueChange?: (color?: string) => void;
|
||||
}) => {
|
||||
const [inputValue, setInputValue] = useState(custom ?? '');
|
||||
|
||||
const onInput = useCallback(
|
||||
(color: string) => {
|
||||
onValueChange?.(color || undefined);
|
||||
setInputValue(color);
|
||||
},
|
||||
[onValueChange]
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: 8, flexDirection: 'column' }}>
|
||||
<div className={styles.row}>{value}</div>
|
||||
<Input
|
||||
placeholder="Input value to override"
|
||||
style={{ width: '100%' }}
|
||||
value={inputValue}
|
||||
onChange={onInput}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
import { ArrowDownSmallIcon, PaletteIcon } from '@blocksuite/icons/rc';
|
||||
import * as Collapsible from '@radix-ui/react-collapsible';
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { type TreeNode } from '../resource';
|
||||
import * as styles from '../theme-editor.css';
|
||||
|
||||
export const ThemeTreeNode = ({
|
||||
node,
|
||||
checked,
|
||||
setActive,
|
||||
isActive,
|
||||
isCustomized,
|
||||
}: {
|
||||
node: TreeNode;
|
||||
checked?: TreeNode;
|
||||
setActive: (vs: TreeNode) => void;
|
||||
isActive?: (node: TreeNode) => boolean;
|
||||
isCustomized?: (node: TreeNode) => boolean;
|
||||
}) => {
|
||||
const isLeaf = !node.children && node.variables;
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const onClick = useCallback(() => {
|
||||
if (isLeaf || node.variables?.length) setActive(node);
|
||||
if (node.children) setOpen(prev => !prev);
|
||||
}, [isLeaf, node, setActive]);
|
||||
|
||||
return (
|
||||
<Collapsible.Root open={open}>
|
||||
<div
|
||||
data-checked={node === checked}
|
||||
data-active={isActive?.(node)}
|
||||
data-customized={isCustomized?.(node)}
|
||||
className={styles.treeNode}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className={styles.treeNodeIconWrapper}>
|
||||
{isLeaf ? (
|
||||
<PaletteIcon width={16} height={16} />
|
||||
) : (
|
||||
<ArrowDownSmallIcon
|
||||
data-open={open}
|
||||
className={styles.treeNodeCollapseIcon}
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<span>{node.label}</span>
|
||||
</div>
|
||||
<Collapsible.Content className={styles.treeNodeContent}>
|
||||
{node.children?.map(child => (
|
||||
<ThemeTreeNode
|
||||
key={child.id}
|
||||
node={child}
|
||||
checked={checked}
|
||||
isActive={isActive}
|
||||
setActive={setActive}
|
||||
isCustomized={isCustomized}
|
||||
/>
|
||||
))}
|
||||
</Collapsible.Content>
|
||||
</Collapsible.Root>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Scrollable } from '@affine/component';
|
||||
import { useLiveData, useService } from '@toeverything/infra';
|
||||
|
||||
import { ThemeEditorService } from '../../services/theme-editor';
|
||||
import type { TreeNode } from '../resource';
|
||||
import * as styles from '../theme-editor.css';
|
||||
import { isColor } from '../utils';
|
||||
import { ColorCell } from './color-cell';
|
||||
import { StringCell } from './string-cell';
|
||||
|
||||
export const VariableList = ({ node }: { node: TreeNode }) => {
|
||||
const themeEditor = useService(ThemeEditorService);
|
||||
const customTheme = useLiveData(themeEditor.customTheme$);
|
||||
|
||||
const variables = node.variables ?? [];
|
||||
|
||||
return (
|
||||
<main className={styles.content}>
|
||||
<header>
|
||||
<ul className={styles.row}>
|
||||
<li>Name</li>
|
||||
<li>Light</li>
|
||||
<li>Dark</li>
|
||||
</ul>
|
||||
</header>
|
||||
<Scrollable.Root className={styles.mainScrollable}>
|
||||
<Scrollable.Viewport className={styles.mainViewport}>
|
||||
{variables.map(variable => (
|
||||
<ul className={styles.row} key={variable.variableName}>
|
||||
<li
|
||||
style={{
|
||||
textDecoration:
|
||||
customTheme?.light?.[variable.variableName] ||
|
||||
customTheme?.dark?.[variable.variableName]
|
||||
? 'underline'
|
||||
: 'none',
|
||||
}}
|
||||
>
|
||||
{variable.name}
|
||||
</li>
|
||||
{(['light', 'dark'] as const).map(mode => {
|
||||
const Renderer = isColor(variable[mode])
|
||||
? ColorCell
|
||||
: StringCell;
|
||||
return (
|
||||
<li key={mode}>
|
||||
<Renderer
|
||||
value={variable[mode]}
|
||||
custom={customTheme?.[mode]?.[variable.variableName]}
|
||||
onValueChange={color =>
|
||||
themeEditor.updateCustomTheme(
|
||||
mode,
|
||||
variable.variableName,
|
||||
color
|
||||
)
|
||||
}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
))}
|
||||
</Scrollable.Viewport>
|
||||
<Scrollable.Scrollbar />
|
||||
</Scrollable.Root>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useService } from '@toeverything/infra';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { ThemeEditorService } from '../services/theme-editor';
|
||||
|
||||
let _provided = false;
|
||||
|
||||
export const useCustomTheme = (target: HTMLElement) => {
|
||||
const themeEditor = useService(ThemeEditorService);
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
useEffect(() => {
|
||||
if (!runtimeConfig.enableThemeEditor) return;
|
||||
if (_provided) return;
|
||||
|
||||
_provided = true;
|
||||
|
||||
const sub = themeEditor.customTheme$.subscribe(themeObj => {
|
||||
if (!themeObj) return;
|
||||
|
||||
const mode = resolvedTheme === 'dark' ? 'dark' : 'light';
|
||||
const valueMap = themeObj[mode];
|
||||
|
||||
// remove previous style
|
||||
target.style.cssText = '';
|
||||
|
||||
Object.entries(valueMap).forEach(([key, value]) => {
|
||||
value && target.style.setProperty(key, value);
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
_provided = false;
|
||||
sub.unsubscribe();
|
||||
};
|
||||
}, [resolvedTheme, target.style, themeEditor.customTheme$]);
|
||||
};
|
||||
|
||||
export const CustomThemeModifier = () => {
|
||||
useCustomTheme(document.documentElement);
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -0,0 +1,122 @@
|
||||
import { darkCssVariables, lightCssVariables } from '@toeverything/theme';
|
||||
import {
|
||||
darkCssVariablesV2,
|
||||
lightCssVariablesV2,
|
||||
} from '@toeverything/theme/v2';
|
||||
|
||||
import { partsToVariableName, variableNameToParts } from './utils';
|
||||
|
||||
export type Variable = {
|
||||
id: string;
|
||||
name: string;
|
||||
variableName: string;
|
||||
light: string;
|
||||
dark: string;
|
||||
ancestors: string[];
|
||||
};
|
||||
|
||||
export interface TreeNode {
|
||||
id: string;
|
||||
label: string;
|
||||
parentId?: string;
|
||||
children?: TreeNode[];
|
||||
variables?: Variable[];
|
||||
}
|
||||
export interface ThemeInfo {
|
||||
tree: TreeNode[];
|
||||
nodeMap: Map<string, TreeNode>;
|
||||
variableMap: Map<string, Variable>;
|
||||
}
|
||||
|
||||
const sortTree = (tree: TreeNode[]) => {
|
||||
const compare = (a: TreeNode, b: TreeNode) => {
|
||||
if (a.children && !b.children) return -1;
|
||||
if (!a.children && b.children) return 1;
|
||||
return a.label.localeCompare(b.label);
|
||||
};
|
||||
const walk = (node: TreeNode) => {
|
||||
node.children?.sort(compare);
|
||||
node.children?.forEach(walk);
|
||||
};
|
||||
|
||||
tree.sort(compare).forEach(walk);
|
||||
return tree;
|
||||
};
|
||||
|
||||
const getTree = (
|
||||
light: Record<string, string>,
|
||||
dark: Record<string, string>
|
||||
): ThemeInfo => {
|
||||
const lightKeys = Object.keys(light);
|
||||
const darkKeys = Object.keys(dark);
|
||||
const allKeys = Array.from(new Set([...lightKeys, ...darkKeys])).map(name =>
|
||||
variableNameToParts(name)
|
||||
);
|
||||
const rootNodesSet = new Set<TreeNode>();
|
||||
const nodeMap = new Map<string, TreeNode>();
|
||||
const variableMap = new Map<string, Variable>();
|
||||
|
||||
allKeys.forEach(parts => {
|
||||
let id = '';
|
||||
let node: TreeNode | undefined;
|
||||
const ancestors: string[] = [];
|
||||
|
||||
parts.slice(0, -1).forEach((part, index) => {
|
||||
const isLeaf = index === parts.length - 2;
|
||||
const parentId = id ? id : undefined;
|
||||
id += `/${part}`;
|
||||
ancestors.push(id);
|
||||
if (!nodeMap.has(id)) {
|
||||
nodeMap.set(id, {
|
||||
id,
|
||||
parentId,
|
||||
label: part,
|
||||
children: isLeaf ? undefined : [],
|
||||
variables: isLeaf ? [] : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
node = nodeMap.get(id);
|
||||
|
||||
if (!node) return; // should never reach
|
||||
|
||||
if (parentId) {
|
||||
const parent = nodeMap.get(parentId);
|
||||
if (!parent) return; // should never reach
|
||||
if (parent.children?.includes(node)) return;
|
||||
parent.children?.push(node);
|
||||
}
|
||||
|
||||
if (index === 0) rootNodesSet.add(node);
|
||||
});
|
||||
|
||||
if (node) {
|
||||
const variableName = partsToVariableName(parts);
|
||||
// for the case that a node should have both children & variables
|
||||
if (!node.variables) {
|
||||
node.variables = [];
|
||||
}
|
||||
const variable = {
|
||||
id: variableName,
|
||||
name: parts[parts.length - 1],
|
||||
variableName,
|
||||
light: light[variableName],
|
||||
dark: dark[variableName],
|
||||
ancestors,
|
||||
};
|
||||
node.variables.push(variable);
|
||||
variableMap.set(variableName, variable);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
tree: sortTree(Array.from(rootNodesSet)),
|
||||
nodeMap,
|
||||
variableMap,
|
||||
};
|
||||
};
|
||||
|
||||
export const affineThemes = {
|
||||
v1: getTree(lightCssVariables, darkCssVariables),
|
||||
v2: getTree(lightCssVariablesV2, darkCssVariablesV2),
|
||||
};
|
||||
@@ -0,0 +1,183 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { globalStyle, style } from '@vanilla-extract/css';
|
||||
|
||||
export const root = style({
|
||||
width: '100vw',
|
||||
height: '100vh',
|
||||
display: 'flex',
|
||||
background: cssVarV2('layer/background/primary'),
|
||||
color: cssVar('textPrimaryColor'),
|
||||
});
|
||||
globalStyle(`${root} *`, {
|
||||
boxSizing: 'border-box',
|
||||
});
|
||||
|
||||
export const sidebar = style({
|
||||
flexShrink: 0,
|
||||
width: 240,
|
||||
borderRight: `1px solid ${cssVarV2('layer/border')}`,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
userSelect: 'none',
|
||||
});
|
||||
export const content = style({
|
||||
width: 0,
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
});
|
||||
|
||||
export const sidebarHeader = style({
|
||||
padding: '8px 48px',
|
||||
background: cssVarV2('layer/background/primary'),
|
||||
borderBottom: `1px solid ${cssVarV2('layer/border')}`,
|
||||
});
|
||||
export const sidebarScrollable = style({
|
||||
height: 0,
|
||||
flex: 1,
|
||||
padding: '8px 8px 0px 8px',
|
||||
});
|
||||
|
||||
export const mainHeader = style({});
|
||||
export const mainScrollable = style({
|
||||
height: 0,
|
||||
flex: 1,
|
||||
});
|
||||
export const mainViewport = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
});
|
||||
export const row = style({
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0,
|
||||
|
||||
selectors: {
|
||||
'header &': {
|
||||
fontWeight: 500,
|
||||
fontSize: cssVar('fontSm'),
|
||||
lineHeight: '22px',
|
||||
padding: '9px 0',
|
||||
borderBottom: `1px solid ${cssVarV2('layer/border')}`,
|
||||
},
|
||||
[`${mainViewport} &`]: {
|
||||
padding: '4px 0',
|
||||
},
|
||||
[`${mainViewport} &:not(:last-child)`]: {
|
||||
borderBottom: `0.5px solid ${cssVarV2('layer/border')}`,
|
||||
},
|
||||
},
|
||||
});
|
||||
globalStyle(`${row} > li`, {
|
||||
width: 0,
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '4px 8px',
|
||||
});
|
||||
|
||||
export const treeNode = style({
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
padding: '8px 16px',
|
||||
borderRadius: 8,
|
||||
color: cssVar('textPrimaryColor'),
|
||||
cursor: 'pointer',
|
||||
selectors: {
|
||||
'&[data-active="true"]': {
|
||||
color: cssVar('brandColor'),
|
||||
},
|
||||
'&[data-checked="true"], &:hover': {
|
||||
background: cssVarV2('layer/background/hoverOverlay'),
|
||||
},
|
||||
'&[data-customized="true"]': {
|
||||
textDecoration: 'underline',
|
||||
},
|
||||
},
|
||||
});
|
||||
export const treeNodeContent = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 2,
|
||||
paddingLeft: 32,
|
||||
paddingTop: 4,
|
||||
paddingBottom: 4,
|
||||
});
|
||||
export const treeNodeIconWrapper = style({
|
||||
color: cssVarV2('icon/secondary'),
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: 20,
|
||||
height: 20,
|
||||
});
|
||||
export const treeNodeCollapseIcon = style({
|
||||
transition: 'transform 0.2s',
|
||||
transform: 'rotate(-90deg)',
|
||||
selectors: {
|
||||
'&[data-open="true"]': {
|
||||
transform: 'rotate(0deg)',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const colorCell = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
fontSize: cssVar('fontXs'),
|
||||
width: 220,
|
||||
});
|
||||
export const colorCellRow = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
position: 'relative',
|
||||
minHeight: 23,
|
||||
selectors: {
|
||||
'&[data-empty="true"]': {
|
||||
display: 'none',
|
||||
},
|
||||
'&[data-override="true"]': {
|
||||
opacity: 0.1,
|
||||
textDecoration: 'line-through',
|
||||
},
|
||||
},
|
||||
});
|
||||
export const colorCellColor = style({
|
||||
flexShrink: 0,
|
||||
width: 16,
|
||||
height: 16,
|
||||
borderRadius: 4,
|
||||
position: 'relative',
|
||||
':before': {
|
||||
width: 16,
|
||||
height: 16,
|
||||
position: 'absolute',
|
||||
content: '""',
|
||||
borderRadius: 'inherit',
|
||||
left: 0,
|
||||
top: 0,
|
||||
border: `1px solid rgba(0,0,0,0.1)`,
|
||||
boxSizing: 'border-box',
|
||||
},
|
||||
selectors: {
|
||||
[`${colorCellRow}[data-custom] &`]: {
|
||||
borderRadius: '50%',
|
||||
},
|
||||
},
|
||||
});
|
||||
export const colorCellValue = style({
|
||||
padding: '4px 8px',
|
||||
borderRadius: 4,
|
||||
background: 'rgba(125,125,125,0.1)',
|
||||
});
|
||||
export const colorCellInput = style({
|
||||
width: '100%',
|
||||
height: 32,
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import { RadioGroup, Scrollable } from '@affine/component';
|
||||
import { useService } from '@toeverything/infra';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { ThemeEditorService } from '../services/theme-editor';
|
||||
import { ThemeEmpty } from './components/empty';
|
||||
import { ThemeTreeNode } from './components/tree-node';
|
||||
import { VariableList } from './components/variable-list';
|
||||
import { affineThemes, type TreeNode } from './resource';
|
||||
import * as styles from './theme-editor.css';
|
||||
|
||||
export const ThemeEditor = () => {
|
||||
const themeEditor = useService(ThemeEditorService);
|
||||
const [version, setVersion] = useState<'v1' | 'v2'>('v1');
|
||||
const [activeNode, setActiveNode] = useState<TreeNode | null>();
|
||||
|
||||
const { nodeMap, variableMap, tree } = affineThemes[version];
|
||||
|
||||
const [customizedNodeIds, setCustomizedNodeIds] = useState<Set<string>>(
|
||||
new Set()
|
||||
);
|
||||
|
||||
// workaround for the performance issue of using `useLiveData(themeEditor.customTheme$)` here
|
||||
useEffect(() => {
|
||||
const sub = themeEditor.customTheme$.subscribe(customTheme => {
|
||||
const ids = Array.from(
|
||||
new Set([
|
||||
...Object.keys(customTheme?.light ?? {}),
|
||||
...Object.keys(customTheme?.dark ?? {}),
|
||||
])
|
||||
).reduce((acc, name) => {
|
||||
const variable = variableMap.get(name);
|
||||
if (!variable) return acc;
|
||||
variable.ancestors.forEach(id => acc.add(id));
|
||||
return acc;
|
||||
}, new Set<string>());
|
||||
|
||||
setCustomizedNodeIds(prev => {
|
||||
const isSame =
|
||||
Array.from(ids).every(id => prev.has(id)) &&
|
||||
Array.from(prev).every(id => ids.has(id));
|
||||
return isSame ? prev : ids;
|
||||
});
|
||||
});
|
||||
return () => sub.unsubscribe();
|
||||
}, [themeEditor.customTheme$, variableMap]);
|
||||
|
||||
const onToggleVersion = useCallback((v: 'v1' | 'v2') => {
|
||||
setVersion(v);
|
||||
setActiveNode(null);
|
||||
}, []);
|
||||
|
||||
const isActive = useCallback(
|
||||
(node: TreeNode) => {
|
||||
let pointer = activeNode;
|
||||
while (pointer) {
|
||||
if (!pointer) return false;
|
||||
if (pointer === node) return true;
|
||||
pointer = pointer.parentId ? nodeMap.get(pointer.parentId) : undefined;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
[activeNode, nodeMap]
|
||||
);
|
||||
|
||||
const isCustomized = useCallback(
|
||||
(node: TreeNode) => customizedNodeIds.has(node.id),
|
||||
[customizedNodeIds]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<div className={styles.sidebar}>
|
||||
<header className={styles.sidebarHeader}>
|
||||
<RadioGroup
|
||||
width="100%"
|
||||
value={version}
|
||||
onChange={onToggleVersion}
|
||||
items={['v1', 'v2']}
|
||||
/>
|
||||
</header>
|
||||
<Scrollable.Root className={styles.sidebarScrollable} key={version}>
|
||||
<Scrollable.Viewport>
|
||||
{tree.map(node => (
|
||||
<ThemeTreeNode
|
||||
key={node.id}
|
||||
node={node}
|
||||
checked={activeNode ?? undefined}
|
||||
setActive={setActiveNode}
|
||||
isActive={isActive}
|
||||
isCustomized={isCustomized}
|
||||
/>
|
||||
))}
|
||||
</Scrollable.Viewport>
|
||||
<Scrollable.Scrollbar />
|
||||
</Scrollable.Root>
|
||||
</div>
|
||||
{activeNode ? <VariableList node={activeNode} /> : <ThemeEmpty />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
export const variableNameToParts = (name: string) => name.slice(9).split('-');
|
||||
|
||||
export const partsToVariableName = (parts: string[]) =>
|
||||
`--affine-${parts.join('-')}`;
|
||||
|
||||
export const isColor = (value: string) => {
|
||||
return value.startsWith('#') || value.startsWith('rgb');
|
||||
};
|
||||
Reference in New Issue
Block a user