Browse Source

feat: support add cover and icon in tauri document (#3069)

* feat: support add cover and icon

* feat: emoji picker

* feat: emoji picker
Kilu.He 1 năm trước cách đây
mục cha
commit
eb77346e5a
35 tập tin đã thay đổi với 1273 bổ sung113 xóa
  1. 4 1
      frontend/appflowy_tauri/src/appflowy_app/AppMain.hooks.ts
  2. 133 0
      frontend/appflowy_tauri/src/appflowy_app/components/_shared/EmojiPicker/EmojiPicker.hooks.ts
  3. 98 0
      frontend/appflowy_tauri/src/appflowy_app/components/_shared/EmojiPicker/EmojiPickerCategories.tsx
  4. 121 0
      frontend/appflowy_tauri/src/appflowy_app/components/_shared/EmojiPicker/EmojiPickerHeader.tsx
  5. 33 0
      frontend/appflowy_tauri/src/appflowy_app/components/_shared/EmojiPicker/index.tsx
  6. 2 2
      frontend/appflowy_tauri/src/appflowy_app/components/document/CalloutBlock/CalloutBlock.hooks.ts
  7. 2 3
      frontend/appflowy_tauri/src/appflowy_app/components/document/CalloutBlock/index.tsx
  8. 59 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentTitle/DocumentIcon.tsx
  9. 40 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentTitle/DocumentTitle.hooks.ts
  10. 44 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentTitle/DocumentTopPanel.tsx
  11. 47 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentTitle/TitleButtonGroup.tsx
  12. 35 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentTitle/cover/ChangeColors.tsx
  13. 72 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentTitle/cover/ChangeCoverButton.tsx
  14. 63 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentTitle/cover/ChangeCoverPopover.tsx
  15. 80 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentTitle/cover/ChangeImages.tsx
  16. 85 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentTitle/cover/DocumentCover.tsx
  17. 46 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentTitle/cover/GalleryItem.tsx
  18. 62 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentTitle/cover/GalleryList.tsx
  19. 5 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentTitle/cover/config.ts
  20. 18 4
      frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentTitle/index.tsx
  21. 0 96
      frontend/appflowy_tauri/src/appflowy_app/components/document/ImageBlock/EditImage.tsx
  22. 27 3
      frontend/appflowy_tauri/src/appflowy_app/components/document/ImageBlock/index.tsx
  23. 0 1
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TurnInto/TurnInto.hooks.ts
  24. 53 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/UploadImage/ImageEdit.tsx
  25. 19 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/UploadImage/ImageEditPopover.tsx
  26. 27 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/UploadImage/TabPanel.tsx
  27. 0 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/UploadImage/UploadImage.tsx
  28. 2 1
      frontend/appflowy_tauri/src/appflowy_app/constants/document/config.ts
  29. 5 1
      frontend/appflowy_tauri/src/appflowy_app/interfaces/document.ts
  30. 9 0
      frontend/appflowy_tauri/src/appflowy_app/utils/document/emoji.ts
  31. 41 0
      frontend/appflowy_tauri/src/appflowy_app/utils/document/image.ts
  32. 12 0
      frontend/appflowy_tauri/src/appflowy_app/utils/tool.ts
  33. 1 0
      frontend/appflowy_tauri/src/styles/mui.css
  34. 10 1
      frontend/appflowy_tauri/src/styles/template.css
  35. 18 0
      frontend/resources/translations/en.json

+ 4 - 1
frontend/appflowy_tauri/src/appflowy_app/AppMain.hooks.ts

@@ -5,9 +5,11 @@ import { currentUserActions } from '$app_reducers/current-user/slice';
 import { Theme as ThemeType, Theme, ThemeMode } from '$app/interfaces';
 import { createTheme } from '@mui/material/styles';
 import { getDesignTokens } from '$app/utils/mui';
+import { useTranslation } from 'react-i18next';
 
 export function useUserSetting() {
   const dispatch = useAppDispatch();
+  const { i18n } = useTranslation();
   const currentUser = useAppSelector((state) => state.currentUser);
   const userSettingController = useMemo(() => {
     if (!currentUser?.id) return;
@@ -35,8 +37,9 @@ export function useUserSetting() {
           language: language,
         })
       );
+      i18n.changeLanguage(language);
     });
-  }, [dispatch, userSettingController]);
+  }, [i18n, dispatch, userSettingController]);
 
   const { themeMode = ThemeMode.Light, theme: themeType = ThemeType.Default } = useAppSelector((state) => {
     return state.currentUser.userSetting || {};

+ 133 - 0
frontend/appflowy_tauri/src/appflowy_app/components/_shared/EmojiPicker/EmojiPicker.hooks.ts

@@ -0,0 +1,133 @@
+import { useCallback, useEffect, useRef, useState } from 'react';
+import emojiData, { EmojiMartData } from '@emoji-mart/data';
+import { PopoverProps } from '@mui/material/Popover';
+import { PopoverOrigin } from '@mui/material/Popover/Popover';
+import { useVirtualizer } from '@tanstack/react-virtual';
+import { chunkArray } from '$app/utils/tool';
+
+export interface EmojiCategory {
+  id: string;
+  emojis: Emoji[];
+}
+
+interface Emoji {
+  id: string;
+  name: string;
+  native: string;
+}
+export function useLoadEmojiData({ skin }: { skin: number }) {
+  const [searchValue, setSearchValue] = useState('');
+  const [emojiCategories, setEmojiCategories] = useState<EmojiCategory[]>([]);
+
+  useEffect(() => {
+    const { emojis, categories } = emojiData as EmojiMartData;
+
+    const emojiCategories = categories
+      .map((category) => {
+        const { id, emojis: categoryEmojis } = category;
+
+        return {
+          id,
+          emojis: categoryEmojis
+            .filter((emojiId) => {
+              const emoji = emojis[emojiId];
+
+              if (!searchValue) return true;
+              return filterSearchValue(emoji, searchValue);
+            })
+            .map((emojiId) => {
+              const emoji = emojis[emojiId];
+              const { id, name, skins } = emoji;
+
+              return {
+                id,
+                name,
+                native: skins[skin] ? skins[skin].native : skins[0].native,
+              };
+            }),
+        };
+      })
+      .filter((category) => category.emojis.length > 0);
+
+    setEmojiCategories(emojiCategories);
+  }, [skin, searchValue]);
+
+  return {
+    emojiCategories,
+    skin,
+    setSearchValue,
+    searchValue,
+  };
+}
+
+export function useSelectSkinPopoverProps(): PopoverProps & {
+  onOpen: (event: React.MouseEvent<HTMLButtonElement>) => void;
+  onClose: () => void;
+} {
+  const [anchorEl, setAnchorEl] = useState<HTMLButtonElement | undefined>(undefined);
+  const onOpen = useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
+    setAnchorEl(event.currentTarget);
+  }, []);
+  const onClose = useCallback(() => {
+    setAnchorEl(undefined);
+  }, []);
+  const open = Boolean(anchorEl);
+  const anchorOrigin = { vertical: 'bottom', horizontal: 'center' } as PopoverOrigin;
+  const transformOrigin = { vertical: 'top', horizontal: 'center' } as PopoverOrigin;
+
+  return {
+    anchorEl,
+    onOpen,
+    onClose,
+    open,
+    anchorOrigin,
+    transformOrigin,
+  };
+}
+
+function filterSearchValue(emoji: emojiData.Emoji, searchValue: string) {
+  const { name, keywords } = emoji;
+  const searchValueLowerCase = searchValue.toLowerCase();
+
+  return (
+    name.toLowerCase().includes(searchValueLowerCase) ||
+    (keywords && keywords.some((keyword) => keyword.toLowerCase().includes(searchValueLowerCase)))
+  );
+}
+
+export function getRowsWithCategories(emojiCategories: EmojiCategory[], rowSize: number) {
+  const rows: {
+    id: string;
+    type: 'category' | 'emojis';
+    emojis?: Emoji[];
+  }[] = [];
+
+  emojiCategories.forEach((category) => {
+    rows.push({
+      id: category.id,
+      type: 'category',
+    });
+    chunkArray(category.emojis, rowSize).forEach((chunk, index) => {
+      rows.push({
+        type: 'emojis',
+        emojis: chunk,
+        id: `${category.id}-${index}`,
+      });
+    });
+  });
+  return rows;
+}
+
+export function useVirtualizedCategories({ count }: { count: number }) {
+  const ref = useRef<HTMLDivElement>(null);
+  const virtualize = useVirtualizer({
+    count,
+    getScrollElement: () => ref.current,
+    estimateSize: () => {
+      return 60;
+    },
+    overscan: 3,
+  });
+
+  return { virtualize, ref };
+}

