ant-design/ant-design

View on GitHub
.dumi/pages/index/components/Theme/index.tsx

Summary

Maintainability
F
4 days
Test Coverage
import * as React from 'react';
import { defaultAlgorithm, defaultTheme } from '@ant-design/compatible';
import {
  BellOutlined,
  FolderOutlined,
  HomeOutlined,
  QuestionCircleOutlined,
} from '@ant-design/icons';
import { TinyColor } from '@ctrl/tinycolor';
import type { MenuProps, ThemeConfig } from 'antd';
import {
  Breadcrumb,
  Button,
  Card,
  ConfigProvider,
  Flex,
  Form,
  Layout,
  Menu,
  Radio,
  theme,
  Typography,
} from 'antd';
import { createStyles } from 'antd-style';
import type { Color } from 'antd/es/color-picker';
import { generateColor } from 'antd/es/color-picker/util';
import classNames from 'classnames';
import { useLocation } from 'dumi';

import useDark from '../../../../hooks/useDark';
import useLocale from '../../../../hooks/useLocale';
import Link from '../../../../theme/common/Link';
import SiteContext from '../../../../theme/slots/SiteContext';
import { getLocalizedPathname } from '../../../../theme/utils';
import Group from '../Group';
import { getCarouselStyle } from '../util';
import BackgroundImage from './BackgroundImage';
import ColorPicker from './ColorPicker';
import { DEFAULT_COLOR, getAvatarURL, getClosetColor, PINK_COLOR } from './colorUtil';
import MobileCarousel from './MobileCarousel';
import RadiusPicker from './RadiusPicker';
import type { THEME } from './ThemePicker';
import ThemePicker from './ThemePicker';

const { Header, Content, Sider } = Layout;

const TokenChecker: React.FC = () => {
  if (process.env.NODE_ENV !== 'production') {
    console.log('Demo Token:', theme.useToken());
  }
  return null;
};

// ============================= Theme =============================
const locales = {
  cn: {
    themeTitle: '定制主题,随心所欲',
    themeDesc: 'Ant Design 5.0 开放更多样式算法,让你定制主题更简单',

    customizeTheme: '定制主题',
    myTheme: '我的主题',
    titlePrimaryColor: '主色',
    titleBorderRadius: '圆角',
    titleCompact: '宽松度',
    default: '默认',
    compact: '紧凑',
    titleTheme: '主题',
    light: '亮色',
    dark: '暗黑',
    toDef: '深度定制',
    toUse: '去使用',
  },
  en: {
    themeTitle: 'Flexible theme customization',
    themeDesc: 'Ant Design 5.0 enable extendable algorithm, make custom theme easier',

    customizeTheme: 'Customize Theme',
    myTheme: 'My Theme',
    titlePrimaryColor: 'Primary Color',
    titleBorderRadius: 'Border Radius',
    titleCompact: 'Compact',
    titleTheme: 'Theme',
    default: 'Default',
    compact: 'Compact',
    light: 'Light',
    dark: 'Dark',
    toDef: 'More',
    toUse: 'Apply',
  },
};

