mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-04 08:38:34 +00:00
feat(core): new theme editor poc (#7810)
This commit is contained in:
1
packages/common/env/src/global.ts
vendored
1
packages/common/env/src/global.ts
vendored
@@ -31,6 +31,7 @@ export const runtimeFlagsSchema = z.object({
|
||||
enableExperimentalFeature: z.boolean(),
|
||||
enableInfoModal: z.boolean(),
|
||||
enableOrganize: z.boolean(),
|
||||
enableThemeEditor: z.boolean(),
|
||||
});
|
||||
|
||||
export type RuntimeConfig = z.infer<typeof runtimeFlagsSchema>;
|
||||
|
||||
@@ -15,6 +15,7 @@ import { useAppSettingHelper } from '../../../../../hooks/affine/use-app-setting
|
||||
import { LanguageMenu } from '../../../language-menu';
|
||||
import { DateFormatSetting } from './date-format-setting';
|
||||
import { settingWrapper } from './style.css';
|
||||
import { ThemeEditorSetting } from './theme-editor-setting';
|
||||
|
||||
export const ThemeSettings = () => {
|
||||
const t = useI18n();
|
||||
@@ -172,6 +173,7 @@ export const AppearanceSettings = () => {
|
||||
/>
|
||||
</SettingRow>
|
||||
) : null}
|
||||
{runtimeConfig.enableThemeEditor ? <ThemeEditorSetting /> : null}
|
||||
</SettingWrapper>
|
||||
{runtimeConfig.enableNewSettingUnstableApi ? (
|
||||
<SettingWrapper title={t['com.affine.appearanceSettings.date.title']()}>
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Button } from '@affine/component';
|
||||
import { SettingRow } from '@affine/component/setting-components';
|
||||
import { ThemeEditorService } from '@affine/core/modules/theme-editor';
|
||||
import { popupWindow } from '@affine/core/utils';
|
||||
import { apis } from '@affine/electron-api';
|
||||
import { DeleteIcon } from '@blocksuite/icons/rc';
|
||||
import { useLiveData, useService } from '@toeverything/infra';
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
export const ThemeEditorSetting = () => {
|
||||
const themeEditor = useService(ThemeEditorService);
|
||||
const modified = useLiveData(themeEditor.modified$);
|
||||
|
||||
const open = useCallback(() => {
|
||||
if (environment.isDesktop) {
|
||||
apis?.ui.openThemeEditor().catch(console.error);
|
||||
} else {
|
||||
popupWindow('/theme-editor');
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<SettingRow
|
||||
name="Customize Theme"
|
||||
desc="Edit all AFFiNE theme variables here"
|
||||
>
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
{modified ? (
|
||||
<Button
|
||||
style={{
|
||||
color: cssVar('errorColor'),
|
||||
borderColor: cssVar('errorColor'),
|
||||
}}
|
||||
prefixStyle={{
|
||||
color: cssVar('errorColor'),
|
||||
}}
|
||||
onClick={() => themeEditor.reset()}
|
||||
variant="secondary"
|
||||
prefix={<DeleteIcon />}
|
||||
>
|
||||
Reset all
|
||||
</Button>
|
||||
) : null}
|
||||
<Button onClick={open}>Open Theme Editor</Button>
|
||||
</div>
|
||||
</SettingRow>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
11
packages/frontend/core/src/modules/theme-editor/index.ts
Normal file
11
packages/frontend/core/src/modules/theme-editor/index.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
4
packages/frontend/core/src/modules/theme-editor/types.ts
Normal file
4
packages/frontend/core/src/modules/theme-editor/types.ts
Normal file
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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');
|
||||
};
|
||||
5
packages/frontend/core/src/pages/theme-editor.tsx
Normal file
5
packages/frontend/core/src/pages/theme-editor.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { ThemeEditor } from '../modules/theme-editor';
|
||||
|
||||
export const Component = () => {
|
||||
return <ThemeEditor />;
|
||||
};
|
||||
@@ -108,6 +108,10 @@ export const topLevelRoutes = [
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/theme-editor',
|
||||
lazy: () => import('./pages/theme-editor'),
|
||||
},
|
||||
{
|
||||
path: '*',
|
||||
lazy: () => import('./pages/404'),
|
||||
|
||||
@@ -8,6 +8,7 @@ import { AppFallback } from '@affine/core/components/affine/app-container';
|
||||
import { configureCommonModules } from '@affine/core/modules';
|
||||
import { configureAppTabsHeaderModule } from '@affine/core/modules/app-tabs-header';
|
||||
import { configureElectronStateStorageImpls } from '@affine/core/modules/storage';
|
||||
import { CustomThemeModifier } from '@affine/core/modules/theme-editor';
|
||||
import { configureDesktopWorkbenchModule } from '@affine/core/modules/workbench';
|
||||
import {
|
||||
configureBrowserWorkspaceFlavours,
|
||||
@@ -110,6 +111,7 @@ export function App() {
|
||||
<CacheProvider value={cache}>
|
||||
<AffineContext store={getCurrentStore()}>
|
||||
<Telemetry />
|
||||
<CustomThemeModifier />
|
||||
<DebugProvider>
|
||||
<GlobalLoading />
|
||||
<NotificationCenter />
|
||||
|
||||
@@ -33,7 +33,10 @@ function main() {
|
||||
.catch(() => console.error('failed to load app config'));
|
||||
|
||||
// skip bootstrap setup for desktop onboarding
|
||||
if (window.appInfo?.windowName === 'onboarding') {
|
||||
if (
|
||||
window.appInfo?.windowName === 'onboarding' ||
|
||||
window.appInfo?.windowName === 'theme-editor'
|
||||
) {
|
||||
performanceMainLogger.info('skip setup');
|
||||
} else {
|
||||
performanceMainLogger.info('setup start');
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export const mainWindowOrigin = process.env.DEV_SERVER_URL || 'file://.';
|
||||
export const onboardingViewUrl = `${mainWindowOrigin}${mainWindowOrigin.endsWith('/') ? '' : '/'}onboarding`;
|
||||
export const shellViewUrl = `${mainWindowOrigin}${mainWindowOrigin.endsWith('/') ? '' : '/'}shell.html`;
|
||||
export const customThemeViewUrl = `${mainWindowOrigin}${mainWindowOrigin.endsWith('/') ? '' : '/'}theme-editor`;
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
updateWorkbenchViewMeta,
|
||||
} from '../windows-manager';
|
||||
import { showTabContextMenu } from '../windows-manager/context-menu';
|
||||
import { getOrCreateCustomThemeWindow } from '../windows-manager/custom-theme-window';
|
||||
import { getChallengeResponse } from './challenge';
|
||||
import { uiSubjects } from './subject';
|
||||
|
||||
@@ -205,4 +206,9 @@ export const uiHandlers = {
|
||||
showTabContextMenu: async (_, tabKey: string, viewIndex: number) => {
|
||||
return showTabContextMenu(tabKey, viewIndex);
|
||||
},
|
||||
openThemeEditor: async () => {
|
||||
const win = await getOrCreateCustomThemeWindow();
|
||||
win.show();
|
||||
win.focus();
|
||||
},
|
||||
} satisfies NamespaceHandlers;
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { BrowserWindow, type Display, screen } from 'electron';
|
||||
|
||||
import { isMacOS } from '../../shared/utils';
|
||||
import { customThemeViewUrl } from '../constants';
|
||||
import { logger } from '../logger';
|
||||
|
||||
let customThemeWindow: Promise<BrowserWindow> | undefined;
|
||||
|
||||
const getScreenSize = (display: Display) => {
|
||||
const { width, height } = isMacOS() ? display.bounds : display.workArea;
|
||||
return { width, height };
|
||||
};
|
||||
|
||||
async function createCustomThemeWindow(additionalArguments: string[]) {
|
||||
logger.info('creating custom theme window');
|
||||
|
||||
const { width: maxWidth, height: maxHeight } = getScreenSize(
|
||||
screen.getPrimaryDisplay()
|
||||
);
|
||||
|
||||
const browserWindow = new BrowserWindow({
|
||||
width: Math.min(maxWidth, 800),
|
||||
height: Math.min(maxHeight, 600),
|
||||
resizable: true,
|
||||
maximizable: false,
|
||||
fullscreenable: false,
|
||||
webPreferences: {
|
||||
webgl: true,
|
||||
preload: join(__dirname, './preload.js'),
|
||||
additionalArguments: additionalArguments,
|
||||
},
|
||||
});
|
||||
|
||||
await browserWindow.loadURL(customThemeViewUrl);
|
||||
|
||||
browserWindow.on('closed', () => {
|
||||
customThemeWindow = undefined;
|
||||
});
|
||||
|
||||
return browserWindow;
|
||||
}
|
||||
|
||||
const getWindowAdditionalArguments = async () => {
|
||||
const { getExposedMeta } = await import('../exposed');
|
||||
const mainExposedMeta = getExposedMeta();
|
||||
return [
|
||||
`--main-exposed-meta=` + JSON.stringify(mainExposedMeta),
|
||||
`--window-name=theme-editor`,
|
||||
];
|
||||
};
|
||||
|
||||
export async function getOrCreateCustomThemeWindow() {
|
||||
const additionalArguments = await getWindowAdditionalArguments();
|
||||
if (
|
||||
!customThemeWindow ||
|
||||
(await customThemeWindow.then(w => w.isDestroyed()))
|
||||
) {
|
||||
customThemeWindow = createCustomThemeWindow(additionalArguments);
|
||||
}
|
||||
|
||||
return customThemeWindow;
|
||||
}
|
||||
|
||||
export async function getCustomThemeWindow() {
|
||||
if (!customThemeWindow) return;
|
||||
const window = await customThemeWindow;
|
||||
if (window.isDestroyed()) return;
|
||||
return window;
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import { ensureHelperProcess } from '../helper-process';
|
||||
import { logger } from '../logger';
|
||||
import { globalStateStorage } from '../shared-storage/storage';
|
||||
import { parseCookie } from '../utils';
|
||||
import { getCustomThemeWindow } from './custom-theme-window';
|
||||
import { getMainWindow, MainWindowManager } from './main-window';
|
||||
import {
|
||||
TabViewsMetaKey,
|
||||
@@ -992,12 +993,25 @@ export const onActiveTabChanged = (fn: (tabId: string) => void) => {
|
||||
};
|
||||
|
||||
export const showDevTools = (id?: string) => {
|
||||
const view = id
|
||||
? WebContentViewsManager.instance.getViewById(id)
|
||||
: WebContentViewsManager.instance.activeWorkbenchView;
|
||||
if (view) {
|
||||
view.webContents.openDevTools();
|
||||
}
|
||||
// use focusedWindow?
|
||||
// const focusedWindow = BrowserWindow.getFocusedWindow()
|
||||
|
||||
// workaround for opening devtools for theme-editor window
|
||||
// there should be some strategy like windows manager, so we can know which window is active
|
||||
getCustomThemeWindow()
|
||||
.then(w => {
|
||||
if (w && w.isFocused()) {
|
||||
w.webContents.openDevTools();
|
||||
} else {
|
||||
const view = id
|
||||
? WebContentViewsManager.instance.getViewById(id)
|
||||
: WebContentViewsManager.instance.activeWorkbenchView;
|
||||
if (view) {
|
||||
view.webContents.openDevTools();
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(console.error);
|
||||
};
|
||||
|
||||
export const pingAppLayoutReady = (wc: WebContents) => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { GlobalLoading } from '@affine/component/global-loading';
|
||||
import { AppFallback } from '@affine/core/components/affine/app-container';
|
||||
import { configureCommonModules } from '@affine/core/modules';
|
||||
import { configureLocalStorageStateStorageImpls } from '@affine/core/modules/storage';
|
||||
import { CustomThemeModifier } from '@affine/core/modules/theme-editor';
|
||||
import { configureBrowserWorkbenchModule } from '@affine/core/modules/workbench';
|
||||
import {
|
||||
configureBrowserWorkspaceFlavours,
|
||||
@@ -96,6 +97,7 @@ export function App() {
|
||||
<CacheProvider value={cache}>
|
||||
<AffineContext store={getCurrentStore()}>
|
||||
<Telemetry />
|
||||
<CustomThemeModifier />
|
||||
<DebugProvider>
|
||||
<GlobalLoading />
|
||||
<NotificationCenter />
|
||||
|
||||
@@ -27,6 +27,7 @@ export function getRuntimeConfig(buildFlags: BuildFlags): RuntimeConfig {
|
||||
// CAUTION(@forehalo): product not ready, do not enable it
|
||||
enableNewSettingUnstableApi: false,
|
||||
enableEnhanceShareMode: false,
|
||||
enableThemeEditor: false,
|
||||
};
|
||||
},
|
||||
get beta() {
|
||||
@@ -54,6 +55,7 @@ export function getRuntimeConfig(buildFlags: BuildFlags): RuntimeConfig {
|
||||
changelogUrl: 'https://github.com/toeverything/AFFiNE/releases',
|
||||
enableInfoModal: true,
|
||||
enableOrganize: true,
|
||||
enableThemeEditor: true,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user