+ 98 - 0
frontend/appflowy_tauri/src/appflowy_app/components/_shared/EmojiPicker/EmojiPickerCategories.tsx

@@ -0,0 +1,98 @@
+import React, { useCallback, useMemo } from 'react';
+import {
+  EmojiCategory,
+  getRowsWithCategories,
+  useVirtualizedCategories,
+} from '$app/components/_shared/EmojiPicker/EmojiPicker.hooks';
+import { useTranslation } from 'react-i18next';
+import { IconButton } from '@mui/material';
+
+function EmojiPickerCategories({
+  emojiCategories,
+  onEmojiSelect,
+}: {
+  emojiCategories: EmojiCategory[];
+  onEmojiSelect: (emoji: string) => void;
+}) {
+  const { t } = useTranslation();
+  const rows = useMemo(() => {
+    return getRowsWithCategories(emojiCategories, 13);
+  }, [emojiCategories]);
+
+  const { ref, virtualize } = useVirtualizedCategories({
+    count: rows.length,
+  });
+  const virtualItems = virtualize.getVirtualItems();
+
+  const getCategoryName = useCallback(
+    (id: string) => {
+      const i18nName: Record<string, string> = {
+        people: t('emoji.categories.people'),
+        nature: t('emoji.categories.nature'),
+        foods: t('emoji.categories.food'),
+        activity: t('emoji.categories.activities'),
+        places: t('emoji.categories.places'),
+        objects: t('emoji.categories.objects'),
+        symbols: t('emoji.categories.symbols'),
+        flags: t('emoji.categories.flags'),
+      };
+
+      return i18nName[id];
+    },
+    [t]
+  );
+
+  return (
+    <div ref={ref} className={'mt-2 w-[416px] flex-1 items-center justify-center overflow-y-auto overflow-x-hidden'}>
+      <div
+        style={{
+          height: virtualize.getTotalSize(),
+          position: 'relative',
+        }}
+        className={'mx-1'}
+      >
+        {virtualItems.length ? (
+          <div
+            style={{
+              position: 'absolute',
+              top: 0,
+              left: 0,
+              width: '100%',
+              transform: `translateY(${virtualItems[0].start || 0}px)`,
+            }}
+          >
+            {virtualItems.map(({ index }) => {
+              const item = rows[index];
+
+              return (
+                <div data-index={index} ref={virtualize.measureElement} key={item.id} className={'flex flex-col'}>
+                  {item.type === 'category' ? (
+                    <div className={'p-2 text-sm font-medium text-text-caption'}>{getCategoryName(item.id)}</div>
+                  ) : null}
+                  <div className={'flex'}>
+                    {item.emojis?.map((emoji) => {
+                      return (
+                        <div key={emoji.id} className={'flex h-[32px] w-[32px] items-center justify-center'}>
+                          <IconButton
+                            size={'small'}
+                            onClick={() => {
+                              onEmojiSelect(emoji.native);
+                            }}
+                          >
+                            {emoji.native}
+                          </IconButton>
+                        </div>
+                      );
+                    })}
+                  </div>
+                </div>
+              );
+            })}
+          </div>
+        ) : null}
+      </div>
+    </div>
+  );
+}
+
+export default EmojiPickerCategories;

+ 121 - 0
frontend/appflowy_tauri/src/appflowy_app/components/_shared/EmojiPicker/EmojiPickerHeader.tsx

@@ -0,0 +1,121 @@
+import React from 'react';
+import { Box, IconButton } from '@mui/material';
+import { DeleteOutlineRounded, SearchOutlined } from '@mui/icons-material';
+import TextField from '@mui/material/TextField';
+import Tooltip from '@mui/material/Tooltip';
+import { randomEmoji } from '$app/utils/document/emoji';
+import ShuffleIcon from '@mui/icons-material/Shuffle';
+import Popover from '@mui/material/Popover';
+import { useSelectSkinPopoverProps } from '$app/components/_shared/EmojiPicker/EmojiPicker.hooks';
+import { useTranslation } from 'react-i18next';
+
+const skinTones = [
+  {
+    value: 0,
+    label: '✋',
+  },
+  {
+    label: '✋🏻',
+    value: 1,
+  },
+  {
+    label: '✋🏼',
+    value: 2,
+  },
+  {
+    label: '✋🏽',
+    value: 3,
+  },
+  {
+    label: '✋🏾',
+    value: 4,
+  },
+  {
+    label: '✋🏿',
+    value: 5,
+  },
+];
+
+interface Props {
+  onEmojiSelect: (emoji: string) => void;
+  skin: number;
+  onSkinSelect: (skin: number) => void;
+  searchValue: string;
+  onSearchChange: (value: string) => void;
+}
+
+function EmojiPickerHeader({ onEmojiSelect, onSkinSelect, searchValue, onSearchChange, skin }: Props) {
+  const { onOpen, ...popoverProps } = useSelectSkinPopoverProps();
+  const { t } = useTranslation();
+
+  return (
+    <div className={'h-[45px] px-0.5'}>
+      <div className={'search-input flex items-end'}>
+        <Box sx={{ display: 'flex', alignItems: 'flex-end', marginRight: 2 }}>
+          <SearchOutlined sx={{ color: 'action.active', mr: 1, my: 0.5 }} />
+          <TextField
+            value={searchValue}
+            onChange={(e) => {
+              onSearchChange(e.target.value);
+            }}
+            label={t('search.label')}
+            variant='standard'
+          />
+        </Box>
+        <Tooltip title={t('emoji.random')}>
+          <div className={'random-emoji-btn mr-2 rounded border border-line-divider'}>
+            <IconButton
+              onClick={() => {
+                const emoji = randomEmoji();
+
+                onEmojiSelect(emoji);
+              }}
+            >
+              <ShuffleIcon />
+            </IconButton>
+          </div>
+        </Tooltip>
+        <Tooltip title={t('emoji.selectSkinTone')}>
+          <div className={'random-emoji-btn mr-2 rounded border border-line-divider'}>
+            <IconButton size={'small'} className={'h-[25px] w-[25px]'} onClick={onOpen}>
+              {skinTones[skin].label}
+            </IconButton>
+          </div>
+        </Tooltip>
+        <Tooltip title={t('emoji.remove')}>
+          <div className={'random-emoji-btn rounded border border-line-divider'}>
+            <IconButton
+              onClick={() => {
+                onEmojiSelect('');
+              }}
+            >
+              <DeleteOutlineRounded />
+            </IconButton>
+          </div>
+        </Tooltip>
+      </div>
+      <Popover {...popoverProps}>
+        <div className={'flex items-center p-2'}>
+          {skinTones.map((skinTone) => (
+            <div className={'mx-0.5'} key={skinTone.value}>
+              <IconButton
+                style={{
+                  backgroundColor: skinTone.value === skin ? 'var(--fill-list-hover)' : 'transparent',
+                }}
+                size={'small'}
+                onClick={() => {
+                  onSkinSelect(skinTone.value);
+                  popoverProps.onClose?.();
+                }}
+              >
+                {skinTone.label}
+              </IconButton>
+            </div>
+          ))}
+        </div>
+      </Popover>
+    </div>
+  );
+}
+
+export default EmojiPickerHeader;

