소스 검색

feat: support editor format text color and bg color (#3061)

Kilu.He 1 년 전
부모
커밋
a885170869
27개의 변경된 파일750개의 추가작업 그리고 106개의 파일을 삭제
  1. 2 0
      frontend/appflowy_tauri/package.json
  2. 59 1
      frontend/appflowy_tauri/pnpm-lock.yaml
  3. 0 3
      frontend/appflowy_tauri/src/appflowy_app/components/_shared/BlockDraggable/index.tsx
  4. 1 1
      frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/BlockMenu.tsx
  5. 7 4
      frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/BlockMenuTurnInto.tsx
  6. 3 32
      frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/BlockSideToolbar.hooks.tsx
  7. 1 1
      frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/index.tsx
  8. 1 1
      frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSlash/BlockSlashMenu.tsx
  9. 1 1
      frontend/appflowy_tauri/src/appflowy_app/components/document/Node/index.tsx
  10. 14 3
      frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/config.ts
  11. 1 1
      frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/index.tsx
  12. 101 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/BgColorPicker.tsx
  13. 197 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/ColorPicker.tsx
  14. 69 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/CustomColorPicker.tsx
  15. 7 11
      frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/FormatButton.tsx
  16. 97 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/TextColorPicker.tsx
  17. 6 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/index.tsx
  18. 1 1
      frontend/appflowy_tauri/src/appflowy_app/components/document/VirtualizedList/index.tsx
  19. 8 6
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/MenuItem.tsx
  20. 11 1
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/TextLeaf.tsx
  21. 1 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/ToolbarTooltip/index.tsx
  22. 4 1
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TurnInto/index.tsx
  23. 77 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/useBindArrowKey.ts
  24. 2 0
      frontend/appflowy_tauri/src/appflowy_app/interfaces/document.ts
  25. 51 38
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/format.ts
  26. 15 0
      frontend/appflowy_tauri/src/styles/template.css
  27. 13 0
      frontend/resources/translations/en.json

+ 2 - 0
frontend/appflowy_tauri/package.json

@@ -46,6 +46,7 @@
     "react": "^18.2.0",
     "react-beautiful-dnd": "^13.1.1",
     "react-calendar": "^4.1.0",
+    "react-color": "^2.19.3",
     "react-dom": "^18.2.0",
     "react-error-boundary": "^3.1.4",
     "react-i18next": "^12.2.0",
@@ -72,6 +73,7 @@
     "@types/quill": "^2.0.10",
     "@types/react": "^18.0.15",
     "@types/react-beautiful-dnd": "^13.1.3",
+    "@types/react-color": "^3.0.6",
     "@types/react-dom": "^18.0.6",
     "@types/react-katex": "^3.0.0",
     "@types/react-transition-group": "^4.4.6",

+ 59 - 1
frontend/appflowy_tauri/pnpm-lock.yaml

@@ -88,6 +88,9 @@ dependencies:
   react-calendar:
     specifier: ^4.1.0
     version: 4.2.1([email protected])([email protected])
+  react-color:
+    specifier: ^2.19.3
+    version: 2.19.3([email protected])
   react-dom:
     specifier: ^18.2.0
     version: 18.2.0([email protected])
@@ -162,6 +165,9 @@ devDependencies:
   '@types/react-beautiful-dnd':
     specifier: ^13.1.3
     version: 13.1.4
+  '@types/react-color':
+    specifier: ^3.0.6
+    version: 3.0.6
   '@types/react-dom':
     specifier: ^18.0.6
     version: 18.2.4
@@ -960,6 +966,14 @@ packages:
     resolution: {integrity: sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==}
     dev: true
 
+  /@icons/[email protected]([email protected]):
+    resolution: {integrity: sha512-QPcGmICAPbGLGb6F/yNf/KzKqvFx8z5qx3D1yFqVAjoFmXK35EgyW+cJ57Te3CNsmzblwtzakLGFqHPqrfb4Tw==}
+    peerDependencies:
+      react: '*'
+    dependencies:
+      react: 18.2.0
+    dev: false
+
   /@istanbuljs/[email protected]:
     resolution: {integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==}
     engines: {node: '>=8'}
@@ -1701,6 +1715,13 @@ packages:
       '@types/react': 18.2.6
     dev: true
 
+  /@types/[email protected]:
+    resolution: {integrity: sha512-OzPIO5AyRmLA7PlOyISlgabpYUa3En74LP8mTMa0veCA719SvYQov4WLMsHvCgXP+L+KI9yGhYnqZafVGG0P4w==}
+    dependencies:
+      '@types/react': 18.2.6
+      '@types/reactcss': 1.2.6
+    dev: true
+
   /@types/[email protected]:
     resolution: {integrity: sha512-G2mHoTMTL4yoydITgOGwWdWMVd8sNgyEP85xVmMKAPUBwQWm9wBPQUmvbeF4V3WBY1P7mmL4BkjQ0SqUpf1snw==}
     dependencies:
@@ -1747,6 +1768,12 @@ packages:
       '@types/scheduler': 0.16.3
       csstype: 3.1.2
 
+  /@types/[email protected]:
+    resolution: {integrity: sha512-qaIzpCuXNWomGR1Xq8SCFTtF4v8V27Y6f+b9+bzHiv087MylI/nTCqqdChNeWS7tslgROmYB7yeiruWX7WnqNg==}
+    dependencies:
+      '@types/react': 18.2.6
+    dev: true
+
   /@types/[email protected]:
     resolution: {integrity: sha512-5cJ8CB4yAx7BH1oMvdU0Jh9lrEXyPkar6F9G/ERswkCuvP4KQZfZkSjcMbAICCpQTN4OuZn8tz0HiKv9TGZgrQ==}
 
@@ -3975,6 +4002,10 @@ packages:
       p-locate: 5.0.0
     dev: true
 
+  /[email protected]:
+    resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==}
+    dev: false
+
   /[email protected]:
     resolution: {integrity: sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==}
 