// ============================= Style =============================
const useStyle = createStyles(({ token, css, cx }) => {
  const { carousel } = getCarouselStyle();
  const demo = css`
    overflow: hidden;
    background: rgba(240, 242, 245, 0.25);
    backdrop-filter: blur(50px);
    box-shadow: 0 2px 10px 2px rgba(0, 0, 0, 0.1);
    transition: all ${token.motionDurationSlow};
  `;

  return {
    demo,

    otherDemo: css`
      &.${cx(demo)} {
        backdrop-filter: blur(10px);
        background: rgba(247, 247, 247, 0.5);
      }
    `,

    darkDemo: css`
      &.${cx(demo)} {
        background: #000;
      }
    `,

    larkDemo: css`
      &.${cx(demo)} {
        // background: #f7f7f7;
        background: rgba(240, 242, 245, 0.65);
      }
    `,
    comicDemo: css`
      &.${cx(demo)} {
        // background: #ffe4e6;
        background: rgba(240, 242, 245, 0.65);
      }
    `,

    menu: css`
      margin-inline-start: auto;
    `,

    header: css`
      display: flex;
      align-items: center;
      border-bottom: 1px solid ${token.colorSplit};
      padding-inline: ${token.paddingLG}px !important;
      height: ${token.controlHeightLG * 1.2}px;
      line-height: ${token.controlHeightLG * 1.2}px;
    `,

    headerDark: css`
      border-bottom-color: rgba(255, 255, 255, 0.1);
    `,

    avatar: css`
      width: ${token.controlHeight}px;
      height: ${token.controlHeight}px;
      border-radius: 100%;
      background: rgba(240, 240, 240, 0.75);
      background-size: cover;
      box-shadow: 0 0 2px rgba(0, 0, 0, 0.2);
    `,

    avatarDark: css`
      background: rgba(200, 200, 200, 0.3);
    `,

    logo: css`
      display: flex;
      align-items: center;
      column-gap: ${token.padding}px;

      h1 {
        font-weight: 400;
        font-size: ${token.fontSizeLG}px;
        line-height: 1.5;
      }
    `,

    logoImg: css`
      width: 30px;
      height: 30px;
      overflow: hidden;

      img {
        width: 30px;
        height: 30px;
        vertical-align: top;
      }
    `,

    transBg: css`
      background: transparent !important;
    `,

    form: css`
      width: 100%;
      margin: 0 auto;
    `,
    pos: css`
      position: absolute;
    `,
    leftTopImagePos: css`
      left: 0;
      top: -100px;
      height: 500px;
    `,
    rightBottomPos: css`
      right: 0;
      bottom: -100px;
      height: 287px;
    `,
    leftTopImage: css`
      left: 50%;
      transform: translate3d(-900px, 0, 0);
      top: -100px;
      height: 500px;
    `,
    rightBottomImage: css`
      right: 50%;
      transform: translate3d(750px, 0, 0);
      bottom: -100px;
      height: 287px;
    `,
    motion: css`
      transition: all ${token.motionDurationSlow};
    `,
    op1: css`
      opacity: 1;
    `,
    op0: css`
      opacity: 0;
    `,
    carousel,
  };
});

// ========================== Menu Config ==========================
const subMenuItems = [
  {
    key: `Design Values`,
    label: `Design Values`,
  },
  {
    key: `Global Styles`,
    label: `Global Styles`,
  },
  {
    key: `Themes`,
    label: `Themes`,
  },
  {
    key: `DesignPatterns`,
    label: `Design Patterns`,
  },
];

const sideMenuItems: MenuProps['items'] = [
  {
    key: `Design`,
    label: `Design`,
    icon: <FolderOutlined />,
    children: subMenuItems,
  },
  {
    key: `Development`,
    label: `Development`,
    icon: <FolderOutlined />,
  },
];

// ============================= Theme =============================

function getTitleColor(colorPrimary: string | Color, isLight?: boolean) {
  if (!isLight) {
    return '#FFF';
  }

  const color = generateColor(colorPrimary);
  const closestColor = getClosetColor(colorPrimary);

  switch (closestColor) {
    case DEFAULT_COLOR:
    case PINK_COLOR:
    case '#F2BD27':
      return undefined;

    case '#5A54F9':
    case '#E0282E':
      return '#FFF';

    default:
      return color.toHsb().b < 0.7 ? '#FFF' : undefined;
  }
}

interface ThemeData {
  themeType: THEME;
  colorPrimary: string | Color;
  borderRadius: number;
  compact: 'default' | 'compact';
}

const ThemeDefault: ThemeData = {
  themeType: 'default',
  colorPrimary: '#1677FF',
  borderRadius: 6,
  compact: 'default',
};

const ThemesInfo: Record<THEME, Partial<ThemeData>> = {
  default: {},
  dark: {
    borderRadius: 2,
  },
  lark: {
    colorPrimary: '#00B96B',
    borderRadius: 4,
  },
  comic: {
    colorPrimary: PINK_COLOR,
    borderRadius: 16,
  },
  v4: {
    ...defaultTheme.token,
  },
};

const normalize = (value: number) => value / 255;

function rgbToColorMatrix(color: string) {
  const rgb = new TinyColor(color).toRgb();
  const { r, g, b } = rgb;

  const invertValue = normalize(r) * 100;
  const sepiaValue = 100;
  const saturateValue = Math.max(normalize(r), normalize(g), normalize(b)) * 10000;
  const hueRotateValue =
    ((Math.atan2(
      Math.sqrt(3) * (normalize(g) - normalize(b)),
      2 * normalize(r) - normalize(g) - normalize(b),
    ) *
      180) /
      Math.PI +
      360) %
    360;

  return `invert(${invertValue}%) sepia(${sepiaValue}%) saturate(${saturateValue}%) hue-rotate(${hueRotateValue}deg)`;
}