+ 33 - 0
frontend/appflowy_tauri/src/appflowy_app/components/_shared/EmojiPicker/index.tsx

@@ -0,0 +1,33 @@
+import React, { useState } from 'react';
+
+import { useLoadEmojiData } from '$app/components/_shared/EmojiPicker/EmojiPicker.hooks';
+
+import EmojiPickerHeader from '$app/components/_shared/EmojiPicker/EmojiPickerHeader';
+import EmojiPickerCategories from '$app/components/_shared/EmojiPicker/EmojiPickerCategories';
+
+interface Props {
+  onEmojiSelect: (emoji: string) => void;
+}
+
+function EmojiPickerComponent({ onEmojiSelect }: Props) {
+  const [skin, setSkin] = useState(0);
+
+  const { emojiCategories, setSearchValue, searchValue } = useLoadEmojiData({
+    skin,
+  });
+
+  return (
+    <div className={'emoji-picker flex h-[360px] max-h-[70vh] flex-col p-4 pt-2'}>
+      <EmojiPickerHeader
+        onEmojiSelect={onEmojiSelect}
+        skin={skin}
+        onSkinSelect={setSkin}
+        searchValue={searchValue}
+        onSearchChange={setSearchValue}
+      />
+      <EmojiPickerCategories onEmojiSelect={onEmojiSelect} emojiCategories={emojiCategories} />
+    </div>
+  );
+}
+
+export default EmojiPickerComponent;

+ 2 - 2
frontend/appflowy_tauri/src/appflowy_app/components/document/CalloutBlock/CalloutBlock.hooks.ts

