From 6228b2727103efb10bc2f381a74212cde5c6d252 Mon Sep 17 00:00:00 2001 From: CatsJuice Date: Mon, 12 Aug 2024 04:12:51 +0000 Subject: [PATCH] feat(core): new theme editor poc (#7810) --- packages/common/env/src/global.ts | 1 + .../general-setting/appearance/index.tsx | 2 + .../appearance/theme-editor-setting.tsx | 49 +++++ packages/frontend/core/src/modules/index.ts | 2 + .../core/src/modules/theme-editor/index.ts | 11 ++ .../theme-editor/services/theme-editor.ts | 65 +++++++ .../core/src/modules/theme-editor/types.ts | 4 + .../views/components/color-cell.tsx | 73 +++++++ .../theme-editor/views/components/empty.tsx | 11 ++ .../components/simple-color-picker.css.ts | 19 ++ .../views/components/simple-color-picker.tsx | 34 ++++ .../views/components/string-cell.css.ts | 9 + .../views/components/string-cell.tsx | 36 ++++ .../views/components/tree-node.tsx | 66 +++++++ .../views/components/variable-list.tsx | 68 +++++++ .../theme-editor/views/custom-theme.tsx | 44 +++++ .../modules/theme-editor/views/resource.ts | 122 ++++++++++++ .../theme-editor/views/theme-editor.css.ts | 183 ++++++++++++++++++ .../theme-editor/views/theme-editor.tsx | 101 ++++++++++ .../src/modules/theme-editor/views/utils.ts | 8 + .../frontend/core/src/pages/theme-editor.tsx | 5 + packages/frontend/core/src/router.tsx | 4 + packages/frontend/electron/renderer/app.tsx | 2 + packages/frontend/electron/renderer/index.tsx | 5 +- .../frontend/electron/src/main/constants.ts | 1 + .../frontend/electron/src/main/ui/handlers.ts | 6 + .../windows-manager/custom-theme-window.ts | 71 +++++++ .../src/main/windows-manager/tab-views.ts | 26 ++- packages/frontend/web/src/app.tsx | 2 + tools/cli/src/webpack/runtime-config.ts | 2 + 30 files changed, 1025 insertions(+), 7 deletions(-) create mode 100644 packages/frontend/core/src/components/affine/setting-modal/general-setting/appearance/theme-editor-setting.tsx create mode 100644 packages/frontend/core/src/modules/theme-editor/index.ts create mode 100644 packages/frontend/core/src/modules/theme-editor/services/theme-editor.ts create mode 100644 packages/frontend/core/src/modules/theme-editor/types.ts create mode 100644 packages/frontend/core/src/modules/theme-editor/views/components/color-cell.tsx create mode 100644 packages/frontend/core/src/modules/theme-editor/views/components/empty.tsx create mode 100644 packages/frontend/core/src/modules/theme-editor/views/components/simple-color-picker.css.ts create mode 100644 packages/frontend/core/src/modules/theme-editor/views/components/simple-color-picker.tsx create mode 100644 packages/frontend/core/src/modules/theme-editor/views/components/string-cell.css.ts create mode 100644 packages/frontend/core/src/modules/theme-editor/views/components/string-cell.tsx create mode 100644 packages/frontend/core/src/modules/theme-editor/views/components/tree-node.tsx create mode 100644 packages/frontend/core/src/modules/theme-editor/views/components/variable-list.tsx create mode 100644 packages/frontend/core/src/modules/theme-editor/views/custom-theme.tsx create mode 100644 packages/frontend/core/src/modules/theme-editor/views/resource.ts create mode 100644 packages/frontend/core/src/modules/theme-editor/views/theme-editor.css.ts create mode 100644 packages/frontend/core/src/modules/theme-editor/views/theme-editor.tsx create mode 100644 packages/frontend/core/src/modules/theme-editor/views/utils.ts create mode 100644 packages/frontend/core/src/pages/theme-editor.tsx create mode 100644 packages/frontend/electron/src/main/windows-manager/custom-theme-window.ts diff --git a/packages/common/env/src/global.ts b/packages/common/env/src/global.ts index 5c4fb81996..349a80a5f1 100644 --- a/packages/common/env/src/global.ts +++ b/packages/common/env/src/global.ts @@ -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; diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/appearance/index.tsx b/packages/frontend/core/src/components/affine/setting-modal/general-setting/appearance/index.tsx index 8b17489a84..f609d6a8e6 100644 --- a/packages/frontend/core/src/components/affine/setting-modal/general-setting/appearance/index.tsx +++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/appearance/index.tsx @@ -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 = () => { /> ) : null} + {runtimeConfig.enableThemeEditor ? : null} {runtimeConfig.enableNewSettingUnstableApi ? ( diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/appearance/theme-editor-setting.tsx b/packages/frontend/core/src/components/affine/setting-modal/general-setting/appearance/theme-editor-setting.tsx new file mode 100644 index 0000000000..387ab2f1d8 --- /dev/null +++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/appearance/theme-editor-setting.tsx @@ -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 ( + +
+ {modified ? ( + + ) : null} + +
+
+ ); +}; diff --git a/packages/frontend/core/src/modules/index.ts b/packages/frontend/core/src/modules/index.ts index 2dbd83e33f..351f34093a 100644 --- a/packages/frontend/core/src/modules/index.ts +++ b/packages/frontend/core/src/modules/index.ts @@ -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); } diff --git a/packages/frontend/core/src/modules/theme-editor/index.ts b/packages/frontend/core/src/modules/theme-editor/index.ts new file mode 100644 index 0000000000..07fd082ca0 --- /dev/null +++ b/packages/frontend/core/src/modules/theme-editor/index.ts @@ -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]); +} diff --git a/packages/frontend/core/src/modules/theme-editor/services/theme-editor.ts b/packages/frontend/core/src/modules/theme-editor/services/theme-editor.ts new file mode 100644 index 0000000000..8d5683693b --- /dev/null +++ b/packages/frontend/core/src/modules/theme-editor/services/theme-editor.ts @@ -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( + this.globalState.watch(this._key).pipe( + map(value => { + if (!value) return { light: {}, dark: {} }; + if (!value.light) value.light = {}; + if (!value.dark) value.dark = {}; + const removeEmpty = (obj: Record) => + 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) => + 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); + } +} diff --git a/packages/frontend/core/src/modules/theme-editor/types.ts b/packages/frontend/core/src/modules/theme-editor/types.ts new file mode 100644 index 0000000000..dcb46282d8 --- /dev/null +++ b/packages/frontend/core/src/modules/theme-editor/types.ts @@ -0,0 +1,4 @@ +export type CustomTheme = { + light: Record; + dark: Record; +}; diff --git a/packages/frontend/core/src/modules/theme-editor/views/components/color-cell.tsx b/packages/frontend/core/src/modules/theme-editor/views/components/color-cell.tsx new file mode 100644 index 0000000000..025ae3c20d --- /dev/null +++ b/packages/frontend/core/src/modules/theme-editor/views/components/color-cell.tsx @@ -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 ( +
+
+
+
+
{value}
+
+ +
+
+
{custom}
+
+
+ + + + + {custom ? ( + onValueChange?.()}> + Recover + + ) : null} + + } + > + } /> + +
+ ); +}; diff --git a/packages/frontend/core/src/modules/theme-editor/views/components/empty.tsx b/packages/frontend/core/src/modules/theme-editor/views/components/empty.tsx new file mode 100644 index 0000000000..e3d6ae5dcd --- /dev/null +++ b/packages/frontend/core/src/modules/theme-editor/views/components/empty.tsx @@ -0,0 +1,11 @@ +import { Empty } from '@affine/component'; + +export const ThemeEmpty = () => { + return ( +
+ +
+ ); +}; diff --git a/packages/frontend/core/src/modules/theme-editor/views/components/simple-color-picker.css.ts b/packages/frontend/core/src/modules/theme-editor/views/components/simple-color-picker.css.ts new file mode 100644 index 0000000000..04a31ec5df --- /dev/null +++ b/packages/frontend/core/src/modules/theme-editor/views/components/simple-color-picker.css.ts @@ -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, +}); diff --git a/packages/frontend/core/src/modules/theme-editor/views/components/simple-color-picker.tsx b/packages/frontend/core/src/modules/theme-editor/views/components/simple-color-picker.tsx new file mode 100644 index 0000000000..ea256d69a5 --- /dev/null +++ b/packages/frontend/core/src/modules/theme-editor/views/components/simple-color-picker.tsx @@ -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 & { + value: string; + setValue: (value: string) => void; +}) => { + const id = useId(); + return ( + + ); +}; diff --git a/packages/frontend/core/src/modules/theme-editor/views/components/string-cell.css.ts b/packages/frontend/core/src/modules/theme-editor/views/components/string-cell.css.ts new file mode 100644 index 0000000000..b1254e0ca4 --- /dev/null +++ b/packages/frontend/core/src/modules/theme-editor/views/components/string-cell.css.ts @@ -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', +}); diff --git a/packages/frontend/core/src/modules/theme-editor/views/components/string-cell.tsx b/packages/frontend/core/src/modules/theme-editor/views/components/string-cell.tsx new file mode 100644 index 0000000000..0a7ed560e6 --- /dev/null +++ b/packages/frontend/core/src/modules/theme-editor/views/components/string-cell.tsx @@ -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 ( +
+
{value}
+ +
+ ); +}; diff --git a/packages/frontend/core/src/modules/theme-editor/views/components/tree-node.tsx b/packages/frontend/core/src/modules/theme-editor/views/components/tree-node.tsx new file mode 100644 index 0000000000..fd5cf4b14e --- /dev/null +++ b/packages/frontend/core/src/modules/theme-editor/views/components/tree-node.tsx @@ -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 ( + +
+
+ {isLeaf ? ( + + ) : ( + + )} +
+ {node.label} +
+ + {node.children?.map(child => ( + + ))} + +
+ ); +}; diff --git a/packages/frontend/core/src/modules/theme-editor/views/components/variable-list.tsx b/packages/frontend/core/src/modules/theme-editor/views/components/variable-list.tsx new file mode 100644 index 0000000000..5d70686d81 --- /dev/null +++ b/packages/frontend/core/src/modules/theme-editor/views/components/variable-list.tsx @@ -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 ( +
+
+
    +
  • Name
  • +
  • Light
  • +
  • Dark
  • +
+
+ + + {variables.map(variable => ( +
    +
  • + {variable.name} +
  • + {(['light', 'dark'] as const).map(mode => { + const Renderer = isColor(variable[mode]) + ? ColorCell + : StringCell; + return ( +
  • + + themeEditor.updateCustomTheme( + mode, + variable.variableName, + color + ) + } + /> +
  • + ); + })} +
+ ))} +
+ +
+
+ ); +}; diff --git a/packages/frontend/core/src/modules/theme-editor/views/custom-theme.tsx b/packages/frontend/core/src/modules/theme-editor/views/custom-theme.tsx new file mode 100644 index 0000000000..b981d85ee3 --- /dev/null +++ b/packages/frontend/core/src/modules/theme-editor/views/custom-theme.tsx @@ -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; +}; diff --git a/packages/frontend/core/src/modules/theme-editor/views/resource.ts b/packages/frontend/core/src/modules/theme-editor/views/resource.ts new file mode 100644 index 0000000000..1006840516 --- /dev/null +++ b/packages/frontend/core/src/modules/theme-editor/views/resource.ts @@ -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; + variableMap: Map; +} + +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, + dark: Record +): 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(); + const nodeMap = new Map(); + const variableMap = new Map(); + + 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), +}; diff --git a/packages/frontend/core/src/modules/theme-editor/views/theme-editor.css.ts b/packages/frontend/core/src/modules/theme-editor/views/theme-editor.css.ts new file mode 100644 index 0000000000..070d49fc82 --- /dev/null +++ b/packages/frontend/core/src/modules/theme-editor/views/theme-editor.css.ts @@ -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, +}); diff --git a/packages/frontend/core/src/modules/theme-editor/views/theme-editor.tsx b/packages/frontend/core/src/modules/theme-editor/views/theme-editor.tsx new file mode 100644 index 0000000000..f805fcf779 --- /dev/null +++ b/packages/frontend/core/src/modules/theme-editor/views/theme-editor.tsx @@ -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(); + + const { nodeMap, variableMap, tree } = affineThemes[version]; + + const [customizedNodeIds, setCustomizedNodeIds] = useState>( + 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()); + + 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 ( +
+
+
+ +
+ + + {tree.map(node => ( + + ))} + + + +
+ {activeNode ? : } +
+ ); +}; diff --git a/packages/frontend/core/src/modules/theme-editor/views/utils.ts b/packages/frontend/core/src/modules/theme-editor/views/utils.ts new file mode 100644 index 0000000000..368d85a32e --- /dev/null +++ b/packages/frontend/core/src/modules/theme-editor/views/utils.ts @@ -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'); +}; diff --git a/packages/frontend/core/src/pages/theme-editor.tsx b/packages/frontend/core/src/pages/theme-editor.tsx new file mode 100644 index 0000000000..e3044569b2 --- /dev/null +++ b/packages/frontend/core/src/pages/theme-editor.tsx @@ -0,0 +1,5 @@ +import { ThemeEditor } from '../modules/theme-editor'; + +export const Component = () => { + return ; +}; diff --git a/packages/frontend/core/src/router.tsx b/packages/frontend/core/src/router.tsx index d2a05d68da..a4a1607258 100644 --- a/packages/frontend/core/src/router.tsx +++ b/packages/frontend/core/src/router.tsx @@ -108,6 +108,10 @@ export const topLevelRoutes = [ ); }, }, + { + path: '/theme-editor', + lazy: () => import('./pages/theme-editor'), + }, { path: '*', lazy: () => import('./pages/404'), diff --git a/packages/frontend/electron/renderer/app.tsx b/packages/frontend/electron/renderer/app.tsx index 8168b2db93..33b30f4617 100644 --- a/packages/frontend/electron/renderer/app.tsx +++ b/packages/frontend/electron/renderer/app.tsx @@ -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() { + diff --git a/packages/frontend/electron/renderer/index.tsx b/packages/frontend/electron/renderer/index.tsx index 2ca5c81f5b..11f2dc0409 100644 --- a/packages/frontend/electron/renderer/index.tsx +++ b/packages/frontend/electron/renderer/index.tsx @@ -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'); diff --git a/packages/frontend/electron/src/main/constants.ts b/packages/frontend/electron/src/main/constants.ts index 9719c8b82e..734db54f51 100644 --- a/packages/frontend/electron/src/main/constants.ts +++ b/packages/frontend/electron/src/main/constants.ts @@ -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`; diff --git a/packages/frontend/electron/src/main/ui/handlers.ts b/packages/frontend/electron/src/main/ui/handlers.ts index 1f5112b666..22ec4bee8f 100644 --- a/packages/frontend/electron/src/main/ui/handlers.ts +++ b/packages/frontend/electron/src/main/ui/handlers.ts @@ -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; diff --git a/packages/frontend/electron/src/main/windows-manager/custom-theme-window.ts b/packages/frontend/electron/src/main/windows-manager/custom-theme-window.ts new file mode 100644 index 0000000000..ebd957debb --- /dev/null +++ b/packages/frontend/electron/src/main/windows-manager/custom-theme-window.ts @@ -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 | 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; +} diff --git a/packages/frontend/electron/src/main/windows-manager/tab-views.ts b/packages/frontend/electron/src/main/windows-manager/tab-views.ts index 49a2921cb7..b7a6f6fa3c 100644 --- a/packages/frontend/electron/src/main/windows-manager/tab-views.ts +++ b/packages/frontend/electron/src/main/windows-manager/tab-views.ts @@ -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) => { diff --git a/packages/frontend/web/src/app.tsx b/packages/frontend/web/src/app.tsx index 38d4b272d1..0e0896801b 100644 --- a/packages/frontend/web/src/app.tsx +++ b/packages/frontend/web/src/app.tsx @@ -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() { + diff --git a/tools/cli/src/webpack/runtime-config.ts b/tools/cli/src/webpack/runtime-config.ts index 594f479b9d..8ae58c1ba0 100644 --- a/tools/cli/src/webpack/runtime-config.ts +++ b/tools/cli/src/webpack/runtime-config.ts @@ -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, }; }, };