const Theme: React.FC = () => {
  const { styles } = useStyle();
  const [locale, lang] = useLocale(locales);
  const isZhCN = lang === 'cn';
  const { search } = useLocation();

  const [themeData, setThemeData] = React.useState<ThemeData>(ThemeDefault);

  const onThemeChange = (_: Partial<ThemeData>, nextThemeData: ThemeData) => {
    React.startTransition(() => {
      setThemeData({ ...ThemesInfo[nextThemeData.themeType], ...nextThemeData });
    });
  };

  const { compact, themeType, colorPrimary, ...themeToken } = themeData;
  const isLight = themeType !== 'dark';
  const [form] = Form.useForm();
  const { isMobile } = React.useContext(SiteContext);
  const colorPrimaryValue = React.useMemo(
    () => (typeof colorPrimary === 'string' ? colorPrimary : colorPrimary.toHexString()),
    [colorPrimary],
  );

  // const algorithmFn = isLight ? theme.defaultAlgorithm : theme.darkAlgorithm;
  const algorithmFn = React.useMemo(() => {
    const algorithms = [isLight ? theme.defaultAlgorithm : theme.darkAlgorithm];

    if (compact === 'compact') {
      algorithms.push(theme.compactAlgorithm);
    }

    if (themeType === 'v4') {
      algorithms.push(defaultAlgorithm);
    }

    return algorithms;
  }, [isLight, compact, themeType]);

  // ================================ Themes ================================
  React.useEffect(() => {
    const mergedData = {
      ...ThemeDefault,
      themeType,
      ...ThemesInfo[themeType],
    };

    setThemeData(mergedData);
    form.setFieldsValue(mergedData);
  }, [themeType]);

  const isRootDark = useDark();

  React.useEffect(() => {
    onThemeChange({}, { ...themeData, themeType: isRootDark ? 'dark' : 'default' });
  }, [isRootDark]);

  // ================================ Tokens ================================
  const closestColor = getClosetColor(colorPrimaryValue);

  const [backgroundColor, avatarColor] = React.useMemo(() => {
    let bgColor = 'transparent';

    const mapToken = theme.defaultAlgorithm({
      ...theme.defaultConfig.token,
      colorPrimary: colorPrimaryValue,
    });

    if (themeType === 'dark') {
      bgColor = '#393F4A';
    } else if (closestColor === DEFAULT_COLOR) {
      bgColor = '#F5F8FF';
    } else {
      bgColor = mapToken.colorPrimaryHover;
    }

    return [bgColor, mapToken.colorPrimaryBgHover];
  }, [themeType, closestColor, colorPrimaryValue]);

  const logoColor = React.useMemo(() => {
    const hsb = generateColor(colorPrimaryValue).toHsb();
    hsb.b = Math.min(hsb.b, 0.7);
    return generateColor(hsb).toHexString();
  }, [colorPrimaryValue]);

  const memoTheme = React.useMemo<ThemeConfig>(
    () => ({
      token: { ...themeToken, colorPrimary: colorPrimaryValue },
      algorithm: algorithmFn,
      components: {
        Layout: isLight
          ? {
              headerBg: 'transparent',
              bodyBg: 'transparent',
            }
          : {},
        Menu: isLight
          ? {
              itemBg: 'transparent',
              subMenuItemBg: 'transparent',
              activeBarBorderWidth: 0,
            }
          : {},
        ...(themeType === 'v4' ? defaultTheme.components : {}),
      },
    }),
    [themeToken, colorPrimaryValue, algorithmFn, themeType],
  );

  // ================================ Render ================================
  const themeNode = (
    <ConfigProvider theme={memoTheme}>
      <TokenChecker />
      <div
        className={classNames(styles.demo, {
          [styles.otherDemo]: isLight && closestColor !== DEFAULT_COLOR && styles.otherDemo,
          [styles.darkDemo]: !isLight,
        })}
        style={{ borderRadius: themeData.borderRadius }}
      >
        <Layout className={styles.transBg}>
          <Header
            className={classNames(styles.header, styles.transBg, !isLight && styles.headerDark)}
          >
            {/* Logo */}
            <div className={styles.logo}>
              <div className={styles.logoImg}>
                <img
                  src="https://gw.alipayobjects.com/zos/rmsportal/KDpgvguMpGfqaHPjicRK.svg"
                  style={{
                    filter:
                      closestColor === DEFAULT_COLOR ? undefined : rgbToColorMatrix(logoColor),
                  }}
                  alt=""
                />
              </div>
              <h1>Ant Design 5.0</h1>
            </div>
            <Flex className={styles.menu} gap="middle">
              <BellOutlined />
              <QuestionCircleOutlined />
              <div
                className={classNames(styles.avatar, { [styles.avatarDark]: themeType === 'dark' })}
                style={{
                  backgroundColor: avatarColor,
                  backgroundImage: `url(${getAvatarURL(closestColor)})`,
                }}
              />
            </Flex>
          </Header>
          <Layout className={styles.transBg} hasSider>
            <Sider className={classNames(styles.transBg)} width={200}>
              <Menu
                mode="inline"
                className={classNames(styles.transBg)}
                selectedKeys={['Themes']}
                openKeys={['Design']}
                style={{ height: '100%', borderRight: 0 }}
                items={sideMenuItems}
                expandIcon={false}
              />
            </Sider>
            <Layout className={styles.transBg} style={{ padding: '0 24px 24px' }}>
              <Breadcrumb
                style={{ margin: '16px 0' }}
                items={[
                  { title: <HomeOutlined /> },
                  { title: 'Design', menu: { items: subMenuItems } },
                  { title: 'Themes' },
                ]}
              />
              <Content>
                <Typography.Title level={2}>{locale.customizeTheme}</Typography.Title>
                <Card
                  title={locale.myTheme}
                  extra={
                    <Flex gap="small">
                      <Link to={getLocalizedPathname('/theme-editor', isZhCN, search)}>
                        <Button type="default">{locale.toDef}</Button>
                      </Link>
                      <Link
                        to={getLocalizedPathname('/docs/react/customize-theme', isZhCN, search)}
                      >
                        <Button type="primary">{locale.toUse}</Button>
                      </Link>
                    </Flex>
                  }
                >
                  <Form
                    form={form}
                    initialValues={themeData}
                    onValuesChange={onThemeChange}
                    labelCol={{ span: 3 }}
                    wrapperCol={{ span: 21 }}
                    className={styles.form}
                  >
                    <Form.Item label={locale.titleTheme} name="themeType">
                      <ThemePicker />
                    </Form.Item>
                    <Form.Item label={locale.titlePrimaryColor} name="colorPrimary">
                      <ColorPicker />
                    </Form.Item>
                    <Form.Item label={locale.titleBorderRadius} name="borderRadius">
                      <RadiusPicker />
                    </Form.Item>
                    <Form.Item label={locale.titleCompact} name="compact" htmlFor="compact_default">
                      <Radio.Group
                        options={[
                          { label: locale.default, value: 'default', id: 'compact_default' },
                          { label: locale.compact, value: 'compact' },
                        ]}
                      />
                    </Form.Item>
                  </Form>
                </Card>
              </Content>
            </Layout>
          </Layout>
        </Layout>
      </div>
    </ConfigProvider>
  );

  return isMobile ? (
    <MobileCarousel title={locale.themeTitle} description={locale.themeDesc} id="flexible" />
  ) : (
    <Group
      title={locale.themeTitle}
      titleColor={getTitleColor(colorPrimaryValue, isLight)}
      description={locale.themeDesc}
      id="flexible"
      background={backgroundColor}
      decoration={
        // =========================== Theme Background ===========================
        <>
          {/* >>>>>> Default <<<<<< */}
          <div
            className={classNames(
              styles.motion,
              isLight && closestColor === DEFAULT_COLOR ? styles.op1 : styles.op0,
            )}
          >
            {/* Image Left Top */}
            <img
              className={classNames(styles.pos, styles.leftTopImage)}
              src="https://gw.alipayobjects.com/zos/bmw-prod/bd71b0c6-f93a-4e52-9c8a-f01a9b8fe22b.svg"
              alt=""
            />
            {/* Image Right Bottom */}
            <img
              className={classNames(styles.pos, styles.rightBottomImage)}
              src="https://gw.alipayobjects.com/zos/bmw-prod/84ad805a-74cb-4916-b7ba-9cdc2bdec23a.svg"
              alt=""
            />
          </div>
          {/* >>>>>> Dark <<<<<< */}
          <div
            className={classNames(
              styles.motion,
              !isLight || !closestColor ? styles.op1 : styles.op0,
            )}
          >
            {/* Image Left Top */}
            <img
              className={classNames(styles.pos, styles.leftTopImagePos)}
              src="https://gw.alipayobjects.com/zos/bmw-prod/a213184a-f212-4afb-beec-1e8b36bb4b8a.svg"
              alt=""
            />
            {/* Image Right Bottom */}
            <img
              className={classNames(styles.pos, styles.rightBottomPos)}
              src="https://gw.alipayobjects.com/zos/bmw-prod/bb74a2fb-bff1-4d0d-8c2d-2ade0cd9bb0d.svg"
              alt=""
            />
          </div>
          {/* >>>>>> Background Image <<<<<< */}
          <BackgroundImage isLight={isLight} colorPrimary={colorPrimaryValue} />
        </>
      }
    >
      {themeNode}
    </Group>
  );
};

export default Theme;