@@ -19,14 +19,14 @@ export function useCalloutBlock(nodeId: string) {
   }, []);
 
   const onEmojiSelect = useCallback(
-    (emoji: { native: string }) => {
+    (emoji: string) => {
       if (!controller) return;
       void dispatch(
         updateNodeDataThunk({
           id: nodeId,
           controller,
           data: {
-            icon: emoji.native,
+            icon: emoji,
           },
         })
       );

+ 2 - 3
frontend/appflowy_tauri/src/appflowy_app/components/document/CalloutBlock/index.tsx

@@ -2,10 +2,9 @@ import { BlockType, NestedBlock } from '$app/interfaces/document';
 import TextBlock from '$app/components/document/TextBlock';
 import NodeChildren from '$app/components/document/Node/NodeChildren';
 import { IconButton } from '@mui/material';
-import emojiData from '@emoji-mart/data';
-import Picker from '@emoji-mart/react';
 import { useCalloutBlock } from '$app/components/document/CalloutBlock/CalloutBlock.hooks';
 import Popover from '@mui/material/Popover';
+import EmojiPicker from '$app/components/_shared/EmojiPicker';
 
 export default function CalloutBlock({
   node,
@@ -38,7 +37,7 @@ export default function CalloutBlock({
               horizontal: 'left',
             }}
           >
-            <Picker searchPosition={'static'} locale={'en'} autoFocus data={emojiData} onEmojiSelect={onEmojiSelect} />
+            <EmojiPicker onEmojiSelect={onEmojiSelect} />
           </Popover>
         </div>
       </div>

+ 59 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentTitle/DocumentIcon.tsx

@@ -0,0 +1,59 @@
+import React, { useCallback, useState } from 'react';
+import Popover from '@mui/material/Popover';
+import EmojiPicker from '$app/components/_shared/EmojiPicker';
+
+function DocumentIcon({
+  icon,
+  className,
+  onUpdateIcon,
+}: {
+  icon?: string;
+  className?: string;
+  onUpdateIcon: (icon: string) => void;
+}) {
+  const [anchorPosition, setAnchorPosition] = useState<
+    | undefined
+    | {
+        top: number;
+        left: number;
+      }
+  >(undefined);
+  const open = Boolean(anchorPosition);
+  const onOpen = useCallback((event: React.MouseEvent<HTMLDivElement>) => {
+    const rect = event.currentTarget.getBoundingClientRect();
+
+    setAnchorPosition({
+      top: rect.top + rect.height,
+      left: rect.left,
+    });
+  }, []);
+
+  const onEmojiSelect = useCallback(
+    (emoji: string) => {
+      onUpdateIcon(emoji);
+      setAnchorPosition(undefined);
+    },
+    [onUpdateIcon]
+  );
+
+  if (!icon) return null;
+  return (
+    <>
+      <div className={`absolute bottom-0 left-0 pt-[20px] ${className}`}>
+        <div onClick={onOpen} className={'h-full w-full cursor-pointer rounded text-6xl hover:text-7xl'}>
+          {icon}
+        </div>
+      </div>
+      <Popover
+        open={open}
+        anchorReference='anchorPosition'
+        anchorPosition={anchorPosition}
+        onClose={() => setAnchorPosition(undefined)}
+      >
+        <EmojiPicker onEmojiSelect={onEmojiSelect} />
+      </Popover>
+    </>
+  );
+}
+
+export default DocumentIcon;

+ 40 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentTitle/DocumentTitle.hooks.ts

@@ -1,7 +1,47 @@
 import { useSubscribeNode } from '../_shared/SubscribeNode.hooks';
+import { useCallback } from 'react';
+import { updateNodeDataThunk } from '$app_reducers/document/async-actions';
+import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
+import { useAppDispatch } from '$app/stores/store';
+
 export function useDocumentTitle(id: string) {
   const { node } = useSubscribeNode(id);
+  const { controller } = useSubscribeDocument();
+  const dispatch = useAppDispatch();
+  const onUpdateIcon = useCallback(
+    (icon: string) => {
+      dispatch(
+        updateNodeDataThunk({
+          id,
+          data: {
+            icon,
+          },
+          controller,
+        })
+      );
+    },
+    [controller, dispatch, id]
+  );
+
+  const onUpdateCover = useCallback(
+    (coverType: 'image' | 'color' | '', cover: string) => {
+      dispatch(
+        updateNodeDataThunk({
+          id,
+          data: {
+            cover,
+            coverType,
+          },
+          controller,
+        })
+      );
+    },
+    [controller, dispatch, id]
+  );
+
   return {
     node,
+    onUpdateCover,
+    onUpdateIcon,
   };
 }

+ 44 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentTitle/DocumentTopPanel.tsx

@@ -0,0 +1,44 @@
+import React, { useEffect, useMemo } from 'react';
+import { BlockType, NestedBlock } from '$app/interfaces/document';
+import DocumentCover from '$app/components/document/DocumentTitle/cover/DocumentCover';
+import DocumentIcon from '$app/components/document/DocumentTitle/DocumentIcon';
+
+const heightCls = {
+  cover: 'h-[220px]',
+  icon: 'h-[80px]',
+  coverAndIcon: 'h-[250px]',
+  none: 'h-0',
+};
+
+function DocumentTopPanel({
+  node,
+  onUpdateCover,
+  onUpdateIcon,
+}: {
+  node: NestedBlock<BlockType.PageBlock>;
+  onUpdateCover: (coverType: 'image' | 'color' | '', cover: string) => void;
+  onUpdateIcon: (icon: string) => void;
+}) {
+  const { cover, coverType, icon } = node.data;
+
+  const className = useMemo(() => {
+    if (cover && icon) return heightCls.coverAndIcon;
+    if (cover) return heightCls.cover;
+    if (icon) return heightCls.icon;
+    return heightCls.none;
+  }, [cover, icon]);
+
+  return (
+    <div
+      style={{
+        display: icon || cover ? 'block' : 'none',
+      }}
+      className={`relative ${className}`}
+    >
+      <DocumentCover onUpdateCover={onUpdateCover} className={heightCls.cover} cover={cover} coverType={coverType} />
+      <DocumentIcon onUpdateIcon={onUpdateIcon} className={heightCls.icon} icon={icon} />
+    </div>
+  );
+}
+
+export default DocumentTopPanel;

+ 47 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentTitle/TitleButtonGroup.tsx

@@ -0,0 +1,47 @@
+import React, { useCallback } from 'react';
+import Button from '@mui/material/Button';
+import { useTranslation } from 'react-i18next';
+import { EmojiEmotionsOutlined, ImageOutlined } from '@mui/icons-material';
+import { BlockType, NestedBlock } from '$app/interfaces/document';
+import { randomColor } from '$app/components/document/DocumentTitle/cover/config';
+import { randomEmoji } from '$app/utils/document/emoji';
+
+interface Props {
+  node: NestedBlock<BlockType.PageBlock>;
+  onUpdateCover: (coverType: 'image' | 'color', cover: string) => void;
+  onUpdateIcon: (icon: string) => void;
+}
+function TitleButtonGroup({ onUpdateIcon, onUpdateCover, node }: Props) {
+  const { t } = useTranslation();
+  const showAddIcon = !node.data.icon;
+  const showAddCover = !node.data.cover;
+
+  const onAddIcon = useCallback(() => {
+    const emoji = randomEmoji();
+
+    onUpdateIcon(emoji);
+  }, [onUpdateIcon]);
+
+  const onAddCover = useCallback(() => {
+    const color = randomColor();
+
+    onUpdateCover('color', color);
+  }, [onUpdateCover]);
+
+  return (
+    <div className={'flex items-center py-2'}>
+      {showAddIcon && (
+        <Button onClick={onAddIcon} color={'inherit'} startIcon={<EmojiEmotionsOutlined />}>
+          {t('document.plugins.cover.addIcon')}
+        </Button>
+      )}
+      {showAddCover && (
+        <Button onClick={onAddCover} color={'inherit'} startIcon={<ImageOutlined />}>
+          {t('document.plugins.cover.addCover')}
+        </Button>
+      )}
+    </div>
+  );
+}
+
+export default TitleButtonGroup;

+ 35 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentTitle/cover/ChangeColors.tsx

@@ -0,0 +1,35 @@
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+import { colors } from './config';
+
+function ChangeColors({ cover, onChange }: { cover: string; onChange: (color: string) => void }) {
+  const { t } = useTranslation();
+
+  return (
+    <div className={'flex flex-col'}>
+      <div className={'p-2 pb-4 text-text-caption'}>{t('document.plugins.cover.colors')}</div>
+      <div className={'flex flex-wrap'}>
+        {colors.map((color) => (
+          <div
+            onClick={() => onChange(color)}
+            key={color}
+            style={{ backgroundColor: color }}
+            className={`m-1 flex h-[20px] w-[20px] cursor-pointer items-center justify-center rounded-[50%]`}
+          >
+            {cover === color && (
+              <div
+                style={{
+                  borderColor: '#fff',
+                  backgroundColor: color,
+                }}
+                className={'h-[16px] w-[calc(16px)] rounded-[50%] border-[2px] border-solid'}
+              />
+            )}
+          </div>
+        ))}
+      </div>
+    </div>
+  );
+}
+
+export default ChangeColors;

+ 72 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentTitle/cover/ChangeCoverButton.tsx

@@ -0,0 +1,72 @@
+import React, { useCallback, useState } from 'react';
+import { DeleteOutlineRounded } from '@mui/icons-material';
+import { useTranslation } from 'react-i18next';
+import { ButtonGroup } from '@mui/material';
+import Button from '@mui/material/Button';
+import ChangeCoverPopover from '$app/components/document/DocumentTitle/cover/ChangeCoverPopover';
+
+function ChangeCoverButton({
+  visible,
+  cover,
+  coverType,
+  onUpdateCover,
+}: {
+  visible: boolean;
+  cover: string;
+  coverType: 'image' | 'color';
+  onUpdateCover: (coverType: 'image' | 'color' | '', cover: string) => void;
+}) {
+  const { t } = useTranslation();
+  const [anchorPosition, setAnchorPosition] = useState<undefined | { top: number; left: number }>(undefined);
+  const open = Boolean(anchorPosition);
+  const onClose = useCallback(() => {
+    setAnchorPosition(undefined);
+  }, []);
+  const onOpen = useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
+    const rect = event.currentTarget.getBoundingClientRect();
+
+    setAnchorPosition({
+      top: rect.top + rect.height,
+      left: rect.left + rect.width + 40,
+    });
+  }, []);
+
+  const onDeleteCover = useCallback(() => {
+    onUpdateCover('', '');
+  }, [onUpdateCover]);
+
+  return (
+    <>
+      {visible && (
+        <div className={'absolute bottom-4 right-6 flex text-[0.7rem]'}>
+          <button
+            onClick={onOpen}
+            className={
+              'flex items-center rounded-md border border-line-divider bg-bg-body p-1 px-2 opacity-70 hover:opacity-100'
+            }
+          >
+            {t('document.plugins.cover.changeCover')}
+          </button>
+          <button
+            className={
+              'ml-2 flex items-center rounded-md border border-line-divider bg-bg-body p-1 opacity-70 hover:opacity-100'
+            }
+            onClick={onDeleteCover}
+          >
+            <DeleteOutlineRounded />
+          </button>
+        </div>
+      )}
+      <ChangeCoverPopover
+        cover={cover}
+        coverType={coverType}
+        open={open}
+        anchorPosition={anchorPosition}
+        onClose={onClose}
+        onUpdateCover={onUpdateCover}
+      />
+    </>
+  );
+}
+
+export default ChangeCoverButton;

+ 63 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentTitle/cover/ChangeCoverPopover.tsx

@@ -0,0 +1,63 @@
+import React, { useCallback, useEffect, useRef, useState } from 'react';
+import Popover, { PopoverActions } from '@mui/material/Popover';
+import ChangeColors from '$app/components/document/DocumentTitle/cover/ChangeColors';
+import ChangeImages from '$app/components/document/DocumentTitle/cover/ChangeImages';
+import { useAppDispatch } from '$app/stores/store';
+import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
+
+function ChangeCoverPopover({
+  open,
+  anchorPosition,
+  onClose,
+  coverType,
+  cover,
+  onUpdateCover,
+}: {
+  open: boolean;
+  anchorPosition?: { top: number; left: number };
+  onClose: () => void;
+  coverType: 'image' | 'color';
+  cover: string;
+  onUpdateCover: (coverType: 'image' | 'color', cover: string) => void;
+}) {
+  const ref = useRef<HTMLDivElement>(null);
+
+  return (
+    <Popover
+      open={open}
+      anchorReference={'anchorPosition'}
+      anchorPosition={anchorPosition}
+      onClose={onClose}
+      transformOrigin={{
+        vertical: 'top',
+        horizontal: 'right',
+      }}
+      PaperProps={{
+        sx: {
+          height: 'auto',
+          overflow: 'visible',
+        },
+        elevation: 0,
+      }}
+    >
+      <div
+        style={{
+          boxShadow:
+            '0px 5px 5px -3px rgba(0,0,0,0.2),0px 8px 10px 1px rgba(0,0,0,0.14),0px 3px 14px 2px rgba(0,0,0,0.12)',
+        }}
+        className={'flex flex-col rounded-md bg-bg-body p-4 '}
+        ref={ref}
+      >
+        <ChangeColors
+          onChange={(color) => {
+            onUpdateCover('color', color);
+          }}
+          cover={cover}
+        />
+        <ChangeImages cover={cover} onChange={(url) => onUpdateCover('image', url)} />
+      </div>
+    </Popover>
+  );
+}
+
+export default ChangeCoverPopover;

+ 80 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentTitle/cover/ChangeImages.tsx

@@ -0,0 +1,80 @@
+import React, { useCallback, useEffect, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+
+import GalleryList from '$app/components/document/DocumentTitle/cover/GalleryList';
+import Button from '@mui/material/Button';
+import { readCoverImageUrls, readImage, writeCoverImageUrls } from '$app/utils/document/image';
+import { Log } from '$app/utils/log';
+import { Image } from '$app/components/document/DocumentTitle/cover/GalleryItem';
+
+function ChangeImages({ cover, onChange }: { onChange: (url: string) => void; cover: string }) {
+  const { t } = useTranslation();
+  const [images, setImages] = useState<Image[]>([]);
+  const loadImageUrls = useCallback(async () => {
+    try {
+      const { images } = await readCoverImageUrls();
+      const newImages = [];
+
+      for (const image of images) {
+        try {
+          const src = await readImage(image.url);
+
+          newImages.push({ ...image, src });
+        } catch (e) {
+          Log.error(e);
+        }
+      }
+
+      setImages(newImages);
+    } catch (e) {
+      Log.error(e);
+    }
+  }, [setImages]);
+
+  const onAddImage = useCallback(
+    async (url: string) => {
+      const { images } = await readCoverImageUrls();
+
+      await writeCoverImageUrls([...images, { url }]);
+      await loadImageUrls();
+    },
+    [loadImageUrls]
+  );
+
+  const onDelete = useCallback(
+    async (image: Image) => {
+      const { images } = await readCoverImageUrls();
+      const newImages = images.filter((i) => i.url !== image.url);
+
+      await writeCoverImageUrls(newImages);
+      await loadImageUrls();
+    },
+    [loadImageUrls]
+  );
+
+  const onClearAll = useCallback(async () => {
+    await writeCoverImageUrls([]);
+    await loadImageUrls();
+  }, [loadImageUrls]);
+
+  useEffect(() => {
+    loadImageUrls();
+  }, [loadImageUrls]);
+
+  return (
+    <div className={'flex w-[500px] flex-col'}>
+      <div className={'flex justify-between pb-2 pl-2 pt-4 text-text-caption'}>
+        <div>{t('document.plugins.cover.images')}</div>
+        <Button onClick={onClearAll}>{t('document.plugins.cover.clearAll')}</Button>
+      </div>
+      <GalleryList
+        images={images}
+        onDelete={onDelete}
+        onAddImage={onAddImage}
+        onSelected={(image) => onChange(image.url)}
+      />
+    </div>
+  );
+}
+
+export default ChangeImages;

+ 85 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentTitle/cover/DocumentCover.tsx

@@ -0,0 +1,85 @@
+import React, { useCallback, useEffect, useState } from 'react';
+import ChangeCoverButton from '$app/components/document/DocumentTitle/cover/ChangeCoverButton';
+import { readImage } from '$app/utils/document/image';
+
+function DocumentCover({
+  cover,
+  coverType,
+  className,
+  onUpdateCover,
+}: {
+  cover?: string;
+  coverType?: 'image' | 'color';
+  className?: string;
+  onUpdateCover: (coverType: 'image' | 'color' | '', cover: string) => void;
+}) {
+  const [hover, setHover] = useState(false);
+  const [leftOffset, setLeftOffset] = useState(0);
+  const [width, setWidth] = useState(0);
+  const [coverSrc, setCoverSrc] = useState<string | undefined>();
+  const calcLeftOffset = useCallback((bodyOffsetLeft: number) => {
+    const docTitle = document.querySelector('.doc-title') as HTMLElement;
+
+    if (!docTitle) {
+      setLeftOffset(0);
+      return;
+    }
+
+    const titleOffsetLeft = docTitle.getBoundingClientRect().left;
+
+    setLeftOffset(titleOffsetLeft - bodyOffsetLeft);
+  }, []);
+
+  const handleWidthChange: ResizeObserverCallback = useCallback(
+    (entries) => {
+      entries.forEach((entry) => {
+        const { width } = entry.contentRect;
+
+        setWidth(width);
+        const left = entry.target.getBoundingClientRect().left;
+
+        calcLeftOffset(left);
+      });
+    },
+    [calcLeftOffset]
+  );
+
+  useEffect(() => {
+    const observer = new ResizeObserver(handleWidthChange);
+    const docPage = document.getElementById('appflowy-block-doc') as HTMLElement;
+
+    observer.observe(docPage);
+    return () => {
+      observer.disconnect();
+    };
+  }, [handleWidthChange]);
+
+  useEffect(() => {
+    if (coverType === 'image' && cover) {
+      void (async () => {
+        const src = await readImage(cover);
+
+        setCoverSrc(src);
+      })();
+    }
+  }, [cover, coverType]);
+
+  if (!cover || !coverType) return null;
+  return (
+    <div
+      onMouseEnter={() => setHover(true)}
+      onMouseLeave={() => setHover(false)}
+      style={{
+        left: -leftOffset,
+        width,
+      }}
+      className={`absolute top-0 w-full overflow-hidden ${className}`}
+    >
+      {coverType === 'image' && <img src={coverSrc} className={'h-full w-full object-cover'} />}
+      {coverType === 'color' && <div className={'h-full w-full'} style={{ backgroundColor: cover }} />}
+      <ChangeCoverButton onUpdateCover={onUpdateCover} visible={hover} cover={cover} coverType={coverType} />
+    </div>
+  );
+}
+
+export default React.memo(DocumentCover);

+ 46 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentTitle/cover/GalleryItem.tsx

@@ -0,0 +1,46 @@
+import React, { useState } from 'react';
+import { DeleteOutlineRounded } from '@mui/icons-material';
+import ImageListItem from '@mui/material/ImageListItem';
+
+export interface Image {
+  url: string;
+  src?: string;
+}
+function GalleryItem({ image, onSelected, onDelete }: { image: Image; onSelected: () => void; onDelete: () => void }) {
+  const [hover, setHover] = useState(false);
+
+  return (
+    <ImageListItem
+      onMouseEnter={() => setHover(true)}
+      onMouseLeave={() => setHover(false)}
+      className={'flex items-center justify-center '}
+      key={image.url}
+    >
+      <div className={'flex h-[80px] w-[120px] cursor-pointer items-center justify-center  overflow-hidden rounded'}>
+        <img
+          style={{
+            objectFit: 'cover',
+            width: '100%',
+            height: '100%',
+          }}
+          onClick={onSelected}
+          src={`${image.src}`}
+          alt={image.url}
+        />
+      </div>
+
+      <div
+        style={{
+          display: hover ? 'block' : 'none',
+        }}
+        className={'absolute right-2 top-2'}
+      >
+        <button className={'rounded bg-bg-body opacity-80 hover:opacity-100'} onClick={() => onDelete()}>
+          <DeleteOutlineRounded />
+        </button>
+      </div>
+    </ImageListItem>
+  );
+}
+
+export default GalleryItem;

+ 62 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentTitle/cover/GalleryList.tsx

@@ -0,0 +1,62 @@
+import React, { useCallback, useState } from 'react';
+import ImageList from '@mui/material/ImageList';
+import ImageListItem from '@mui/material/ImageListItem';
+import { AddOutlined } from '@mui/icons-material';
+import { useTranslation } from 'react-i18next';
+
+import Dialog from '@mui/material/Dialog';
+import DialogTitle from '@mui/material/DialogTitle';
+import ImageEdit from '$app/components/document/_shared/UploadImage/ImageEdit';
+import GalleryItem, { Image } from '$app/components/document/DocumentTitle/cover/GalleryItem';
+
+interface Props {
+  onSelected: (image: Image) => void;
+  images: Image[];
+  onDelete: (image: Image) => Promise<void>;
+  onAddImage: (url: string) => Promise<void>;
+}
+function GalleryList({ images, onSelected, onDelete, onAddImage }: Props) {
+  const { t } = useTranslation();
+  const [showEdit, setShowEdit] = useState(false);
+  const onExitEdit = useCallback(() => {
+    setShowEdit(false);
+  }, []);
+
+  return (
+    <>
+      <ImageList className={'max-h-[172px] w-full overflow-auto'} cols={4}>
+        <ImageListItem>
+          <div
+            className={
+              'm-1 flex h-[80px] w-[120px] cursor-pointer items-center justify-center rounded border border-fill-default bg-content-blue-50 text-fill-default hover:bg-content-blue-100'
+            }
+            onClick={() => setShowEdit(true)}
+          >
+            <AddOutlined />
+          </div>
+        </ImageListItem>
+        {images.map((image) => {
+          return (
+            <GalleryItem
+              key={image.url}
+              image={image}
+              onSelected={() => onSelected(image)}
+              onDelete={() => onDelete(image)}
+            />
+          );
+        })}
+      </ImageList>
+      <Dialog open={showEdit} onClose={onExitEdit} fullWidth>
+        <DialogTitle>{t('button.upload')}</DialogTitle>
+        <ImageEdit
+          onSubmitUrl={async (url) => {
+            await onAddImage(url);
+            onExitEdit();
+          }}
+        />
+      </Dialog>
+    </>
+  );
+}
+
+export default GalleryList;

+ 5 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentTitle/cover/config.ts

@@ -0,0 +1,5 @@
+export const colors = ['#e1fbff', '#defff1', '#ddffd6', '#f5ffdc', '#fff2cd', '#ffefe3', '#ffe7ee', '#e8e0ff'];
+
+export const randomColor = () => {
+  return colors[Math.floor(Math.random() * colors.length)];
+};

+ 18 - 4
frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentTitle/index.tsx

@@ -1,16 +1,30 @@
-import React from 'react';
+import React, { useState } from 'react';
 import { useDocumentTitle } from './DocumentTitle.hooks';
 import TextBlock from '../TextBlock';
 import { useTranslation } from 'react-i18next';
+import TitleButtonGroup from './TitleButtonGroup';
+import DocumentTopPanel from './DocumentTopPanel';
 
 export default function DocumentTitle({ id }: { id: string }) {
-  const { node } = useDocumentTitle(id);
+  const { node, onUpdateCover, onUpdateIcon } = useDocumentTitle(id);
   const { t } = useTranslation();
+  const [hover, setHover] = useState(false);
 
   if (!node) return null;
+
   return (
-    <div data-block-id={node.id} className='doc-title relative mb-2 pt-[50px] text-4xl font-bold'>
-      <TextBlock placeholder={t('document.title.placeholder')} childIds={[]} node={node} />
+    <div className={'flex flex-col'} onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)}>
+      <DocumentTopPanel onUpdateCover={onUpdateCover} onUpdateIcon={onUpdateIcon} node={node} />
+      <div
+        style={{
+          opacity: hover ? 1 : 0,
+        }}
+      >
+        <TitleButtonGroup node={node} onUpdateCover={onUpdateCover} onUpdateIcon={onUpdateIcon} />
+      </div>
+      <div data-block-id={node.id} className='doc-title relative text-4xl font-bold'>
+        <TextBlock placeholder={t('document.title.placeholder')} childIds={[]} node={node} />
+      </div>
     </div>
   );
 }

+ 0 - 96
frontend/appflowy_tauri/src/appflowy_app/components/document/ImageBlock/EditImage.tsx

@@ -1,96 +0,0 @@
-import React, { useCallback, useState } from 'react';
-import { Button, TextField, Tabs, Tab, Box } from '@mui/material';
-import { useAppDispatch } from '$app/stores/store';
-import { updateNodeDataThunk } from '$app_reducers/document/async-actions';
-import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
-import UploadImage from '$app/components/document/_shared/UploadImage';
-import { useTranslation } from 'react-i18next';
-
-enum TAB_KEYS {
-  UPLOAD = 'upload',
-  LINK = 'link',
-}
-
-function EditImage({ id, url, onClose }: { id: string; url: string; onClose: () => void }) {
-  const dispatch = useAppDispatch();
-  const { t } = useTranslation();
-  const { controller } = useSubscribeDocument();
-  const [linkVal, setLinkVal] = useState<string>(url);
-  const [tabKey, setTabKey] = useState<TAB_KEYS>(TAB_KEYS.UPLOAD);
-  const handleChange = useCallback((_: React.SyntheticEvent, newValue: TAB_KEYS) => {
-    setTabKey(newValue);
-  }, []);
-
-  const handleConfirmUrl = useCallback(
-    (url: string) => {
-      if (!url) return;
-      dispatch(
-        updateNodeDataThunk({
-          id,
-          data: {
-            url,
-          },
-          controller,
-        })
-      );
-      onClose();
-    },
-    [onClose, dispatch, id, controller]
-  );
-
-  return (
-    <div className={'w-[540px]'}>
-      <Box sx={{ borderBottom: 1, borderColor: 'divider' }}>
-        <Tabs value={tabKey} onChange={handleChange}>
-          <Tab label={t('document.imageBlock.upload.label')} value={TAB_KEYS.UPLOAD} />
-
-          <Tab label={t('document.imageBlock.url.label')} value={TAB_KEYS.LINK} />
-        </Tabs>
-      </Box>
-      <TabPanel value={tabKey} index={TAB_KEYS.UPLOAD}>
-        <UploadImage onChange={handleConfirmUrl} />
-      </TabPanel>
-
-      <TabPanel className={'flex flex-col p-3'} value={tabKey} index={TAB_KEYS.LINK}>
-        <TextField
-          value={linkVal}
-          onChange={(e) => setLinkVal(e.target.value)}
-          variant='outlined'
-          label={t('document.imageBlock.url.label')}
-          autoFocus={true}
-          style={{
-            marginBottom: '10px',
-          }}
-          placeholder={t('document.imageBlock.url.placeholder')}
-        />
-        <Button onClick={() => handleConfirmUrl(linkVal)} variant='contained'>
-          {t('button.upload')}
-        </Button>
-      </TabPanel>
-    </div>
-  );
-}
-
-export default EditImage;
-
-interface TabPanelProps {
-  children?: React.ReactNode;
-  index: TAB_KEYS;
-  value: TAB_KEYS;
-}
-
-function TabPanel(props: TabPanelProps & React.HTMLAttributes<HTMLDivElement>) {
-  const { children, value, index, ...other } = props;
-
-  return (
-    <div
-      role='tabpanel'
-      hidden={value !== index}
-      id={`image-tabpanel-${index}`}
-      aria-labelledby={`image-tab-${index}`}
-      {...other}
-    >
-      {value === index && children}
-    </div>
-  );
-}

+ 27 - 3
frontend/appflowy_tauri/src/appflowy_app/components/document/ImageBlock/index.tsx

@@ -1,20 +1,44 @@
 import React, { useCallback } from 'react';
 import { BlockType, NestedBlock } from '$app/interfaces/document';
 import { useImageBlock } from './useImageBlock';
-import EditImage from '$app/components/document/ImageBlock/EditImage';
 import { useBlockPopover } from '$app/components/document/_shared/BlockPopover/BlockPopover.hooks';
 import ImagePlaceholder from '$app/components/document/ImageBlock/ImagePlaceholder';
 import ImageRender from '$app/components/document/ImageBlock/ImageRender';
+import { useAppDispatch } from '$app/stores/store';
+import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
+import { updateNodeDataThunk } from '$app_reducers/document/async-actions';
+import ImageEdit from '$app/components/document/_shared/UploadImage/ImageEdit';
 
 function ImageBlock({ node }: { node: NestedBlock<BlockType.ImageBlock> }) {
   const { url } = node.data;
   const { displaySize, onResizeStart, src, alignSelf, loading, error } = useImageBlock(node);
+  const dispatch = useAppDispatch();
+  const { controller } = useSubscribeDocument();
+  const id = node.id;
 
   const renderPopoverContent = useCallback(
     ({ onClose }: { onClose: () => void }) => {
-      return <EditImage onClose={onClose} id={node.id} url={url} />;
+      const onSubmitUrl = (url: string) => {
+        if (!url) return;
+        dispatch(
+          updateNodeDataThunk({
+            id,
+            data: {
+              url,
+            },
+            controller,
+          })
+        );
+        onClose();
+      };
+
+      return (
+        <div className={'w-[540px]'}>
+          <ImageEdit url={url} onSubmitUrl={onSubmitUrl} />
+        </div>
+      );
     },
-    [node.id, url]
+    [controller, dispatch, id, url]
   );
 
   const { anchorElRef, contextHolder, openPopover } = useBlockPopover({

+ 0 - 1
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TurnInto/TurnInto.hooks.ts

@@ -6,7 +6,6 @@ import { turnToBlockThunk } from '$app_reducers/document/async-actions';
 import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
 import Delta from 'quill-delta';
 import { getDeltaText } from '$app/utils/document/delta';
-import { rangeActions, rectSelectionActions } from '$app_reducers/document/slice';
 import { setRectSelectionThunk } from '$app_reducers/document/async-actions/rect_selection';
 
 export function useTurnInto({ node, onClose }: { node: NestedBlock; onClose?: () => void }) {

+ 53 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/UploadImage/ImageEdit.tsx

@@ -0,0 +1,53 @@
+import React, { useCallback, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { TAB_KEYS, TabPanel } from './TabPanel';
+import { Box, Button, Tab, Tabs, TextField } from '@mui/material';
+import UploadImage from './UploadImage';
+
+interface Props {
+  onSubmitUrl: (url: string) => void;
+  url?: string;
+}
+
+function ImageEdit({ onSubmitUrl, url }: Props) {
+  const { t } = useTranslation();
+  const [linkVal, setLinkVal] = useState<string>(url || '');
+  const [tabKey, setTabKey] = useState<TAB_KEYS>(TAB_KEYS.UPLOAD);
+  const handleChange = useCallback((_: React.SyntheticEvent, newValue: TAB_KEYS) => {
+    setTabKey(newValue);
+  }, []);
+
+  return (
+    <div className={'h-full w-full'}>
+      <Box sx={{ borderBottom: 1, borderColor: 'divider' }}>
+        <Tabs value={tabKey} onChange={handleChange}>
+          <Tab label={t('document.imageBlock.upload.label')} value={TAB_KEYS.UPLOAD} />
+
+          <Tab label={t('document.imageBlock.url.label')} value={TAB_KEYS.LINK} />
+        </Tabs>
+      </Box>
+      <TabPanel value={tabKey} index={TAB_KEYS.UPLOAD}>
+        <UploadImage onChange={onSubmitUrl} />
+      </TabPanel>
+
+      <TabPanel className={'flex flex-col p-3'} value={tabKey} index={TAB_KEYS.LINK}>
+        <TextField
+          value={linkVal}
+          onChange={(e) => setLinkVal(e.target.value)}
+          variant='outlined'
+          label={t('document.imageBlock.url.label')}
+          autoFocus={true}
+          style={{
+            marginBottom: '10px',
+          }}
+          placeholder={t('document.imageBlock.url.placeholder')}
+        />
+        <Button onClick={() => onSubmitUrl(linkVal)} variant='contained'>
+          {t('button.upload')}
+        </Button>
+      </TabPanel>
+    </div>
+  );
+}
+
+export default ImageEdit;

+ 19 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/UploadImage/ImageEditPopover.tsx

@@ -0,0 +1,19 @@
+import React from 'react';
+import Popover, { PopoverProps } from '@mui/material/Popover';
+import ImageEdit from './ImageEdit';
+import { PopoverOrigin } from '@mui/material/Popover/Popover';
+
+interface Props extends PopoverProps {
+  onSubmitUrl: (url: string) => void;
+  url?: string;
+}
+
+function ImageEditPopover({ onSubmitUrl, url, ...props }: Props) {
+  return (
+    <Popover {...props}>
+      <ImageEdit onSubmitUrl={onSubmitUrl} url={url} />
+    </Popover>
+  );
+}
+
+export default ImageEditPopover;

+ 27 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/UploadImage/TabPanel.tsx

@@ -0,0 +1,27 @@
+import React from 'react';
+export enum TAB_KEYS {
+  UPLOAD = 'upload',
+  LINK = 'link',
+}
+
+interface TabPanelProps {
+  children?: React.ReactNode;
+  index: TAB_KEYS;
+  value: TAB_KEYS;
+}
+
+export function TabPanel(props: TabPanelProps & React.HTMLAttributes<HTMLDivElement>) {
+  const { children, value, index, ...other } = props;
+
+  return (
+    <div
+      role='tabpanel'
+      hidden={value !== index}
+      id={`image-tabpanel-${index}`}
+      aria-labelledby={`image-tab-${index}`}
+      {...other}
+    >
+      {value === index && children}
+    </div>
+  );
+}

+ 0 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/UploadImage/index.tsx → frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/UploadImage/UploadImage.tsx


+ 2 - 1
frontend/appflowy_tauri/src/appflowy_app/constants/document/config.ts

@@ -1,4 +1,5 @@
 import { Align, BlockConfig, BlockType, SplitRelationship } from '$app/interfaces/document';
+import { randomEmoji } from '$app/utils/document/emoji';
 
 /**
  * If the block type is not in the config, it will be thrown an error in development env
@@ -69,7 +70,7 @@ export const blockConfig: Record<string, BlockConfig> = {
     canAddChild: true,
     defaultData: {
       delta: [],
-      icon: 'bulb',
+      icon: randomEmoji(),
     },
     splitProps: {
       nextLineRelationShip: SplitRelationship.NextSibling,

+ 5 - 1
frontend/appflowy_tauri/src/appflowy_app/interfaces/document.ts

@@ -82,7 +82,11 @@ export interface ImageBlockData {
   align: Align;
 }
 
-export type PageBlockData = TextBlockData;
+export interface PageBlockData extends TextBlockData {
+  cover?: string;
+  icon?: string;
+  coverType?: 'image' | 'color';
+}
 
 export type BlockData<Type> = Type extends BlockType.HeadingBlock
   ? HeadingBlockData

+ 9 - 0
frontend/appflowy_tauri/src/appflowy_app/utils/document/emoji.ts

@@ -0,0 +1,9 @@
+import emojiData, { EmojiMartData } from '@emoji-mart/data';
+
+export const randomEmoji = () => {
+  const emojis = (emojiData as EmojiMartData).emojis;
+  const keys = Object.keys(emojis);
+  const randomKey = keys[Math.floor(Math.random() * keys.length)];
+
+  return emojis[randomKey].skins[0].native;
+};

+ 41 - 0
frontend/appflowy_tauri/src/appflowy_app/utils/document/image.ts

@@ -14,6 +14,47 @@ export async function readImage(url: string) {
   }
 }
 
+export async function readCoverImageUrls(): Promise<{
+  images: { url: string }[];
+}> {
+  const { BaseDirectory, readTextFile, exists } = await import('@tauri-apps/api/fs');
+
+  try {
+    const existDir = await exists('cover/image_urls.json', { dir: BaseDirectory.AppLocalData });
+
+    if (!existDir) {
+      return {
+        images: [],
+      };
+    }
+
+    const data = await readTextFile('cover/image_urls.json', { dir: BaseDirectory.AppLocalData });
+
+    return JSON.parse(data);
+  } catch (e) {
+    return Promise.reject(e);
+  }
+}
+
+export async function writeCoverImageUrls(images: { url: string }[]) {
+  const { BaseDirectory, createDir, exists, writeTextFile } = await import('@tauri-apps/api/fs');
+
+  const fileName = 'cover/image_urls.json';
+  const jsonString = JSON.stringify({ images });
+
+  try {
+    const existDir = await exists('cover', { dir: BaseDirectory.AppLocalData });
+
+    if (!existDir) {
+      await createDir('cover', { dir: BaseDirectory.AppLocalData });
+    }
+
+    await writeTextFile(fileName, jsonString, { dir: BaseDirectory.AppLocalData });
+  } catch (e) {
+    return Promise.reject(e);
+  }
+}
+
 export function convertBlobToBase64(blob: Blob) {
   return new Promise((resolve, reject) => {
     const reader = new FileReader();

+ 12 - 0
frontend/appflowy_tauri/src/appflowy_app/utils/tool.ts

@@ -114,3 +114,15 @@ export function clone<T>(value: T): T {
 
   return result;
 }
+
+export function chunkArray<T>(array: T[], chunkSize: number) {
+  const chunks = [];
+  let i = 0;
+
+  while (i < array.length) {
+    chunks.push(array.slice(i, i + chunkSize));
+    i += chunkSize;
+  }
+
+  return chunks;
+}

+ 1 - 0
frontend/appflowy_tauri/src/styles/mui.css

@@ -34,6 +34,7 @@
 .MuiButtonBase-root.MuiIconButton-root {
     border-radius: 4px;
     padding: 2px;
+    box-shadow: none;
 }
 
 .MuiButtonBase-root.MuiButton-containedPrimary.MuiButton-contained:hover {

+ 10 - 1
frontend/appflowy_tauri/src/styles/template.css

@@ -27,6 +27,15 @@ div[role="textbox"] ::selection {
   @apply bg-transparent;
 }
 
+:root[data-dark-mode=true] body {
+    scrollbar-color: #fff var(--bg-body);
+}
+
+body {
+    scrollbar-track-color: var(--bg-body);
+    scrollbar-shadow-color: var(--bg-body);
+}
+
 .btn {
   @apply rounded-xl border border-line-divider px-4 py-3;
 }
@@ -62,4 +71,4 @@ span[data-slate-placeholder="true"]:not(.inline-block-content) {
     border-color: var(--line-divider) !important;
     color: var(--text-title) !important;
     box-shadow: var(--line-border) 0px 0px 0px 1px inset !important;
-}
+}

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

@@ -624,5 +624,23 @@
     "pink": "Pink",
     "brown": "Brown",
     "gray": "Gray"
+  },
+  "emoji": {
+    "filter": "Filter",
+    "random": "Random",
+    "selectSkinTone": "Select skin tone",
+    "remove": "Remove emoji",
+    "categories": {
+      "smileys": "Smileys & Emotion",
+      "people": "People & Body",
+      "animals": "Animals & Nature",
+      "food": "Food & Drink",
+      "activities": "Activities",
+      "places": "Travel & Places",
+      "objects": "Objects",
+      "symbols": "Symbols",
+      "flags": "Flags",
+      "nature": "Nature"
+    }
   }
 }