Prechádzať zdrojové kódy

feat: support image block (#2912)

Kilu.He 1 rok pred
rodič
commit
452d7eb6d0
29 zmenil súbory, kde vykonal 1129 pridanie a 133 odobranie
  1. 1 1
      frontend/appflowy_tauri/src-tauri/Cargo.toml
  2. 13 0
      frontend/appflowy_tauri/src-tauri/tauri.conf.json
  3. 0 1
      frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/index.tsx
  4. 18 1
      frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSlash/BlockSlashMenu.tsx
  5. 15 15
      frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSlash/index.hooks.ts
  6. 1 1
      frontend/appflowy_tauri/src/appflowy_app/components/document/CalloutBlock/index.tsx
  7. 62 32
      frontend/appflowy_tauri/src/appflowy_app/components/document/EquationBlock/index.tsx
  8. 0 71
      frontend/appflowy_tauri/src/appflowy_app/components/document/EquationBlock/useEquationBlock.ts
  9. 97 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/ImageBlock/EditImage.tsx
  10. 118 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/ImageBlock/ImageAlign.tsx
  11. 55 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/ImageBlock/ImagePlaceholder.tsx
  12. 71 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/ImageBlock/ImageRender.tsx
  13. 39 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/ImageBlock/ImageToolbar.tsx
  14. 58 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/ImageBlock/index.tsx
  15. 182 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/ImageBlock/useImageBlock.ts
  16. 3 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/Node/index.tsx
  17. 1 1
      frontend/appflowy_tauri/src/appflowy_app/components/document/VirtualizedList/VirtualizedList.hooks.tsx
  18. 107 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/BlockPopover/BlockPopover.hooks.tsx
  19. 16 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeBlockEdit.hooks.ts
  20. 121 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/UploadImage/index.tsx
  21. 11 1
      frontend/appflowy_tauri/src/appflowy_app/constants/document/config.ts
  22. 1 0
      frontend/appflowy_tauri/src/appflowy_app/constants/document/name.ts
  23. 19 4
      frontend/appflowy_tauri/src/appflowy_app/interfaces/document.ts
  24. 22 5
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/menu.ts
  25. 31 0
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/block_edit_slice.ts
  26. 2 0
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/slice.ts
  27. 53 0
      frontend/appflowy_tauri/src/appflowy_app/utils/document/image.ts
  28. 8 0
      frontend/appflowy_tauri/src/appflowy_app/utils/env.ts
  29. 4 0
      frontend/appflowy_tauri/src/appflowy_app/views/DocumentPage.tsx

+ 1 - 1
frontend/appflowy_tauri/src-tauri/Cargo.toml

@@ -16,7 +16,7 @@ tauri-build = { version = "1.2", features = [] }
 [dependencies]
 [dependencies]
 serde_json = "1.0"
 serde_json = "1.0"
 serde = { version = "1.0", features = ["derive"] }
 serde = { version = "1.0", features = ["derive"] }
-tauri = { version = "1.2", features = ["shell-open"] }
+tauri = { version = "1.2", features = ["fs-all", "shell-open"] }
 tauri-utils = "1.2"
 tauri-utils = "1.2"
 bytes = { version = "1.4" }
 bytes = { version = "1.4" }
 tracing = { version = "0.1", features = ["log"] }
 tracing = { version = "0.1", features = ["log"] }

+ 13 - 0
frontend/appflowy_tauri/src-tauri/tauri.conf.json

@@ -16,6 +16,19 @@
       "shell": {
       "shell": {
         "all": false,
         "all": false,
         "open": true
         "open": true
+      },
+      "fs": {
+        "all": true,
+        "scope": ["$APPLOCALDATA/**", "$APPLOCALDATA/images/*"],
+        "readFile": true,
+        "writeFile": true,
+        "readDir": true,
+        "copyFile": true,
+        "createDir": true,
+        "removeDir": true,
+        "removeFile": true,
+        "renameFile": true,
+        "exists": true
       }
       }
     },
     },
     "bundle": {
     "bundle": {

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

@@ -7,7 +7,6 @@ import DragIndicatorRoundedIcon from '@mui/icons-material/DragIndicatorRounded';
 import AddSharpIcon from '@mui/icons-material/AddSharp';
 import AddSharpIcon from '@mui/icons-material/AddSharp';
 import BlockMenu from './BlockMenu';
 import BlockMenu from './BlockMenu';
 import ToolbarButton from './ToolbarButton';
 import ToolbarButton from './ToolbarButton';
-import { rectSelectionActions } from '$app_reducers/document/slice';
 import { addBlockBelowClickThunk } from '$app_reducers/document/async-actions/menu';
 import { addBlockBelowClickThunk } from '$app_reducers/document/async-actions/menu';
 import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
 import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
 import { RANGE_NAME, RECT_RANGE_NAME } from '$app/constants/document/name';
 import { RANGE_NAME, RECT_RANGE_NAME } from '$app/constants/document/name';

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

@@ -11,6 +11,8 @@ import {
   TextFields,
   TextFields,
   Title,
   Title,
   SafetyDivider,
   SafetyDivider,
+  Image,
+  Functions,
 } from '@mui/icons-material';
 } from '@mui/icons-material';
 import {
 import {
   BlockData,
   BlockData,
@@ -25,6 +27,7 @@ import { useSubscribeDocument } from '$app/components/document/_shared/Subscribe
 import { slashCommandActions } from '$app_reducers/document/slice';
 import { slashCommandActions } from '$app_reducers/document/slice';
 import { Keyboard } from '$app/constants/document/keyboard';
 import { Keyboard } from '$app/constants/document/keyboard';
 import { selectOptionByUpDown } from '$app/utils/document/menu';
 import { selectOptionByUpDown } from '$app/utils/document/menu';
+import { blockEditActions } from '$app_reducers/document/block_edit_slice';
 
 
 function BlockSlashMenu({
 function BlockSlashMenu({
   id,
   id,
@@ -57,7 +60,7 @@ function BlockSlashMenu({
       );
       );
       onClose?.();
       onClose?.();
     },
     },
-    [controller, dispatch, id, onClose]
+    [controller, dispatch, docId, id, onClose]
   );
   );
 
 
   const options: (SlashCommandOption & {
   const options: (SlashCommandOption & {
@@ -160,6 +163,20 @@ function BlockSlashMenu({
           icon: <DataObject />,
           icon: <DataObject />,
           group: SlashCommandGroup.MEDIA,
           group: SlashCommandGroup.MEDIA,
         },
         },
+        {
+          key: SlashCommandOptionKey.IMAGE,
+          type: BlockType.ImageBlock,
+          title: 'Image',
+          icon: <Image />,
+          group: SlashCommandGroup.MEDIA,
+        },
+        {
+          key: SlashCommandOptionKey.EQUATION,
+          type: BlockType.EquationBlock,
+          title: 'Block equation',
+          icon: <Functions />,
+          group: SlashCommandGroup.ADVANCED,
+        },
       ].filter((option) => {
       ].filter((option) => {
         if (!searchText) return true;
         if (!searchText) return true;
         const match = (text: string) => {
         const match = (text: string) => {

+ 15 - 15
frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSlash/index.hooks.ts

@@ -1,17 +1,17 @@
 import { useAppDispatch } from '$app/stores/store';
 import { useAppDispatch } from '$app/stores/store';
-import React, { useCallback, useEffect, useMemo } from 'react';
+import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
 import { slashCommandActions } from '$app_reducers/document/slice';
 import { slashCommandActions } from '$app_reducers/document/slice';
 import { useSubscribeNode } from '$app/components/document/_shared/SubscribeNode.hooks';
 import { useSubscribeNode } from '$app/components/document/_shared/SubscribeNode.hooks';
-import { Op } from 'quill-delta';
+import Delta from 'quill-delta';
 import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
 import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
 import { useSubscribeSlashState } from '$app/components/document/_shared/SubscribeSlash.hooks';
 import { useSubscribeSlashState } from '$app/components/document/_shared/SubscribeSlash.hooks';
+import { getDeltaText } from '$app/utils/document/delta';
 
 
 export function useBlockSlash() {
 export function useBlockSlash() {
   const dispatch = useAppDispatch();
   const dispatch = useAppDispatch();
   const { docId } = useSubscribeDocument();
   const { docId } = useSubscribeDocument();
-
   const { blockId, visible, slashText, hoverOption } = useSubscribeSlash();
   const { blockId, visible, slashText, hoverOption } = useSubscribeSlash();
-  const [anchorPosition, setAnchorPosition] = React.useState<{
+  const [anchorPosition, setAnchorPosition] = useState<{
     top: number;
     top: number;
     left: number;
     left: number;
   }>();
   }>();
@@ -68,24 +68,24 @@ export function useSubscribeSlash() {
   const slashCommandState = useSubscribeSlashState();
   const slashCommandState = useSubscribeSlashState();
   const visible = slashCommandState.isSlashCommand;
   const visible = slashCommandState.isSlashCommand;
   const blockId = slashCommandState.blockId;
   const blockId = slashCommandState.blockId;
+  const rightDistanceRef = useRef<number>(0);
 
 
   const { node } = useSubscribeNode(blockId || '');
   const { node } = useSubscribeNode(blockId || '');
 
 
   const slashText = useMemo(() => {
   const slashText = useMemo(() => {
     if (!node) return '';
     if (!node) return '';
-    const delta = node.data.delta || [];
-
-    return delta
-      .map((op: Op) => {
-        if (typeof op.insert === 'string') {
-          return op.insert;
-        } else {
-          return '';
-        }
-      })
-      .join('');
+    const delta = new Delta(node.data.delta);
+    const length = delta.length();
+    const slicedDelta = delta.slice(0, length - rightDistanceRef.current);
+
+    return getDeltaText(slicedDelta);
   }, [node]);
   }, [node]);
 
 
+  useEffect(() => {
+    if (!visible) return;
+    rightDistanceRef.current = new Delta(node.data.delta).length();
+  }, [visible]);
+
   return {
   return {
     visible,
     visible,
     blockId,
     blockId,

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

@@ -17,7 +17,7 @@ export default function CalloutBlock({
   const { openEmojiSelect, open, closeEmojiSelect, id, anchorEl, onEmojiSelect } = useCalloutBlock(node.id);
   const { openEmojiSelect, open, closeEmojiSelect, id, anchorEl, onEmojiSelect } = useCalloutBlock(node.id);
 
 
   return (
   return (
-    <div className={'my-1 flex rounded border border-solid border-main-accent bg-main-secondary p-4'}>
+    <div className={'my-1 flex rounded border border-solid border-main-accent bg-main-selector p-4'}>
       <div className={'w-[1.5em]'} onMouseDown={(e) => e.stopPropagation()}>
       <div className={'w-[1.5em]'} onMouseDown={(e) => e.stopPropagation()}>
         <div className={'flex h-[calc(1.5em_+_2px)] w-[24px] select-none items-center justify-start'}>
         <div className={'flex h-[calc(1.5em_+_2px)] w-[24px] select-none items-center justify-start'}>
           <IconButton
           <IconButton

+ 62 - 32
frontend/appflowy_tauri/src/appflowy_app/components/document/EquationBlock/index.tsx

@@ -1,52 +1,82 @@
-import React from 'react';
+import React, { useCallback, useState } from 'react';
 import { BlockType, NestedBlock } from '$app/interfaces/document';
 import { BlockType, NestedBlock } from '$app/interfaces/document';
 import KatexMath from '$app/components/document/_shared/KatexMath';
 import KatexMath from '$app/components/document/_shared/KatexMath';
-import Popover from '@mui/material/Popover';
 import EquationEditContent from '$app/components/document/_shared/TemporaryInput/EquationEditContent';
 import EquationEditContent from '$app/components/document/_shared/TemporaryInput/EquationEditContent';
-import { useEquationBlock } from '$app/components/document/EquationBlock/useEquationBlock';
 import { Functions } from '@mui/icons-material';
 import { Functions } from '@mui/icons-material';
+import { useBlockPopover } from '$app/components/document/_shared/BlockPopover/BlockPopover.hooks';
+import { updateNodeDataThunk } from '$app_reducers/document/async-actions';
+import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
+import { useAppDispatch } from '$app/stores/store';
 
 
 function EquationBlock({ node }: { node: NestedBlock<BlockType.EquationBlock> }) {
 function EquationBlock({ node }: { node: NestedBlock<BlockType.EquationBlock> }) {
-  const { ref, value, onChange, onOpenPopover, open, anchorPosition, onConfirm, onClosePopover } =
-    useEquationBlock(node);
+  const formula = node.data.formula;
+  const [value, setValue] = useState(formula);
+  const { controller } = useSubscribeDocument();
+  const id = node.id;
+  const dispatch = useAppDispatch();
 
 
-  const formula = open ? value : node.data.formula;
+  const onChange = useCallback((newVal: string) => {
+    setValue(newVal);
+  }, []);
+
+  const onAfterOpen = useCallback(() => {
+    setValue(formula);
+  }, [formula]);
+
+  const onConfirm = useCallback(async () => {
+    await dispatch(
+      updateNodeDataThunk({
+        id,
+        data: {
+          formula: value,
+        },
+        controller,
+      })
+    );
+  }, [dispatch, id, value, controller]);
+
+  const renderContent = useCallback(
+    ({ onClose }: { onClose: () => void }) => {
+      return (
+        <EquationEditContent
+          placeholder={'c = \\pm\\sqrt{a^2 + b^2\\text{ if }a\\neq 0\\text{ or }b\\neq 0}'}
+          multiline={true}
+          value={value}
+          onChange={onChange}
+          onConfirm={async () => {
+            await onConfirm();
+            onClose();
+          }}
+        />
+      );
+    },
+    [value, onChange, onConfirm]
+  );
+
+  const { open, contextHolder, openPopover, anchorElRef } = useBlockPopover({
+    id: node.id,
+    renderContent,
+    onAfterOpen,
+  });
+  const displayFormula = open ? value : formula;
 
 
   return (
   return (
     <>
     <>
       <div
       <div
-        ref={ref}
-        onClick={onOpenPopover}
-        className={'flex min-h-[59px] cursor-pointer items-center justify-center overflow-hidden hover:bg-main-selector'}
+        ref={anchorElRef}
+        onClick={openPopover}
+        className={'my-1 flex min-h-[59px] cursor-pointer flex-col overflow-hidden rounded hover:bg-main-secondary'}
       >
       >
-        {formula ? (
-          <KatexMath latex={formula} />
+        {displayFormula ? (
+          <KatexMath latex={displayFormula} />
         ) : (
         ) : (
-          <span className={'flex text-shade-2'}>
+          <div className={'flex h-[100%] w-[100%] flex-1 items-center bg-main-selector px-1 text-shade-2'}>
             <Functions />
             <Functions />
             <span>Add a TeX equation</span>
             <span>Add a TeX equation</span>
-          </span>
+          </div>
         )}
         )}
       </div>
       </div>
-      <Popover
-        transformOrigin={{
-          vertical: 'top',
-          horizontal: 'center',
-        }}
-        onMouseDown={(e) => e.stopPropagation()}
-        onClose={onClosePopover}
-        open={open}
-        anchorReference={'anchorPosition'}
-        anchorPosition={anchorPosition}
-      >
-        <EquationEditContent
-          placeholder={'c = \\pm\\sqrt{a^2 + b^2\\text{ if }a\\neq 0\\text{ or }b\\neq 0}'}
-          multiline={true}
-          value={value}
-          onChange={onChange}
-          onConfirm={onConfirm}
-        />
-      </Popover>
+      {contextHolder}
     </>
     </>
   );
   );
 }
 }

+ 0 - 71
frontend/appflowy_tauri/src/appflowy_app/components/document/EquationBlock/useEquationBlock.ts

@@ -1,71 +0,0 @@
-import { useCallback, useRef, useState } from 'react';
-import { BlockType, NestedBlock } from '$app/interfaces/document';
-import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
-import { useAppDispatch } from '$app/stores/store';
-import { updateNodeDataThunk } from '$app_reducers/document/async-actions';
-import { rectSelectionActions } from '$app_reducers/document/slice';
-import { setRectSelectionThunk } from '$app_reducers/document/async-actions/rect_selection';
-
-export function useEquationBlock(node: NestedBlock<BlockType.EquationBlock>) {
-  const { controller, docId } = useSubscribeDocument();
-  const id = node.id;
-  const dispatch = useAppDispatch();
-  const formula = node.data.formula;
-  const ref = useRef<HTMLDivElement>(null);
-  const [value, setValue] = useState(formula);
-
-  const [anchorPosition, setAnchorPosition] = useState<{
-    top: number;
-    left: number;
-  }>();
-  const open = Boolean(anchorPosition);
-
-  const onChange = useCallback((newVal: string) => {
-    setValue(newVal);
-  }, []);
-
-  const onOpenPopover = useCallback(() => {
-    setValue(formula);
-    const rect = ref.current?.getBoundingClientRect();
-
-    if (!rect) return;
-    setAnchorPosition({
-      top: rect.top + rect.height,
-      left: rect.left + rect.width / 2,
-    });
-  }, [formula]);
-
-  const onClosePopover = useCallback(() => {
-    setAnchorPosition(undefined);
-    dispatch(
-      setRectSelectionThunk({
-        docId,
-        selection: [id],
-      })
-    );
-  }, [dispatch, id, docId]);
-
-  const onConfirm = useCallback(async () => {
-    await dispatch(
-      updateNodeDataThunk({
-        id,
-        data: {
-          formula: value,
-        },
-        controller,
-      })
-    );
-    onClosePopover();
-  }, [dispatch, id, value, controller, onClosePopover]);
-
-  return {
-    open,
-    ref,
-    value,
-    onChange,
-    onOpenPopover,
-    onClosePopover,
-    onConfirm,
-    anchorPosition,
-  };
-}

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

@@ -0,0 +1,97 @@
+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 { isTauri } from '$app/utils/env';
+
+enum TAB_KEYS {
+  UPLOAD = 'upload',
+  LINK = 'link',
+}
+
+function EditImage({ id, url, onClose }: { id: string; url: string; onClose: () => void }) {
+  const dispatch = useAppDispatch();
+  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}>
+          {isTauri() && <Tab label={'Upload Image'} value={TAB_KEYS.UPLOAD} />}
+
+          <Tab label='URL Image' value={TAB_KEYS.LINK} />
+        </Tabs>
+      </Box>
+      {isTauri() && (
+        <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={'URL'}
+          autoFocus={true}
+          style={{
+            marginBottom: '10px',
+          }}
+          placeholder={'Please enter the URL of the image'}
+        />
+        <Button onClick={() => handleConfirmUrl(linkVal)} variant='contained'>
+          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>
+  );
+}

+ 118 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/ImageBlock/ImageAlign.tsx

@@ -0,0 +1,118 @@
+import React, { useCallback, useEffect, useRef, useState } from 'react';
+import { useAppDispatch } from '$app/stores/store';
+import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
+import { Align } from '$app/interfaces/document';
+import { FormatAlignCenter, FormatAlignLeft, FormatAlignRight } from '@mui/icons-material';
+import { updateNodeDataThunk } from '$app_reducers/document/async-actions';
+import MenuTooltip from '$app/components/document/TextActionMenu/menu/MenuTooltip';
+import Popover from '@mui/material/Popover';
+
+function ImageAlign({
+  id,
+  align,
+  onOpen,
+  onClose,
+}: {
+  id: string;
+  align: Align;
+  onOpen: () => void;
+  onClose: () => void;
+}) {
+  const ref = useRef<HTMLDivElement | null>(null);
+  const [anchorEl, setAnchorEl] = useState<HTMLDivElement>();
+  const popoverOpen = Boolean(anchorEl);
+
+  useEffect(() => {
+    if (popoverOpen) {
+      onOpen();
+    } else {
+      onClose();
+    }
+  }, [onClose, onOpen, popoverOpen]);
+
+  const dispatch = useAppDispatch();
+  const { controller } = useSubscribeDocument();
+  const renderAlign = (align: Align) => {
+    switch (align) {
+      case Align.Left:
+        return <FormatAlignLeft />;
+      case Align.Center:
+        return <FormatAlignCenter />;
+      default:
+        return <FormatAlignRight />;
+    }
+  };
+
+  const updateAlign = useCallback(
+    (align: Align) => {
+      dispatch(
+        updateNodeDataThunk({
+          id,
+          data: {
+            align,
+          },
+          controller,
+        })
+      );
+      setAnchorEl(undefined);
+    },
+    [controller, dispatch, id]
+  );
+
+  return (
+    <>
+      <MenuTooltip title='Align'>
+        <div
+          ref={ref}
+          className='flex items-center justify-center p-1'
+          onClick={(_) => {
+            ref.current && setAnchorEl(ref.current);
+          }}
+        >
+          {renderAlign(align)}
+        </div>
+      </MenuTooltip>
+      <Popover
+        open={popoverOpen}
+        anchorOrigin={{
+          vertical: 'bottom',
+          horizontal: 'center',
+        }}
+        transformOrigin={{
+          vertical: 'top',
+          horizontal: 'center',
+        }}
+        onMouseDown={(e) => e.stopPropagation()}
+        anchorEl={anchorEl}
+        onClose={() => setAnchorEl(undefined)}
+        PaperProps={{
+          style: {
+            backgroundColor: '#1E1E1E',
+            opacity: 0.8,
+          },
+        }}
+      >
+        <div className='flex items-center justify-center bg-transparent p-1'>
+          {[Align.Left, Align.Center, Align.Right].map((item: Align) => {
+            return (
+              <div
+                key={item}
+                style={{
+                  color: align === item ? '#00BCF0' : '#fff',
+                }}
+                className={'cursor-pointer'}
+                onClick={() => {
+                  updateAlign(item);
+                }}
+              >
+                {renderAlign(item)}
+              </div>
+            );
+          })}
+        </div>
+      </Popover>
+    </>
+  );
+}
+
+export default ImageAlign;

+ 55 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/ImageBlock/ImagePlaceholder.tsx

@@ -0,0 +1,55 @@
+import React from 'react';
+import { Alert, CircularProgress } from '@mui/material';
+import { ImageSvg } from '$app/components/_shared/svg/ImageSvg';
+
+function ImagePlaceholder({
+  error,
+  loading,
+  isEmpty,
+  width,
+  height,
+  alignSelf,
+  openPopover,
+}: {
+  error: boolean;
+  loading: boolean;
+  isEmpty: boolean;
+  width?: number;
+  height?: number;
+  alignSelf: string;
+  openPopover: () => void;
+}) {
+  const visible = loading || error || isEmpty;
+
+  return (
+    <div
+      style={{
+        width: width ? width + 'px' : undefined,
+        height: height ? height + 'px' : undefined,
+        alignSelf,
+        visibility: visible ? undefined : 'hidden',
+      }}
+      className={'absolute z-10 flex h-[100%] min-h-[59px] w-[100%] items-center justify-center'}
+    >
+      {loading && <CircularProgress />}
+      {error && (
+        <Alert className={'flex h-[100%] w-[100%] items-center justify-center'} severity='error'>
+          Error loading image
+        </Alert>
+      )}
+      {isEmpty && (
+        <div
+          onClick={openPopover}
+          className={'flex h-[100%] w-[100%] flex-1 items-center bg-main-selector px-1 text-shade-2'}
+        >
+          <i className={'mx-2 h-5 w-5'}>
+            <ImageSvg />
+          </i>
+          <span>Add an image</span>
+        </div>
+      )}
+    </div>
+  );
+}
+
+export default ImagePlaceholder;

+ 71 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/ImageBlock/ImageRender.tsx

@@ -0,0 +1,71 @@
+import React, { useCallback, useState } from 'react';
+import ImageToolbar from '$app/components/document/ImageBlock/ImageToolbar';
+import { BlockType, NestedBlock } from '$app/interfaces/document';
+
+function ImageRender({
+  src,
+  node,
+  width,
+  height,
+  alignSelf,
+  onResizeStart,
+}: {
+  node: NestedBlock<BlockType.ImageBlock>;
+  width: number;
+  height: number;
+  alignSelf: string;
+  src: string;
+  onResizeStart: (e: React.MouseEvent<HTMLDivElement>, isLeft: boolean) => void;
+}) {
+  const [toolbarOpen, setToolbarOpen] = useState<boolean>(false);
+
+  const renderResizer = useCallback(
+    (isLeft: boolean) => {
+      return (
+        <div
+          onMouseDown={(e) => onResizeStart(e, isLeft)}
+          className={`${toolbarOpen ? 'pointer-events-auto' : 'pointer-events-none'} absolute z-[2] ${
+            isLeft ? 'left-0' : 'right-0'
+          } top-0 flex h-[100%] w-[15px] cursor-col-resize items-center justify-center`}
+        >
+          <div
+            className={`h-[48px] max-h-[50%] w-2 rounded-[20px] border border-solid border-main-selector bg-shade-3 ${
+              toolbarOpen ? 'opacity-1' : 'opacity-0'
+            } transition-opacity duration-300 `}
+          />
+        </div>
+      );
+    },
+    [onResizeStart, toolbarOpen]
+  );
+
+  return (
+    <div
+      contentEditable={false}
+      onMouseEnter={() => setToolbarOpen(true)}
+      onMouseLeave={() => setToolbarOpen(false)}
+      style={{
+        width: width + 'px',
+        height: height + 'px',
+        alignSelf,
+      }}
+      className={`relative cursor-default`}
+    >
+      {src && (
+        <img
+          src={src}
+          className={'relative cursor-pointer'}
+          style={{
+            height: height + 'px',
+            width: width + 'px',
+          }}
+        />
+      )}
+      {renderResizer(true)}
+      {renderResizer(false)}
+      <ImageToolbar id={node.id} open={toolbarOpen} align={node.data.align} />
+    </div>
+  );
+}
+
+export default ImageRender;

+ 39 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/ImageBlock/ImageToolbar.tsx

@@ -0,0 +1,39 @@
+import React, { useState } from 'react';
+import { Align } from '$app/interfaces/document';
+import ImageAlign from '$app/components/document/ImageBlock/ImageAlign';
+import MenuTooltip from '$app/components/document/TextActionMenu/menu/MenuTooltip';
+import { DeleteOutline } from '@mui/icons-material';
+import { useAppDispatch } from '$app/stores/store';
+import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
+import { deleteNodeThunk } from '$app_reducers/document/async-actions';
+
+function ImageToolbar({ id, open, align }: { id: string; open: boolean; align: Align }) {
+  const [popoverOpen, setPopoverOpen] = useState(false);
+  const visible = open || popoverOpen;
+  const dispatch = useAppDispatch();
+  const { controller } = useSubscribeDocument();
+
+  return (
+    <>
+      <div
+        className={`${
+          visible ? 'opacity-1 pointer-events-auto' : 'pointer-events-none opacity-0'
+        } absolute right-2 top-2 z-[1px] flex h-[26px] max-w-[calc(100%-16px)] cursor-pointer items-center justify-center whitespace-nowrap rounded bg-shade-1 bg-opacity-50 text-sm text-white transition-opacity`}
+      >
+        <ImageAlign id={id} align={align} onOpen={() => setPopoverOpen(true)} onClose={() => setPopoverOpen(false)} />
+        <MenuTooltip title={'Delete'}>
+          <div
+            onClick={() => {
+              dispatch(deleteNodeThunk({ id, controller }));
+            }}
+            className='flex items-center justify-center bg-transparent p-1'
+          >
+            <DeleteOutline />
+          </div>
+        </MenuTooltip>
+      </div>
+    </>
+  );
+}
+
+export default ImageToolbar;

+ 58 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/ImageBlock/index.tsx

@@ -0,0 +1,58 @@
+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';
+
+function ImageBlock({ node }: { node: NestedBlock<BlockType.ImageBlock> }) {
+  const { url } = node.data;
+  const { displaySize, onResizeStart, src, alignSelf, loading, error } = useImageBlock(node);
+
+  const renderPopoverContent = useCallback(
+    ({ onClose }: { onClose: () => void }) => {
+      return <EditImage onClose={onClose} id={node.id} url={url} />;
+    },
+    [node.id, url]
+  );
+
+  const { anchorElRef, contextHolder, openPopover } = useBlockPopover({
+    id: node.id,
+    renderContent: renderPopoverContent,
+  });
+
+  const { width, height } = displaySize;
+
+  return (
+    <>
+      <div
+        ref={anchorElRef}
+        className={
+          'my-1 flex min-h-[59px] cursor-pointer flex-col justify-center overflow-hidden hover:bg-main-secondary'
+        }
+      >
+        <ImageRender
+          node={node}
+          width={width}
+          height={height}
+          alignSelf={alignSelf}
+          src={src}
+          onResizeStart={onResizeStart}
+        />
+        <ImagePlaceholder
+          isEmpty={!src}
+          alignSelf={alignSelf}
+          width={width}
+          height={height}
+          loading={loading}
+          error={error}
+          openPopover={openPopover}
+        />
+      </div>
+      {contextHolder}
+    </>
+  );
+}
+
+export default ImageBlock;

+ 182 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/ImageBlock/useImageBlock.ts

@@ -0,0 +1,182 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { Align, BlockType, NestedBlock } from '$app/interfaces/document';
+import { useAppDispatch } from '$app/stores/store';
+import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
+import { updateNodeDataThunk } from '$app_reducers/document/async-actions';
+import { Log } from '$app/utils/log';
+import { getNode } from '$app/utils/document/node';
+import { readImage } from '$app/utils/document/image';
+
+export function useImageBlock(node: NestedBlock<BlockType.ImageBlock>) {
+  const { url, width, align, height } = node.data;
+  const dispatch = useAppDispatch();
+  const [loading, setLoading] = useState<boolean>(false);
+  const [error, setError] = useState<boolean>(false);
+  const { controller } = useSubscribeDocument();
+  const [resizing, setResizing] = useState<boolean>(false);
+  const startResizePoint = useRef<{
+    left: boolean;
+    x: number;
+    y: number;
+  }>();
+  const startResizeWidth = useRef<number>(0);
+
+  const [src, setSrc] = useState<string>('');
+  const [displaySize, setDisplaySize] = useState<{
+    width: number;
+    height: number;
+  }>({
+    width: width || 0,
+    height: height || 0,
+  });
+
+  const onResizeStart = useCallback(
+    (e: React.MouseEvent<HTMLDivElement>, left: boolean) => {
+      e.preventDefault();
+      e.stopPropagation();
+      setResizing(true);
+      startResizeWidth.current = displaySize.width;
+      startResizePoint.current = {
+        x: e.clientX,
+        y: e.clientY,
+        left,
+      };
+    },
+    [displaySize.width]
+  );
+
+  const updateWidth = useCallback(
+    (width: number, height: number) => {
+      dispatch(
+        updateNodeDataThunk({
+          id: node.id,
+          data: {
+            width,
+            height,
+          },
+          controller,
+        })
+      );
+    },
+    [controller, dispatch, node.id]
+  );
+
+  useEffect(() => {
+    const currentSize: {
+      width?: number;
+      height?: number;
+    } = {};
+    const onResize = (e: MouseEvent) => {
+      const clientX = e.clientX;
+
+      if (!startResizePoint.current) return;
+      const { x, left } = startResizePoint.current;
+      const startWidth = startResizeWidth.current || 0;
+      const diff = (left ? x - clientX : clientX - x) / 2;
+
+      setDisplaySize((prevState) => {
+        const displayWidth = prevState?.width || 0;
+        const displayHeight = prevState?.height || 0;
+        const ratio = displayWidth / displayHeight;
+
+        const width = startWidth + diff;
+        const height = width / ratio;
+
+        Object.assign(currentSize, {
+          width,
+          height,
+        });
+        return {
+          width,
+          height,
+        };
+      });
+    };
+
+    const onResizeEnd = (e: MouseEvent) => {
+      setResizing(false);
+      if (!startResizePoint.current) return;
+      startResizePoint.current = undefined;
+      if (!currentSize.width || !currentSize.height) return;
+      updateWidth(Math.floor(currentSize.width) || 0, Math.floor(currentSize.height) || 0);
+    };
+
+    if (resizing) {
+      document.addEventListener('mousemove', onResize);
+      document.addEventListener('mouseup', onResizeEnd);
+    } else {
+      document.removeEventListener('mousemove', onResize);
+      document.removeEventListener('mouseup', onResizeEnd);
+    }
+  }, [resizing, updateWidth]);
+
+  const alignSelf = useMemo(() => {
+    if (align === Align.Left) return 'flex-start';
+    if (align === Align.Right) return 'flex-end';
+    return 'center';
+  }, [align]);
+
+  useEffect(() => {
+    if (!url) return;
+    const image = new Image();
+
+    setLoading(true);
+    setError(false);
+    image.onload = function () {
+      const ratio = image.width / image.height;
+      const element = getNode(node.id) as HTMLDivElement;
+
+      if (!element) return;
+      const maxWidth = element.offsetWidth || 1000;
+      const imageWidth = Math.min(image.width, maxWidth);
+
+      setDisplaySize((prevState) => {
+        if (prevState.width <= 0) {
+          return {
+            width: imageWidth,
+            height: imageWidth / ratio,
+          };
+        }
+
+        return prevState;
+      });
+
+      setLoading(false);
+    };
+
+    image.onerror = function () {
+      setLoading(false);
+      setError(true);
+    };
+
+    const isRemote = url.startsWith('http');
+
+    if (isRemote) {
+      setSrc(url);
+      image.src = url;
+      return;
+    }
+
+    void (async () => {
+      setError(false);
+      try {
+        const src = await readImage(url);
+
+        setSrc(src);
+        image.src = src;
+      } catch (e) {
+        Log.error(e);
+        setError(true);
+      }
+    })();
+  }, [node.id, url]);
+
+  return {
+    displaySize,
+    src,
+    alignSelf,
+    onResizeStart,
+    loading,
+    error,
+  };
+}

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

@@ -18,6 +18,7 @@ import BlockOverlay from '$app/components/document/Overlay/BlockOverlay';
 import CodeBlock from '$app/components/document/CodeBlock';
 import CodeBlock from '$app/components/document/CodeBlock';
 import { NodeIdContext } from '$app/components/document/_shared/SubscribeNode.hooks';
 import { NodeIdContext } from '$app/components/document/_shared/SubscribeNode.hooks';
 import EquationBlock from '$app/components/document/EquationBlock';
 import EquationBlock from '$app/components/document/EquationBlock';
+import ImageBlock from '$app/components/document/ImageBlock';
 
 
 function NodeComponent({ id, ...props }: { id: string } & React.HTMLAttributes<HTMLDivElement>) {
 function NodeComponent({ id, ...props }: { id: string } & React.HTMLAttributes<HTMLDivElement>) {
   const { node, childIds, isSelected, ref } = useNode(id);
   const { node, childIds, isSelected, ref } = useNode(id);
@@ -64,6 +65,8 @@ function NodeComponent({ id, ...props }: { id: string } & React.HTMLAttributes<H
         return <CodeBlock node={node} />;
         return <CodeBlock node={node} />;
       case BlockType.EquationBlock:
       case BlockType.EquationBlock:
         return <EquationBlock node={node} />;
         return <EquationBlock node={node} />;
+      case BlockType.ImageBlock:
+        return <ImageBlock node={node} />;
       default:
       default:
         return <UnSupportedBlock />;
         return <UnSupportedBlock />;
     }
     }

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

@@ -9,7 +9,7 @@ export function useVirtualizedList(count: number) {
   const virtualize = useVirtualizer({
   const virtualize = useVirtualizer({
     count,
     count,
     getScrollElement: () => parentRef.current,
     getScrollElement: () => parentRef.current,
-    overscan: 5,
+    overscan: 10,
     estimateSize: () => {
     estimateSize: () => {
       return defaultSize;
       return defaultSize;
     },
     },

+ 107 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/BlockPopover/BlockPopover.hooks.tsx

@@ -0,0 +1,107 @@
+import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import Popover from '@mui/material/Popover';
+import { useEditingState } from '$app/components/document/_shared/SubscribeBlockEdit.hooks';
+import { useAppDispatch } from '$app/stores/store';
+import { blockEditActions } from '$app_reducers/document/block_edit_slice';
+import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
+import { setRectSelectionThunk } from '$app_reducers/document/async-actions/rect_selection';
+
+export function useBlockPopover({
+  renderContent,
+  onAfterClose,
+  onAfterOpen,
+  id,
+}: {
+  id: string;
+  onAfterClose?: () => void;
+  onAfterOpen?: () => void;
+  renderContent: ({ onClose }: { onClose: () => void }) => React.ReactNode;
+}) {
+  const anchorElRef = useRef<HTMLDivElement>(null);
+  const { docId } = useSubscribeDocument();
+
+  const [anchorEl, setAnchorEl] = useState<HTMLDivElement | null>(null);
+  const open = Boolean(anchorEl);
+  const editing = useEditingState(id);
+  const dispatch = useAppDispatch();
+  const closePopover = useCallback(() => {
+    setAnchorEl(null);
+    dispatch(
+      blockEditActions.setBlockEditState({
+        id: docId,
+        state: {
+          id,
+          editing: false,
+        },
+      })
+    );
+    onAfterClose?.();
+  }, [dispatch, docId, id, onAfterClose]);
+
+  const selectBlock = useCallback(() => {
+    dispatch(
+      setRectSelectionThunk({
+        docId,
+        selection: [id],
+      })
+    );
+  }, [dispatch, docId, id]);
+
+  const openPopover = useCallback(() => {
+    setAnchorEl(anchorElRef.current);
+    selectBlock();
+    onAfterOpen?.();
+  }, [onAfterOpen, selectBlock]);
+
+  useEffect(() => {
+    if (editing) {
+      openPopover();
+    }
+  }, [editing, openPopover]);
+
+  const contextHolder = useMemo(() => {
+    return (
+      <Popover
+        disableRestoreFocus={true}
+        disableAutoFocus={true}
+        transformOrigin={{
+          vertical: 'top',
+          horizontal: 'center',
+        }}
+        anchorOrigin={{
+          vertical: 'bottom',
+          horizontal: 'center',
+        }}
+        onMouseDown={(e) => e.stopPropagation()}
+        onClose={closePopover}
+        open={open}
+        anchorEl={anchorEl}
+      >
+        {renderContent({
+          onClose: closePopover,
+        })}
+      </Popover>
+    );
+  }, [anchorEl, closePopover, open, renderContent]);
+
+  useEffect(() => {
+    if (!anchorElRef.current) {
+      return;
+    }
+
+    const el = anchorElRef.current;
+
+    el.addEventListener('click', selectBlock);
+    return () => {
+      el.removeEventListener('click', selectBlock);
+    };
+  }, [selectBlock]);
+
+  return {
+    contextHolder,
+    openPopover,
+    closePopover,
+    open,
+    anchorElRef,
+  };
+}

+ 16 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeBlockEdit.hooks.ts

@@ -0,0 +1,16 @@
+import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
+import { useAppSelector } from '$app/stores/store';
+import { BLOCK_EDIT_NAME } from '$app/constants/document/name';
+
+export function useSubscribeBlockEditState() {
+  const { docId } = useSubscribeDocument();
+  const blockEditState = useAppSelector((state) => state[BLOCK_EDIT_NAME][docId]);
+
+  return blockEditState;
+}
+
+export function useEditingState(id: string) {
+  const blockEditState = useSubscribeBlockEditState();
+
+  return blockEditState?.id === id && blockEditState?.editing;
+}

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

@@ -0,0 +1,121 @@
+import React, { useCallback, useRef, useState } from 'react';
+import { ImageSvg } from '$app/components/_shared/svg/ImageSvg';
+import { CircularProgress } from '@mui/material';
+import { writeImage } from '$app/utils/document/image';
+import { isTauri } from '$app/utils/env';
+
+export interface UploadImageProps {
+  onChange: (filePath: string) => void;
+}
+
+function UploadImage({ onChange }: UploadImageProps) {
+  const inputRef = useRef<HTMLInputElement>(null);
+  const [loading, setLoading] = useState<boolean>(false);
+  const [error, setError] = useState<string>('');
+  const beforeUpload = useCallback((file: File) => {
+    // check file size and type
+    const sizeMatched = file.size / 1024 / 1024 < 5; // 5MB
+    const typeMatched = /image\/(png|jpg|jpeg|gif)/.test(file.type); // png, jpg, jpeg, gif
+
+    return sizeMatched && typeMatched;
+  }, []);
+
+  const handleUpload = useCallback(
+    async (file: File) => {
+      if (!file) return;
+      if (!beforeUpload(file)) {
+        setError('Image should be less than 5MB and in png, jpg, jpeg, gif format');
+        return;
+      }
+
+      setError('');
+      setLoading(true);
+      // upload to tauri local data dir
+      try {
+        const filePath = await writeImage(file);
+
+        setLoading(false);
+        onChange(filePath);
+      } catch {
+        setLoading(false);
+        setError('Upload failed');
+      }
+    },
+    [beforeUpload, onChange]
+  );
+
+  const handleChange = useCallback(
+    (e: React.ChangeEvent<HTMLInputElement>) => {
+      const files = e.target.files;
+
+      if (!files || files.length === 0) return;
+      const file = files[0];
+
+      handleUpload(file);
+    },
+    [handleUpload]
+  );
+
+  const handleDrop = useCallback(
+    (e: React.DragEvent<HTMLDivElement>) => {
+      e.preventDefault();
+      const files = e.dataTransfer.files;
+
+      if (!files || files.length === 0) return;
+      const file = files[0];
+
+      handleUpload(file);
+    },
+    [handleUpload]
+  );
+
+  const handleDragOver = useCallback((e: React.DragEvent<HTMLDivElement>) => {
+    e.preventDefault();
+  }, []);
+
+  const errorColor = error ? '#FB006D' : undefined;
+
+  return (
+    <div className={'flex flex-col px-5 pt-5'}>
+      <div
+        className={'flex-1 cursor-pointer'}
+        onClick={() => {
+          if (loading) return;
+          inputRef.current?.click();
+        }}
+        tabIndex={0}
+      >
+        <input onChange={handleChange} ref={inputRef} type='file' className={'hidden'} accept={'image/*'} />
+        <div
+          className={
+            'flex flex-col items-center justify-center rounded-md border border-dashed border-main-accent bg-main-selector py-10 text-main-accent'
+          }
+          style={{
+            borderColor: errorColor,
+            background: error ? 'rgba(251, 0, 109, 0.08)' : undefined,
+            color: errorColor,
+          }}
+          onDrop={handleDrop}
+          onDragOver={handleDragOver}
+        >
+          <div className={'h-8 w-8'}>
+            <ImageSvg />
+          </div>
+          <div className={'my-2 p-2'}>{isTauri() ? 'Click space to chose image' : 'Chose image or drag to space'}</div>
+        </div>
+
+        {loading ? <CircularProgress /> : null}
+      </div>
+      <div
+        style={{
+          color: errorColor,
+        }}
+        className={`mt-5 text-sm text-shade-3`}
+      >
+        The maximum file size is 5MB. Supported formats: JPG, PNG, GIF, SVG.
+      </div>
+    </div>
+  );
+}
+
+export default UploadImage;

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

@@ -1,4 +1,4 @@
-import { BlockConfig, BlockType, SplitRelationship } from '$app/interfaces/document';
+import { Align, BlockConfig, BlockType, SplitRelationship } from '$app/interfaces/document';
 
 
 /**
 /**
  * If the block type is not in the config, it will be thrown an error in development env
  * If the block type is not in the config, it will be thrown an error in development env
@@ -104,4 +104,14 @@ export const blockConfig: Record<string, BlockConfig> = {
       formula: '',
       formula: '',
     },
     },
   },
   },
+  [BlockType.ImageBlock]: {
+    canAddChild: false,
+    defaultData: {
+      url: '',
+      align: Align.Center,
+      width: 0,
+      height: 0,
+      caption: [],
+    },
+  },
 };
 };

+ 1 - 0
frontend/appflowy_tauri/src/appflowy_app/constants/document/name.ts

@@ -1,5 +1,6 @@
 export const DOCUMENT_NAME = 'document';
 export const DOCUMENT_NAME = 'document';
 export const TEMPORARY_NAME = 'document/temporary';
 export const TEMPORARY_NAME = 'document/temporary';
+export const BLOCK_EDIT_NAME = 'document/block_edit';
 export const RANGE_NAME = 'document/range';
 export const RANGE_NAME = 'document/range';
 
 
 export const RECT_RANGE_NAME = 'document/rect_range';
 export const RECT_RANGE_NAME = 'document/rect_range';

+ 19 - 4
frontend/appflowy_tauri/src/appflowy_app/interfaces/document.ts

@@ -25,13 +25,10 @@ export enum BlockType {
   ToggleListBlock = 'toggle_list',
   ToggleListBlock = 'toggle_list',
   CodeBlock = 'code',
   CodeBlock = 'code',
   EquationBlock = 'math_equation',
   EquationBlock = 'math_equation',
-  EmbedBlock = 'embed',
   QuoteBlock = 'quote',
   QuoteBlock = 'quote',
   CalloutBlock = 'callout',
   CalloutBlock = 'callout',
   DividerBlock = 'divider',
   DividerBlock = 'divider',
-  MediaBlock = 'media',
-  TableBlock = 'table',
-  ColumnBlock = 'column',
+  ImageBlock = 'image',
 }
 }
 
 
 export interface EauqtionBlockData {
 export interface EauqtionBlockData {
@@ -71,6 +68,20 @@ export interface TextBlockData {
 
 
 export interface DividerBlockData {}
 export interface DividerBlockData {}
 
 
+export enum Align {
+  Left = 'left',
+  Center = 'center',
+  Right = 'right',
+}
+
+export interface ImageBlockData {
+  width: number;
+  height: number;
+  caption: Op[];
+  url: string;
+  align: Align;
+}
+
 export type PageBlockData = TextBlockData;
 export type PageBlockData = TextBlockData;
 
 
 export type BlockData<Type> = Type extends BlockType.HeadingBlock
 export type BlockData<Type> = Type extends BlockType.HeadingBlock
@@ -93,6 +104,8 @@ export type BlockData<Type> = Type extends BlockType.HeadingBlock
   ? CalloutBlockData
   ? CalloutBlockData
   : Type extends BlockType.EquationBlock
   : Type extends BlockType.EquationBlock
   ? EauqtionBlockData
   ? EauqtionBlockData
+  : Type extends BlockType.ImageBlock
+  ? ImageBlockData
   : Type extends BlockType.TextBlock
   : Type extends BlockType.TextBlock
   ? TextBlockData
   ? TextBlockData
   : any;
   : any;
@@ -142,6 +155,7 @@ export enum SlashCommandOptionKey {
   HEADING_1,
   HEADING_1,
   HEADING_2,
   HEADING_2,
   HEADING_3,
   HEADING_3,
+  IMAGE,
 }
 }
 
 
 export interface SlashCommandOption {
 export interface SlashCommandOption {
@@ -153,6 +167,7 @@ export interface SlashCommandOption {
 export enum SlashCommandGroup {
 export enum SlashCommandGroup {
   BASIC = 'Basic',
   BASIC = 'Basic',
   MEDIA = 'Media',
   MEDIA = 'Media',
+  ADVANCED = 'Advanced',
 }
 }
 
 
 export interface RectSelectionState {
 export interface RectSelectionState {

+ 22 - 5
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/menu.ts

@@ -9,6 +9,7 @@ import Delta, { Op } from 'quill-delta';
 import { getDeltaText } from '$app/utils/document/delta';
 import { getDeltaText } from '$app/utils/document/delta';
 import { RootState } from '$app/stores/store';
 import { RootState } from '$app/stores/store';
 import { DOCUMENT_NAME } from '$app/constants/document/name';
 import { DOCUMENT_NAME } from '$app/constants/document/name';
+import { blockEditActions } from '$app_reducers/document/block_edit_slice';
 
 
 /**
 /**
  * add block below click
  * add block below click
@@ -90,7 +91,7 @@ export const triggerSlashCommandActionThunk = createAsyncThunk(
     const defaultData = blockConfig[props.type].defaultData;
     const defaultData = blockConfig[props.type].defaultData;
 
 
     if (node.type === BlockType.TextBlock && (text === '' || text === '/')) {
     if (node.type === BlockType.TextBlock && (text === '' || text === '/')) {
-      dispatch(
+      const { payload: newId } = await dispatch(
         turnToBlockThunk({
         turnToBlockThunk({
           id,
           id,
           controller,
           controller,
@@ -101,6 +102,16 @@ export const triggerSlashCommandActionThunk = createAsyncThunk(
           },
           },
         })
         })
       );
       );
+
+      dispatch(
+        blockEditActions.setBlockEditState({
+          id: docId,
+          state: {
+            id: newId as string,
+            editing: true,
+          },
+        })
+      );
       return;
       return;
     }
     }
 
 
@@ -122,10 +133,7 @@ export const triggerSlashCommandActionThunk = createAsyncThunk(
         id,
         id,
         controller,
         controller,
         type: props.type,
         type: props.type,
-        data: {
-          ...defaultData,
-          ...props.data,
-        },
+        data: defaultData,
       })
       })
     );
     );
     const newBlockId = insertNodePayload.payload as string;
     const newBlockId = insertNodePayload.payload as string;
@@ -136,5 +144,14 @@ export const triggerSlashCommandActionThunk = createAsyncThunk(
         caret: { id: newBlockId, index: 0, length: 0 },
         caret: { id: newBlockId, index: 0, length: 0 },
       })
       })
     );
     );
+    dispatch(
+      blockEditActions.setBlockEditState({
+        id: docId,
+        state: {
+          id: newBlockId,
+          editing: true,
+        },
+      })
+    );
   }
   }
 );
 );

+ 31 - 0
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/block_edit_slice.ts

@@ -0,0 +1,31 @@
+import { createSlice, PayloadAction } from '@reduxjs/toolkit';
+import { BLOCK_EDIT_NAME } from '$app/constants/document/name';
+
+interface BlockEditState {
+  id: string;
+  editing: boolean;
+}
+
+const initialState: Record<string, BlockEditState> = {};
+
+export const blockEditSlice = createSlice({
+  name: BLOCK_EDIT_NAME,
+  initialState,
+  reducers: {
+    setBlockEditState: (state, action: PayloadAction<{ id: string; state: BlockEditState }>) => {
+      const { id, state: blockEditState } = action.payload;
+
+      state[id] = blockEditState;
+    },
+    initBlockEditState: (state, action: PayloadAction<string>) => {
+      const docId = action.payload;
+
+      state[docId] = {
+        ...state[docId],
+        editing: false,
+      };
+    },
+  },
+});
+
+export const blockEditActions = blockEditSlice.actions;

+ 2 - 0
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/slice.ts

@@ -19,6 +19,7 @@ import {
   SLASH_COMMAND_NAME,
   SLASH_COMMAND_NAME,
   TEXT_LINK_NAME,
   TEXT_LINK_NAME,
 } from '$app/constants/document/name';
 } from '$app/constants/document/name';
+import { blockEditSlice } from '$app_reducers/document/block_edit_slice';
 
 
 const initialState: Record<string, DocumentState> = {};
 const initialState: Record<string, DocumentState> = {};
 
 
@@ -425,6 +426,7 @@ export const documentReducers = {
   [slashCommandSlice.name]: slashCommandSlice.reducer,
   [slashCommandSlice.name]: slashCommandSlice.reducer,
   [linkPopoverSlice.name]: linkPopoverSlice.reducer,
   [linkPopoverSlice.name]: linkPopoverSlice.reducer,
   [temporarySlice.name]: temporarySlice.reducer,
   [temporarySlice.name]: temporarySlice.reducer,
+  [blockEditSlice.name]: blockEditSlice.reducer,
 };
 };
 
 
 export const documentActions = documentSlice.actions;
 export const documentActions = documentSlice.actions;

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

@@ -0,0 +1,53 @@
+export async function readImage(url: string) {
+  const { BaseDirectory, readBinaryFile } = await import('@tauri-apps/api/fs');
+
+  try {
+    const data = await readBinaryFile(url, { dir: BaseDirectory.AppLocalData });
+    const type = url.split('.').pop();
+    const blob = new Blob([data], {
+      type: `image/${type}`,
+    });
+
+    return URL.createObjectURL(blob);
+  } catch (e) {
+    return Promise.reject(e);
+  }
+}
+
+export function convertBlobToBase64(blob: Blob) {
+  return new Promise((resolve, reject) => {
+    const reader = new FileReader();
+
+    reader.onloadend = () => {
+      if (!reader.result) return;
+
+      resolve(reader.result);
+    };
+
+    reader.onerror = reject;
+    reader.readAsDataURL(blob);
+  });
+}
+
+export async function writeImage(file: File) {
+  const { BaseDirectory, createDir, exists, writeBinaryFile } = await import('@tauri-apps/api/fs');
+
+  const fileName = `${Date.now()}-${file.name}`;
+  const arrayBuffer = await file.arrayBuffer();
+  const unit8Array = new Uint8Array(arrayBuffer);
+
+  try {
+    const existDir = await exists('images', { dir: BaseDirectory.AppLocalData });
+
+    if (!existDir) {
+      await createDir('images', { dir: BaseDirectory.AppLocalData });
+    }
+
+    const filePath = 'images/' + fileName;
+
+    await writeBinaryFile(filePath, unit8Array, { dir: BaseDirectory.AppLocalData });
+    return filePath;
+  } catch (e) {
+    return Promise.reject(e);
+  }
+}

+ 8 - 0
frontend/appflowy_tauri/src/appflowy_app/utils/env.ts

@@ -1,3 +1,11 @@
 export function isApple() {
 export function isApple() {
   return typeof navigator !== 'undefined' && /Mac OS X/.test(navigator.userAgent);
   return typeof navigator !== 'undefined' && /Mac OS X/.test(navigator.userAgent);
 }
 }
+
+export function isTauri() {
+  // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+  // @ts-ignore
+  const isTauri = window.__TAURI__;
+
+  return isTauri;
+}

+ 4 - 0
frontend/appflowy_tauri/src/appflowy_app/views/DocumentPage.tsx

@@ -7,10 +7,14 @@ const muiTheme = createTheme({
   typography: {
   typography: {
     fontFamily: ['Poppins'].join(','),
     fontFamily: ['Poppins'].join(','),
     fontSize: 12,
     fontSize: 12,
+    button: {
+      textTransform: 'none',
+    },
   },
   },
   palette: {
   palette: {
     primary: {
     primary: {
       main: '#00BCF0',
       main: '#00BCF0',
+      light: '#00BCF0',
     },
     },
   },
   },
 });
 });