@@ -4035,6 +4066,10 @@ packages:
       tmpl: 1.0.5
     dev: false
 
+  /[email protected]:
+    resolution: {integrity: sha512-6qE4B9deFBIa9YSpOc9O0Sgc43zTeVYbgDT5veRKSlB2+ZuHNoVVxA1L/ckMUayV9Ay9y7Z/SZCLcGteW9i7bg==}
+    dev: false
+
   /[email protected]:
     resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==}
     dev: false
@@ -4593,6 +4628,21 @@ packages:
       react-dom: 18.2.0([email protected])
     dev: false
 
+  /[email protected]([email protected]):
+    resolution: {integrity: sha512-LEeGE/ZzNLIsFWa1TMe8y5VYqr7bibneWmvJwm1pCn/eNmrabWDh659JSPn9BuaMpEfU83WTOJfnCcjDZwNQTA==}
+    peerDependencies:
+      react: '*'
+    dependencies:
+      '@icons/material': 0.2.4([email protected])
+      lodash: 4.17.21
+      lodash-es: 4.17.21
+      material-colors: 1.2.6
+      prop-types: 15.8.1
+      react: 18.2.0
+      reactcss: 1.2.3([email protected])
+      tinycolor2: 1.6.0
+    dev: false
+
   /[email protected]([email protected]):
     resolution: {integrity: sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==}
     peerDependencies:
@@ -4770,6 +4820,15 @@ packages:
       loose-envify: 1.4.0
     dev: false
 
+  /[email protected]([email protected]):
+    resolution: {integrity: sha512-KiwVUcFu1RErkI97ywr8nvx8dNOpT03rbnma0SSalTYjkrPYaEajR4a/MRt6DZ46K6arDRbWMNHF+xH7G7n/8A==}
+    peerDependencies:
+      react: '*'
+    dependencies:
+      lodash: 4.17.21
+      react: 18.2.0
+    dev: false
+
   /[email protected]:
     resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==}
     dependencies:
@@ -5230,7 +5289,6 @@ packages:
 
   /[email protected]:
     resolution: {integrity: sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==}
-    dev: true
 
   /[email protected]:
     resolution: {integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==}

+ 0 - 3
frontend/appflowy_tauri/src/appflowy_app/components/_shared/BlockDraggable/index.tsx

