feat(core): new theme editor poc (#7810)

This commit is contained in:
CatsJuice
2024-08-12 04:12:51 +00:00
parent 75e02bb088
commit 6228b27271
30 changed files with 1025 additions and 7 deletions

View File

@@ -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>;

View File

@@ -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']()}>

View File

@@ -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>
);
};

View File

@@ -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);
}

View 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]);
}

View File

@@ -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);
}
}

View File

@@ -0,0 +1,4 @@
export type CustomTheme = {
light: Record<string, string>;
dark: Record<string, string>;
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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,
});

View File

@@ -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>
);
};

View File

@@ -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',
});

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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;
};

View File

@@ -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),
};

View File

@@ -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,
});

View File

@@ -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>
);
};

View File

@@ -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');
};

View File

@@ -0,0 +1,5 @@
import { ThemeEditor } from '../modules/theme-editor';
export const Component = () => {
return <ThemeEditor />;
};

View File

@@ -108,6 +108,10 @@ export const topLevelRoutes = [
);
},
},
{
path: '/theme-editor',
lazy: () => import('./pages/theme-editor'),
},
{
path: '*',
lazy: () => import('./pages/404'),

View File

@@ -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 />

View File

@@ -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');

View File

@@ -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`;

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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) => {
// 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) => {

View File

@@ -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 />

View File

@@ -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,
};
},
};