@@ -40,9 +40,6 @@ function BlockDraggable(
         data-draggable-type={type}
         onMouseDown={getAnchorEl ? undefined : onDragStart}
         className={`relative ${className || ''}`}
-        style={{
-          opacity: isDragging ? 0.7 : 1,
-        }}
         {...props}
       >
         {

+ 1 - 1
frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/BlockMenu.tsx

@@ -143,7 +143,7 @@ function BlockMenu({ id, onClose }: { id: string; onClose: () => void }) {
           return (
             <BlockMenuTurnInto
               key={option.key}
-              lable={option.title}
+              label={option.title}
               onHovered={() => {
                 setHovered(BlockMenuOption.TurnInto);
                 setSubMenuOpened(true);

+ 7 - 4
frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/BlockMenuTurnInto.tsx

@@ -9,14 +9,14 @@ function BlockMenuTurnInto({
   onHovered,
   isHovered,
   menuOpened,
-  lable,
+  label,
 }: {
   id: string;
   onClose: () => void;
   onHovered: (e: MouseEvent) => void;
   isHovered: boolean;
   menuOpened: boolean;
-  lable?: string;
+  label?: string;
 }) {
   const ref = useRef<HTMLDivElement | null>(null);
   const [anchorPosition, setAnchorPosition] = React.useState<{ top: number; left: number }>();
@@ -39,7 +39,7 @@ function BlockMenuTurnInto({
     <>
       <MenuItem
         ref={ref}
-        title={lable}
+        title={label}
         isHovered={isHovered}
         icon={<Transform />}
         extra={<ArrowRight />}
@@ -60,7 +60,10 @@ function BlockMenuTurnInto({
             pointerEvents: 'auto',
           },
         }}
-        onClose={onClose}
+        onOk={() => onClose()}
+        onClose={() => {
+          setAnchorPosition(undefined);
+        }}
         anchorReference={'anchorPosition'}
         anchorPosition={anchorPosition}
         transformOrigin={{

+ 3 - 32
frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/BlockSideToolbar.hooks.tsx

@@ -9,9 +9,9 @@ import { getNode } from '$app/utils/document/node';
 import { get } from '$app/utils/tool';
 
 const headingBlockTopOffset: Record<number, string> = {
-  1: '1.65rem',
-  2: '1.3rem',
-  3: '0.25rem',
+  1: '0.4rem',
+  2: '0.2rem',
+  3: '0.15rem',
 };
 
 export function useBlockSideToolbar(id: string) {
@@ -87,35 +87,6 @@ export function useBlockSideToolbar(id: string) {
   };
 }
 
-function getNodeIdByPoint(x: number, y: number) {
-  const viewportNodes = document.querySelectorAll('[data-block-id]');
-  let node: {
-    el: Element;
-    rect: DOMRect;
-  } | null = null;
-
-  viewportNodes.forEach((el) => {
-    const rect = el.getBoundingClientRect();
-
-    if (rect.x + rect.width - 1 >= x && rect.y + rect.height - 1 >= y && rect.y <= y) {
-      if (!node || rect.y > node.rect.y) {
-        node = {
-          el,
-          rect,
-        };
-      }
-    }
-  });
-  return node
-    ? (
-        node as {
-          el: Element;
-          rect: DOMRect;
-        }
-      ).el.getAttribute('data-block-id')
-    : null;
-}
-
 const transformOrigin: PopoverOrigin = {
   vertical: 'bottom',
   horizontal: 'left',

+ 1 - 1
frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/index.tsx

@@ -29,7 +29,7 @@ export default function BlockSideToolbar({ id }: { id: string }) {
           opacity: show ? 1 : 0,
           top: topOffset,
         }}
-        className='absolute left-[-50px] inline-flex transition-opacity duration-100'
+        className='absolute left-[-50px] inline-flex'
       >
         {/** Add Block below */}
         <Tooltip disableInteractive={true} title={t('blockActions.addBelowTooltip')} placement={'top-start'}>

+ 1 - 1
frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSlash/BlockSlashMenu.tsx

@@ -292,7 +292,7 @@ function BlockSlashMenu({
       <div ref={ref} className={'min-h-0 flex-1 overflow-y-auto overflow-x-hidden'}>
         {Object.entries(optionsByGroup).map(([group, options]) => (
           <div key={group}>
-            <div className={'text-shade-3 px-2 py-2 text-sm'}>{group}</div>
+            <div className={'px-2 py-2 text-sm text-text-caption'}>{group}</div>
             <div>
               {options.map((option) => {
                 return (

+ 1 - 1
frontend/appflowy_tauri/src/appflowy_app/components/document/Node/index.tsx

@@ -90,7 +90,7 @@ function NodeComponent({ id, ...props }: { id: string } & React.HTMLAttributes<H
         {...props}
         ref={ref}
         data-block-id={node.id}
-        className={`pt-[0.5px] ${className}`}
+        className={className}
       >
         {renderBlock()}
         <BlockOverlay id={id} />

+ 14 - 3
frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/config.ts

@@ -9,6 +9,8 @@ export const defaultTextActionItems = [
   TextAction.Strikethrough,
   TextAction.Code,
   TextAction.Equation,
+  TextAction.TextColor,
+  TextAction.Highlight,
 ];
 const groupKeys = {
   comment: [],
@@ -21,11 +23,20 @@ const groupKeys = {
     TextAction.Equation,
   ],
   link: [TextAction.Link],
+  color: [TextAction.TextColor, TextAction.Highlight],
   turn: [TextAction.Turn],
 };
 
 export const multiLineTextActionProps: TextActionMenuProps = {
-  customItems: [TextAction.Bold, TextAction.Italic, TextAction.Underline, TextAction.Strikethrough, TextAction.Code],
+  customItems: [
+    TextAction.Bold,
+    TextAction.Italic,
+    TextAction.Underline,
+    TextAction.Strikethrough,
+    TextAction.Code,
+    TextAction.TextColor,
+    TextAction.Highlight,
+  ],
 };
-export const multiLineTextActionGroups = [groupKeys.format];
-export const textActionGroups = [groupKeys.turn, groupKeys.format, groupKeys.link];
+export const multiLineTextActionGroups = [groupKeys.format, groupKeys.color];
+export const textActionGroups = [groupKeys.turn, groupKeys.format, groupKeys.color, groupKeys.link];

+ 1 - 1
frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/index.tsx

@@ -18,7 +18,7 @@ const TextActionComponent = ({ container }: { container: HTMLDivElement }) => {
         style={{
           opacity: 0,
         }}
-        className='absolute mt-[-6px] inline-flex h-[32px] min-w-[100px] items-stretch overflow-hidden rounded-[8px] bg-fill-toolbar leading-tight text-content-on-fill shadow-md transition-opacity duration-100'
+        className='absolute mt-[-6px] inline-flex h-[32px] min-w-[100px] items-stretch overflow-hidden rounded-[8px] bg-fill-toolbar leading-tight text-content-on-fill shadow-md'
         onMouseDown={(e) => {
           // prevent toolbar from taking focus away from editor
           e.preventDefault();

+ 101 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/BgColorPicker.tsx

@@ -0,0 +1,101 @@
+import React, { useCallback, useMemo } from 'react';
+import { useTranslation } from 'react-i18next';
+import ColorPicker from '$app/components/document/TextActionMenu/menu/ColorPicker';
+import { FormatColorFill, FormatColorText } from '@mui/icons-material';
+import { TextAction } from '$app/interfaces/document';
+
+function BgColorPicker() {
+  const { t } = useTranslation();
+
+  const getColorIcon = useCallback((color: string) => {
+    return (
+      <div
+        style={{
+          backgroundColor: color,
+        }}
+        className={'rounded border border-line-divider p-0.5'}
+      >
+        <FormatColorText />
+      </div>
+    );
+  }, []);
+  const colors = useMemo(
+    () => [
+      {
+        name: t('colors.default'),
+        key: 'default',
+        color: 'transparent',
+      },
+      {
+        name: t('colors.custom'),
+        key: 'custom',
+        color: 'transparent',
+      },
+      {
+        key: 'gray',
+        name: t('colors.gray'),
+        color: '#78909c',
+      },
+      {
+        key: 'brown',
+        name: t('colors.brown'),
+        color: '#8d6e63',
+      },
+      {
+        key: 'orange',
+        name: t('colors.orange'),
+        color: '#ff9100',
+      },
+      {
+        key: 'yellow',
+        name: t('colors.yellow'),
+        color: '#ffd600',
+      },
+      {
+        key: 'green',
+        name: t('colors.green'),
+        color: '#00e676',
+      },
+      {
+        key: 'blue',
+        name: t('colors.blue'),
+        color: '#448aff',
+      },
+      {
+        key: 'purple',
+        name: t('colors.purple'),
+        color: '#e040fb',
+      },
+      {
+        key: 'pink',
+        name: t('colors.pink'),
+        color: '#ff4081',
+      },
+      {
+        key: 'red',
+        name: t('colors.red'),
+        color: '#ff5252',
+      },
+    ],
+    [t]
+  );
+
+  return (
+    <ColorPicker
+      getColorIcon={getColorIcon}
+      icon={
+        <FormatColorFill
+          sx={{
+            width: 18,
+            height: 18,
+          }}
+        />
+      }
+      colors={colors}
+      format={TextAction.Highlight}
+      label={t('toolbar.highlight')}
+    />
+  );
+}
+
+export default BgColorPicker;

+ 197 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/ColorPicker.tsx

@@ -0,0 +1,197 @@
+import React, { useCallback, useEffect, useRef, useState } from 'react';
+import { List } from '@mui/material';
+import MenuItem from '@mui/material/MenuItem';
+import { useBindArrowKey } from '$app/components/document/_shared/useBindArrowKey';
+import Popover from '@mui/material/Popover';
+import Tooltip from '@mui/material/Tooltip';
+import { useAppDispatch } from '$app/stores/store';
+import { formatThunk, getFormatValuesThunk } from '$app_reducers/document/async-actions/format';
+import { TextAction } from '$app/interfaces/document';
+import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
+import CustomColorPicker from '$app/components/document/TextActionMenu/menu/CustomColorPicker';
+
+export interface ColorItem {
+  name: string;
+  key: string;
+  color: string;
+}
+function ColorPicker({
+  label,
+  format,
+  colors,
+  icon,
+  getColorIcon,
+}: {
+  format: TextAction;
+  label: string;
+  colors: ColorItem[];
+  icon: React.ReactNode;
+  getColorIcon: (color: string) => React.ReactNode;
+}) {
+  const { controller, docId } = useSubscribeDocument();
+  const ref = useRef<HTMLDivElement>(null);
+  const [anchorPosition, setAnchorPosition] = useState<
+    | {
+        left: number;
+        top: number;
+      }
+    | undefined
+  >(undefined);
+  const open = Boolean(anchorPosition);
+  const dispatch = useAppDispatch();
+  const [customPickerAnchorPosition, setCustomPickerAnchorPosition] = useState<
+    | {
+        left: number;
+        top: number;
+      }
+    | undefined
+  >(undefined);
+  const customOpened = Boolean(customPickerAnchorPosition);
+  const [selectOption, setSelectOption] = useState<string | null>(null);
+  const [activeColor, setActiveColor] = useState<string | null>(null);
+
+  const openCustomColorPicker = useCallback(() => {
+    const target = document.querySelector('.color-item-custom') as Element;
+
+    const rect = target.getBoundingClientRect();
+
+    setCustomPickerAnchorPosition({
+      left: rect.left + rect.width + 10,
+      top: rect.top,
+    });
+  }, []);
+
+  useEffect(() => {
+    if (selectOption === 'custom') {
+      openCustomColorPicker();
+    } else {
+      setCustomPickerAnchorPosition(undefined);
+    }
+  }, [selectOption, openCustomColorPicker]);
+
+  const onOpen = useCallback(() => {
+    const rect = ref.current?.getBoundingClientRect();
+
+    if (!rect) return;
+    setAnchorPosition({
+      left: rect.left,
+      top: rect.top + rect.height + 10,
+    });
+  }, []);
+
+  const loadActiveColor = useCallback(async () => {
+    const { payload: formatValues } = (await dispatch(getFormatValuesThunk({ format, docId }))) as {
+      payload: Record<string, (boolean | string | undefined)[]>;
+    };
+    const multiLines = Object.keys(formatValues).length > 1;
+    const firstKey = Object.keys(formatValues)[0];
+    const firstValue = formatValues[firstKey].find((item) => item);
+
+    setActiveColor(multiLines ? null : String(firstValue));
+  }, [dispatch, docId, format]);
+
+  useEffect(() => {
+    void (async () => {
+      await loadActiveColor();
+    })();
+  }, [loadActiveColor]);
+
+  const formatColor = useCallback(
+    async (color: string | null) => {
+      await dispatch(formatThunk({ format, value: color, controller }));
+      setAnchorPosition(undefined);
+      await loadActiveColor();
+    },
+    [format, controller, dispatch, loadActiveColor]
+  );
+
+  const onClick = useCallback(async () => {
+    if (selectOption === 'custom') {
+      return;
+    }
+
+    if (selectOption === 'default') {
+      await formatColor(null);
+    } else {
+      const item = colors.find((color) => color.key === selectOption);
+
+      await formatColor(item?.color || null);
+    }
+  }, [selectOption, formatColor, colors]);
+
+  useBindArrowKey({
+    options: colors.map((item) => item.key),
+    onChange: (key) => {
+      setSelectOption(key);
+    },
+    selectOption,
+    onEnter: () => onClick(),
+  });
+
+  return (
+    <>
+      <div
+        ref={ref}
+        className={'cursor-pointer px-1.5 hover:text-fill-hover'}
+        onClick={onOpen}
+        style={{
+          color: activeColor || undefined,
+        }}
+      >
+        <Tooltip placement={'top-start'} disableInteractive title={label}>
+          <div>{icon}</div>
+        </Tooltip>
+      </div>
+      <Popover
+        onMouseDown={(e) => {
+          e.stopPropagation();
+        }}
+        disableAutoFocus={true}
+        disableRestoreFocus={true}
+        transformOrigin={{
+          vertical: 'top',
+          horizontal: 'left',
+        }}
+        open={open}
+        anchorReference={'anchorPosition'}
+        anchorPosition={anchorPosition}
+        onClose={() => setAnchorPosition(undefined)}
+      >
+        <List>
+          <div className={'w-[200px] px-4 py-2 uppercase text-text-caption'}>{label}</div>
+          {colors.map((item) => (
+            <MenuItem
+              className={`color-item-${item.key}`}
+              key={item.key}
+              onMouseEnter={() => {
+                setSelectOption(item.key);
+              }}
+              style={{
+                padding: '4px',
+              }}
+              selected={selectOption === item.key}
+              onClick={onClick}
+            >
+              <div className={'flex items-center'}>
+                {getColorIcon(item.color)}
+                <div className={'ml-2'}>{item.name}</div>
+              </div>
+              {item.key === 'custom' && (
+                <CustomColorPicker
+                  open={customOpened}
+                  onChange={formatColor}
+                  anchorPosition={customPickerAnchorPosition}
+                  onClose={() => {
+                    setCustomPickerAnchorPosition(undefined);
+                  }}
+                />
+              )}
+            </MenuItem>
+          ))}
+        </List>
+      </Popover>
+    </>
+  );
+}
+
+export default ColorPicker;

+ 69 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/CustomColorPicker.tsx

@@ -0,0 +1,69 @@
+import React, { useState } from 'react';
+import Popover from '@mui/material/Popover';
+import { RGBColor, SketchPicker } from 'react-color';
+import Button from '@mui/material/Button';
+import { useTranslation } from 'react-i18next';
+import { Divider } from '@mui/material';
+
+function CustomColorPicker({
+  onChange,
+  open,
+  onClose,
+  anchorPosition,
+}: {
+  open: boolean;
+  onChange: (color: string) => void;
+  anchorPosition?: {
+    left: number;
+    top: number;
+  };
+  onClose: () => void;
+}) {
+  const { t } = useTranslation();
+  const [color, setColor] = useState<RGBColor | undefined>();
+
+  return (
+    <Popover
+      onMouseDown={(e) => e.stopPropagation()}
+      disableAutoFocus={true}
+      disableRestoreFocus={true}
+      sx={{
+        pointerEvents: 'none',
+      }}
+      PaperProps={{
+        style: {
+          pointerEvents: 'auto',
+        },
+        className: 'p-2',
+      }}
+      open={open}
+      transformOrigin={{
+        vertical: 'top',
+        horizontal: 'left',
+      }}
+      anchorReference={'anchorPosition'}
+      anchorPosition={anchorPosition}
+      onClose={onClose}
+    >
+      <SketchPicker
+        onChange={(color, event) => {
+          setColor(color.rgb);
+        }}
+        color={color}
+      />
+      <Divider />
+      <div className={'z-10 flex justify-end bg-bg-body px-2 pt-2'}>
+        <Button
+          onClick={() => {
+            onChange(`rgba(${color?.r}, ${color?.g}, ${color?.b}, ${color?.a})`);
+          }}
+          variant={'contained'}
+        >
+          {t('button.done')}
+        </Button>
+      </div>
+    </Popover>
+  );
+}
+
+export default CustomColorPicker;

+ 7 - 11
frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/FormatButton.tsx

@@ -29,7 +29,7 @@ const FormatButton = ({ format, icon }: { format: TextAction; icon: string }) =>
   const { node: focusNode } = useSubscribeNode(focusId);
 
   const [isActive, setIsActive] = React.useState(false);
-  const color = useMemo(() => (isActive ? 'text-content-on-fill-hover' : ''), [isActive]);
+  const color = useMemo(() => (isActive ? 'text-fill-hover' : ''), [isActive]);
 
   const isFormatActive = useCallback(async () => {
     if (!focusNode) return false;
@@ -125,22 +125,18 @@ const FormatButton = ({ format, icon }: { format: TextAction; icon: string }) =>
         return <StrikethroughSOutlined sx={iconSize} />;
       case TextAction.Link:
         return (
-          <div className={'flex items-center justify-center px-1 text-[0.8rem]'}>
-            <LinkIcon
-              sx={{
-                fontSize: '1.2rem',
-                marginRight: '0.25rem',
-              }}
-            />
-            <div className={'underline'}>{t('toolbar.link')}</div>
-          </div>
+          <LinkIcon
+            sx={{
+              fontSize: '1.2rem',
+            }}
+          />
         );
       case TextAction.Equation:
         return <Functions sx={iconSize} />;
       default:
         return null;
     }
-  }, [icon, t]);
+  }, [icon]);
 
   return (
     <ToolbarTooltip title={formatTooltips[format]}>

+ 97 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/TextColorPicker.tsx

@@ -0,0 +1,97 @@
+import React, { useCallback, useMemo } from 'react';
+import { useTranslation } from 'react-i18next';
+import { TextAction } from '$app/interfaces/document';
+import ColorPicker from '$app/components/document/TextActionMenu/menu/ColorPicker';
+import { FormatColorText } from '@mui/icons-material';
+
+function TextColorPicker() {
+  const { t } = useTranslation();
+
+  const getColorIcon = useCallback((color: string) => {
+    return (
+      <div className={'rounded border border-line-divider p-0.5'}>
+        <FormatColorText style={{ color }} />
+      </div>
+    );
+  }, []);
+
+  const colors = useMemo(
+    () => [
+      {
+        name: t('colors.default'),
+        key: 'default',
+        color: 'var(--text-title)',
+      },
+      {
+        name: t('colors.custom'),
+        key: 'custom',
+        color: 'var(--text-title)',
+      },
+      {
+        key: 'gray',
+        name: t('colors.gray'),
+        color: '#546e7a',
+      },
+      {
+        key: 'brown',
+        name: t('colors.brown'),
+        color: '#795548',
+      },
+      {
+        key: 'orange',
+        name: t('colors.orange'),
+        color: '#ff5722',
+      },
+      {
+        key: 'yellow',
+        name: t('colors.yellow'),
+        color: '#ffff00',
+      },
+      {
+        key: 'green',
+        name: t('colors.green'),
+        color: '#4caf50',
+      },
+      {
+        key: 'blue',
+        name: t('colors.blue'),
+        color: '#0d47a1',
+      },
+      {
+        key: 'purple',
+        name: t('colors.purple'),
+        color: '#9c27b0',
+      },
+      {
+        key: 'pink',
+        name: t('colors.pink'),
+        color: '#d81b60',
+      },
+      {
+        key: 'red',
+        name: t('colors.red'),
+        color: '#b71c1c',
+      },
+    ],
+    [t]
+  );
+
+  return (
+    <ColorPicker
+      icon={
+        <FormatColorText
+          sx={{
+            width: 18,
+            height: 18,
+          }}
+        />
+      }
+      getColorIcon={getColorIcon}
+      colors={colors}
+      format={TextAction.TextColor}
+      label={t('toolbar.color')}
+    />
+  );
+}
+
+export default TextColorPicker;

+ 6 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/index.tsx

@@ -3,6 +3,8 @@ import React, { useCallback } from 'react';
 import TurnIntoSelect from '$app/components/document/TextActionMenu/menu/TurnIntoSelect';
 import FormatButton from '$app/components/document/TextActionMenu/menu/FormatButton';
 import { useTextActionMenu } from '$app/components/document/TextActionMenu/menu/index.hooks';
+import TextColorPicker from '$app/components/document/TextActionMenu/menu/TextColorPicker';
+import BgColorPicker from '$app/components/document/TextActionMenu/menu/BgColorPicker';
 
 function TextActionMenuList() {
   const { groupItems, isSingleLine, focusId } = useTextActionMenu();
@@ -19,6 +21,10 @@ function TextActionMenuList() {
         case TextAction.Code:
         case TextAction.Equation:
           return <FormatButton format={action} icon={action} />;
+        case TextAction.TextColor:
+          return <TextColorPicker />;
+        case TextAction.Highlight:
+          return <BgColorPicker />;
         default:
           return null;
       }

+ 1 - 1
frontend/appflowy_tauri/src/appflowy_app/components/document/VirtualizedList/index.tsx

@@ -49,7 +49,7 @@ export default function VirtualizedList({
                 const id = childIds[virtualRow.index];
 
                 return (
-                  <div key={id} data-index={virtualRow.index} ref={virtualize.measureElement}>
+                  <div className={'pt-[0.5px]'} key={id} data-index={virtualRow.index} ref={virtualize.measureElement}>
                     {virtualRow.index === 0 ? <DocumentTitle id={node.id} /> : null}
                     {renderNode(id)}
                   </div>

+ 8 - 6
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/MenuItem.tsx

@@ -1,5 +1,5 @@
 import React, { forwardRef, MouseEvent, useMemo } from 'react';
-import { ListItemButton } from '@mui/material';
+import { MenuItem as MuiMenuItem } from '@mui/material';
 
 const MenuItem = forwardRef(function (
   {
@@ -34,14 +34,16 @@ const MenuItem = forwardRef(function (
 
   return (
     <div className={className} ref={ref} id={id}>
-      <ListItemButton
+      <MuiMenuItem
         sx={{
           borderRadius: '4px',
           padding: '4px 8px',
           fontSize: 14,
         }}
         selected={isHovered}
-        onMouseEnter={(e) => onHover?.(e)}
+        onMouseEnter={(e) => {
+          onHover?.(e);
+        }}
         onClick={(e) => {
           e.preventDefault();
           e.stopPropagation();
@@ -53,7 +55,7 @@ const MenuItem = forwardRef(function (
             width: imgSize.width,
             height: imgSize.height,
           }}
-          className={`mr-2 flex items-center justify-center rounded border border-shade-5`}
+          className={`mr-2 flex items-center justify-center rounded border border-line-divider`}
         >
           {icon}
         </div>
@@ -61,7 +63,7 @@ const MenuItem = forwardRef(function (
           <div className={'text-sm'}>{title}</div>
           {desc && (
             <div
-              className={'font-normal text-shade-4'}
+              className={'font-normal text-text-caption'}
               style={{
                 fontSize: '0.85em',
                 fontWeight: 300,
@@ -72,7 +74,7 @@ const MenuItem = forwardRef(function (
           )}
         </div>
         <div>{extra}</div>
-      </ListItemButton>
+      </MuiMenuItem>
     </div>
   );
 });

+ 11 - 1
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/TextLeaf.tsx

@@ -21,6 +21,8 @@ interface Attributes {
   link_placeholder?: string;
   temporary?: boolean;
   formula?: string;
+  font_color?: string;
+  bg_color?: string;
 }
 interface TextLeafProps extends RenderLeafProps {
   leaf: BaseText & Attributes;
@@ -122,7 +124,15 @@ const TextLeaf = (props: TextLeafProps) => {
   }
 
   return (
-    <span ref={ref} {...customAttributes} className={className.join(' ')}>
+    <span
+      style={{
+        backgroundColor: leaf.bg_color,
+        color: leaf.font_color,
+      }}
+      ref={ref}
+      {...customAttributes}
+      className={className.join(' ')}
+    >
       {newChildren}
     </span>
   );

+ 1 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/ToolbarTooltip/index.tsx

@@ -4,6 +4,7 @@ import Tooltip from '@mui/material/Tooltip';
 function ToolbarTooltip({ title, children }: { children: JSX.Element; title?: string }) {
   return (
     <Tooltip
+      disableInteractive
       slotProps={{ tooltip: { style: { background: 'var(--bg-tips)', borderRadius: 8 } } }}
       title={title}
       placement='top-start'

+ 4 - 1
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TurnInto/index.tsx

@@ -31,10 +31,12 @@ interface Option {
 const TurnIntoPopover = ({
   id,
   onClose,
+  onOk,
   ...props
 }: {
   id: string;
   onClose?: () => void;
+  onOk?: () => void;
 } & PopoverProps) => {
   const { node } = useSubscribeNode(id);
   const { turnIntoHeading, turnIntoBlock } = useTurnInto({ node, onClose });
@@ -142,8 +144,9 @@ const TurnIntoPopover = ({
       const isSelected = getSelected(option);
 
       option.onClick ? option.onClick(option.type, isSelected) : turnIntoBlock(option.type, isSelected);
+      onOk?.();
     },
-    [getSelected, turnIntoBlock]
+    [onOk, getSelected, turnIntoBlock]
   );
 
   const onKeyDown = useCallback(

+ 77 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/useBindArrowKey.ts

@@ -0,0 +1,77 @@
+import { useCallback, useEffect, useState } from 'react';
+import { Keyboard } from '$app/constants/document/keyboard';
+
+export const useBindArrowKey = ({
+  options,
+  onLeft,
+  onRight,
+  onEnter,
+  onChange,
+  selectOption,
+}: {
+  options: string[];
+  onLeft?: () => void;
+  onRight?: () => void;
+  onEnter?: () => void;
+  onChange?: (key: string) => void;
+  selectOption?: string | null;
+}) => {
+  const onUp = useCallback(() => {
+    const getSelected = () => {
+      const index = options.findIndex((item) => item === selectOption);
+
+      if (index === -1) return options[0];
+      const length = options.length;
+
+      return options[(index + length - 1) % length];
+    };
+
+    onChange?.(getSelected());
+  }, [onChange, options, selectOption]);
+
+  const onDown = useCallback(() => {
+    const getSelected = () => {
+      const index = options.findIndex((item) => item === selectOption);
+
+      if (index === -1) return options[0];
+      const length = options.length;
+
+      return options[(index + 1) % length];
+    };
+
+    onChange?.(getSelected());
+  }, [onChange, options, selectOption]);
+
+  const handleArrowKey = useCallback(
+    (e: KeyboardEvent) => {
+      if (
+        [Keyboard.keys.UP, Keyboard.keys.DOWN, Keyboard.keys.LEFT, Keyboard.keys.RIGHT, Keyboard.keys.ENTER].includes(
+          e.key
+        )
+      ) {
+        e.stopPropagation();
+        e.preventDefault();
+      }
+
+      if (e.key === Keyboard.keys.UP) {
+        onUp();
+      } else if (e.key === Keyboard.keys.DOWN) {
+        onDown();
+      } else if (e.key === Keyboard.keys.LEFT) {
+        onLeft?.();
+      } else if (e.key === Keyboard.keys.RIGHT) {
+        onRight?.();
+      } else if (e.key === Keyboard.keys.ENTER) {
+        onEnter?.();
+      }
+    },
+    [onDown, onEnter, onLeft, onRight, onUp]
+  );
+
+  useEffect(() => {
+    document.addEventListener('keydown', handleArrowKey, true);
+    return () => {
+      document.removeEventListener('keydown', handleArrowKey, true);
+    };
+  }, [handleArrowKey]);
+};

+ 2 - 0
frontend/appflowy_tauri/src/appflowy_app/interfaces/document.ts

@@ -235,6 +235,8 @@ export enum TextAction {
   Code = 'code',
   Equation = 'formula',
   Link = 'href',
+  TextColor = 'font_color',
+  Highlight = 'bg_color',
 }
 export interface TextActionMenuProps {
   /**

+ 51 - 38
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/format.ts

@@ -5,6 +5,35 @@ import { DocumentController } from '$app/stores/effects/document/document_contro
 import Delta from 'quill-delta';
 import { DOCUMENT_NAME, RANGE_NAME } from '$app/constants/document/name';
 
+type FormatValues = Record<string, (boolean | string | undefined)[]>;
+
+export const getFormatValuesThunk = createAsyncThunk(
+  'document/getFormatValues',
+  ({ docId, format }: { docId: string; format: TextAction }, thunkAPI) => {
+    const { getState } = thunkAPI;
+    const state = getState() as RootState;
+    const document = state[DOCUMENT_NAME][docId];
+    const documentRange = state[RANGE_NAME][docId];
+    const { ranges } = documentRange;
+    const mapAttrs = (delta: Delta, format: TextAction) => {
+      return delta.ops.map((op) => op.attributes?.[format] as boolean | string | undefined);
+    };
+
+    const formatValues: FormatValues = {};
+
+    Object.entries(ranges).forEach(([id, range]) => {
+      const node = document.nodes[id];
+      const delta = new Delta(node.data?.delta);
+      const index = range?.index || 0;
+      const length = range?.length || 0;
+      const rangeDelta = delta.slice(index, index + length);
+
+      formatValues[id] = mapAttrs(rangeDelta, format);
+    });
+    return formatValues;
+  }
+);
+
 export const getFormatActiveThunk = createAsyncThunk<
   boolean,
   {
@@ -12,30 +41,22 @@ export const getFormatActiveThunk = createAsyncThunk<
     docId: string;
   }
 >('document/getFormatActive', async ({ format, docId }, thunkAPI) => {
-  const { getState } = thunkAPI;
-  const state = getState() as RootState;
-  const document = state[DOCUMENT_NAME][docId];
-  const documentRange = state[RANGE_NAME][docId];
-  const { ranges } = documentRange;
-  const match = (delta: Delta, format: TextAction) => {
-    return delta.ops.every((op) => op.attributes?.[format]);
+  const { dispatch } = thunkAPI;
+  const { payload } = (await dispatch(getFormatValuesThunk({ docId, format }))) as {
+    payload: FormatValues;
   };
 
-  return Object.entries(ranges).every(([id, range]) => {
-    const node = document.nodes[id];
-    const delta = new Delta(node.data?.delta);
-    const index = range?.index || 0;
-    const length = range?.length || 0;
-    const rangeDelta = delta.slice(index, index + length);
-
-    return match(rangeDelta, format);
+  return Object.values(payload).every((values) => {
+    return values.every((value) => {
+      return value !== undefined;
+    });
   });
 });
 
 export const toggleFormatThunk = createAsyncThunk(
   'document/toggleFormat',
   async (payload: { format: TextAction; controller: DocumentController; isActive?: boolean }, thunkAPI) => {
-    const { getState, dispatch } = thunkAPI;
+    const { dispatch } = thunkAPI;
     const { format, controller } = payload;
     const docId = controller.documentId;
     let isActive = payload.isActive;
@@ -51,38 +72,30 @@ export const toggleFormatThunk = createAsyncThunk(
       isActive = !!active;
     }
 
-    const formatValue = isActive ? undefined : true;
+    const formatValue = isActive ? null : true;
+    await dispatch(formatThunk({ format, value: formatValue, controller }));
+  }
+);
+
+export const formatThunk = createAsyncThunk(
+  'document/format',
+  async (payload: { format: TextAction; value: string | boolean | null; controller: DocumentController }, thunkAPI) => {
+    const { getState } = thunkAPI;
+    const { format, controller, value } = payload;
+    const docId = controller.documentId;
     const state = getState() as RootState;
     const document = state[DOCUMENT_NAME][docId];
     const documentRange = state[RANGE_NAME][docId];
     const { ranges } = documentRange;
 
-    const toggle = (delta: Delta, format: TextAction, value: string | boolean | undefined) => {
-      const newOps = delta.ops.map((op) => {
-        const attributes = {
-          ...op.attributes,
-          [format]: value,
-        };
-
-        return {
-          insert: op.insert,
-          attributes: attributes,
-        };
-      });
-
-      return new Delta(newOps);
-    };
-
     const actions = Object.entries(ranges).map(([id, range]) => {
       const node = document.nodes[id];
       const delta = new Delta(node.data?.delta);
       const index = range?.index || 0;
       const length = range?.length || 0;
-      const beforeDelta = delta.slice(0, index);
-      const afterDelta = delta.slice(index + length);
-      const rangeDelta = delta.slice(index, index + length);
-      const toggleFormatDelta = toggle(rangeDelta, format, formatValue);
-      const newDelta = beforeDelta.concat(toggleFormatDelta).concat(afterDelta);
+      const diffDelta: Delta = new Delta();
+      diffDelta.retain(index).retain(length, { [format]: value });
+      const newDelta = delta.compose(diffDelta);
 
       return controller.getUpdateAction({
         ...node,

+ 15 - 0
frontend/appflowy_tauri/src/styles/template.css

@@ -47,4 +47,19 @@ th {
 span[data-slate-placeholder="true"]:not(.inline-block-content) {
   @apply text-text-placeholder;
   opacity: 1 !important;
+}
+
+.sketch-picker {
+    background-color: var(--bg-body) !important;
+    border-color: transparent !important;
+    box-shadow: none !important;
+}
+.sketch-picker .flexbox-fix {
+    border-color: var(--line-divider) !important;
+}
+.sketch-picker [id^='rc-editable-input'] {
+    background-color: var(--bg-body) !important;
+    border-color: var(--line-divider) !important;
+    color: var(--text-title) !important;
+    box-shadow: var(--line-border) 0px 0px 0px 1px inset !important;
 }

+ 13 - 0
frontend/resources/translations/en.json

@@ -608,5 +608,18 @@
   "views": {
     "deleteContentTitle": "Are you sure want to delete the {pageType}?",
     "deleteContentCaption": "if you delete this {pageType}, you can restore it from the trash."
+  },
+  "colors": {
+    "custom": "Custom",
+    "default": "Default",
+    "red": "Red",
+    "orange": "Orange",
+    "yellow": "Yellow",
+    "green": "Green",
+    "blue": "Blue",
+    "purple": "Purple",
+    "pink": "Pink",
+    "brown": "Brown",
+    "gray": "Gray"
   }
 }