From 452d7eb6d0c26715efa15bf3a3c2a9387c42e629 Mon Sep 17 00:00:00 2001 From: "Kilu.He" <108015703+qinluhe@users.noreply.github.com> Date: Mon, 3 Jul 2023 10:04:40 +0800 Subject: [PATCH] feat: support image block (#2912) --- frontend/appflowy_tauri/src-tauri/Cargo.toml | 2 +- .../appflowy_tauri/src-tauri/tauri.conf.json | 13 ++ .../document/BlockSideToolbar/index.tsx | 1 - .../document/BlockSlash/BlockSlashMenu.tsx | 19 +- .../document/BlockSlash/index.hooks.ts | 28 +-- .../document/CalloutBlock/index.tsx | 2 +- .../document/EquationBlock/index.tsx | 100 ++++++---- .../EquationBlock/useEquationBlock.ts | 71 ------- .../document/ImageBlock/EditImage.tsx | 97 ++++++++++ .../document/ImageBlock/ImageAlign.tsx | 118 ++++++++++++ .../document/ImageBlock/ImagePlaceholder.tsx | 55 ++++++ .../document/ImageBlock/ImageRender.tsx | 71 +++++++ .../document/ImageBlock/ImageToolbar.tsx | 39 ++++ .../components/document/ImageBlock/index.tsx | 58 ++++++ .../document/ImageBlock/useImageBlock.ts | 182 ++++++++++++++++++ .../components/document/Node/index.tsx | 3 + .../VirtualizedList/VirtualizedList.hooks.tsx | 2 +- .../BlockPopover/BlockPopover.hooks.tsx | 107 ++++++++++ .../_shared/SubscribeBlockEdit.hooks.ts | 16 ++ .../document/_shared/UploadImage/index.tsx | 121 ++++++++++++ .../appflowy_app/constants/document/config.ts | 12 +- .../appflowy_app/constants/document/name.ts | 1 + .../src/appflowy_app/interfaces/document.ts | 23 ++- .../reducers/document/async-actions/menu.ts | 27 ++- .../reducers/document/block_edit_slice.ts | 31 +++ .../stores/reducers/document/slice.ts | 2 + .../src/appflowy_app/utils/document/image.ts | 53 +++++ .../src/appflowy_app/utils/env.ts | 8 + .../src/appflowy_app/views/DocumentPage.tsx | 4 + 29 files changed, 1131 insertions(+), 135 deletions(-) delete mode 100644 frontend/appflowy_tauri/src/appflowy_app/components/document/EquationBlock/useEquationBlock.ts create mode 100644 frontend/appflowy_tauri/src/appflowy_app/components/document/ImageBlock/EditImage.tsx create mode 100644 frontend/appflowy_tauri/src/appflowy_app/components/document/ImageBlock/ImageAlign.tsx create mode 100644 frontend/appflowy_tauri/src/appflowy_app/components/document/ImageBlock/ImagePlaceholder.tsx create mode 100644 frontend/appflowy_tauri/src/appflowy_app/components/document/ImageBlock/ImageRender.tsx create mode 100644 frontend/appflowy_tauri/src/appflowy_app/components/document/ImageBlock/ImageToolbar.tsx create mode 100644 frontend/appflowy_tauri/src/appflowy_app/components/document/ImageBlock/index.tsx create mode 100644 frontend/appflowy_tauri/src/appflowy_app/components/document/ImageBlock/useImageBlock.ts create mode 100644 frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/BlockPopover/BlockPopover.hooks.tsx create mode 100644 frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeBlockEdit.hooks.ts create mode 100644 frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/UploadImage/index.tsx create mode 100644 frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/block_edit_slice.ts create mode 100644 frontend/appflowy_tauri/src/appflowy_app/utils/document/image.ts diff --git a/frontend/appflowy_tauri/src-tauri/Cargo.toml b/frontend/appflowy_tauri/src-tauri/Cargo.toml index d89b3590fb..75efed88bf 100644 --- a/frontend/appflowy_tauri/src-tauri/Cargo.toml +++ b/frontend/appflowy_tauri/src-tauri/Cargo.toml @@ -16,7 +16,7 @@ tauri-build = { version = "1.2", features = [] } [dependencies] serde_json = "1.0" 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" bytes = { version = "1.4" } tracing = { version = "0.1", features = ["log"] } diff --git a/frontend/appflowy_tauri/src-tauri/tauri.conf.json b/frontend/appflowy_tauri/src-tauri/tauri.conf.json index 5d60dc72c7..6b528bf4fb 100644 --- a/frontend/appflowy_tauri/src-tauri/tauri.conf.json +++ b/frontend/appflowy_tauri/src-tauri/tauri.conf.json @@ -16,6 +16,19 @@ "shell": { "all": false, "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": { diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/index.tsx index 1cce8fa8ec..a408578b31 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/index.tsx +++ b/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 BlockMenu from './BlockMenu'; import ToolbarButton from './ToolbarButton'; -import { rectSelectionActions } from '$app_reducers/document/slice'; import { addBlockBelowClickThunk } from '$app_reducers/document/async-actions/menu'; import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks'; import { RANGE_NAME, RECT_RANGE_NAME } from '$app/constants/document/name'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSlash/BlockSlashMenu.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSlash/BlockSlashMenu.tsx index 75074cc31a..f512957918 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSlash/BlockSlashMenu.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSlash/BlockSlashMenu.tsx @@ -11,6 +11,8 @@ import { TextFields, Title, SafetyDivider, + Image, + Functions, } from '@mui/icons-material'; import { BlockData, @@ -25,6 +27,7 @@ import { useSubscribeDocument } from '$app/components/document/_shared/Subscribe import { slashCommandActions } from '$app_reducers/document/slice'; import { Keyboard } from '$app/constants/document/keyboard'; import { selectOptionByUpDown } from '$app/utils/document/menu'; +import { blockEditActions } from '$app_reducers/document/block_edit_slice'; function BlockSlashMenu({ id, @@ -57,7 +60,7 @@ function BlockSlashMenu({ ); onClose?.(); }, - [controller, dispatch, id, onClose] + [controller, dispatch, docId, id, onClose] ); const options: (SlashCommandOption & { @@ -160,6 +163,20 @@ function BlockSlashMenu({ icon: , group: SlashCommandGroup.MEDIA, }, + { + key: SlashCommandOptionKey.IMAGE, + type: BlockType.ImageBlock, + title: 'Image', + icon: , + group: SlashCommandGroup.MEDIA, + }, + { + key: SlashCommandOptionKey.EQUATION, + type: BlockType.EquationBlock, + title: 'Block equation', + icon: , + group: SlashCommandGroup.ADVANCED, + }, ].filter((option) => { if (!searchText) return true; const match = (text: string) => { diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSlash/index.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSlash/index.hooks.ts index bee66cd185..7d807b4715 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSlash/index.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSlash/index.hooks.ts @@ -1,17 +1,17 @@ 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 { 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 { useSubscribeSlashState } from '$app/components/document/_shared/SubscribeSlash.hooks'; +import { getDeltaText } from '$app/utils/document/delta'; export function useBlockSlash() { const dispatch = useAppDispatch(); const { docId } = useSubscribeDocument(); - const { blockId, visible, slashText, hoverOption } = useSubscribeSlash(); - const [anchorPosition, setAnchorPosition] = React.useState<{ + const [anchorPosition, setAnchorPosition] = useState<{ top: number; left: number; }>(); @@ -68,24 +68,24 @@ export function useSubscribeSlash() { const slashCommandState = useSubscribeSlashState(); const visible = slashCommandState.isSlashCommand; const blockId = slashCommandState.blockId; + const rightDistanceRef = useRef(0); const { node } = useSubscribeNode(blockId || ''); const slashText = useMemo(() => { if (!node) return ''; - const delta = node.data.delta || []; + const delta = new Delta(node.data.delta); + const length = delta.length(); + const slicedDelta = delta.slice(0, length - rightDistanceRef.current); - return delta - .map((op: Op) => { - if (typeof op.insert === 'string') { - return op.insert; - } else { - return ''; - } - }) - .join(''); + return getDeltaText(slicedDelta); }, [node]); + useEffect(() => { + if (!visible) return; + rightDistanceRef.current = new Delta(node.data.delta).length(); + }, [visible]); + return { visible, blockId, diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/CalloutBlock/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/CalloutBlock/index.tsx index 7d37b05e21..4b794974d5 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/CalloutBlock/index.tsx +++ b/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); return ( -
+
e.stopPropagation()}>
}) { - 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); + }, []); - return ( - <> -
- {formula ? ( - - ) : ( - - - Add a TeX equation - - )} -
- e.stopPropagation()} - onClose={onClosePopover} - open={open} - anchorReference={'anchorPosition'} - anchorPosition={anchorPosition} - > + 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 ( { + await onConfirm(); + onClose(); + }} /> - + ); + }, + [value, onChange, onConfirm] + ); + + const { open, contextHolder, openPopover, anchorElRef } = useBlockPopover({ + id: node.id, + renderContent, + onAfterOpen, + }); + const displayFormula = open ? value : formula; + + return ( + <> +
+ {displayFormula ? ( + + ) : ( +
+ + Add a TeX equation +
+ )} +
+ {contextHolder} ); } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/EquationBlock/useEquationBlock.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/EquationBlock/useEquationBlock.ts deleted file mode 100644 index 15f897c197..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/EquationBlock/useEquationBlock.ts +++ /dev/null @@ -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) { - const { controller, docId } = useSubscribeDocument(); - const id = node.id; - const dispatch = useAppDispatch(); - const formula = node.data.formula; - const ref = useRef(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, - }; -} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/ImageBlock/EditImage.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/ImageBlock/EditImage.tsx new file mode 100644 index 0000000000..4b1dcd4e02 --- /dev/null +++ b/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(url); + const [tabKey, setTabKey] = useState(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 ( +
+ + + {isTauri() && } + + + + + {isTauri() && ( + + + + )} + + + setLinkVal(e.target.value)} + variant='outlined' + label={'URL'} + autoFocus={true} + style={{ + marginBottom: '10px', + }} + placeholder={'Please enter the URL of the image'} + /> + + +
+ ); +} + +export default EditImage; + +interface TabPanelProps { + children?: React.ReactNode; + index: TAB_KEYS; + value: TAB_KEYS; +} + +function TabPanel(props: TabPanelProps & React.HTMLAttributes) { + const { children, value, index, ...other } = props; + + return ( + + ); +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/ImageBlock/ImageAlign.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/ImageBlock/ImageAlign.tsx new file mode 100644 index 0000000000..6c2e8dac39 --- /dev/null +++ b/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(null); + const [anchorEl, setAnchorEl] = useState(); + 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 ; + case Align.Center: + return ; + default: + return ; + } + }; + + const updateAlign = useCallback( + (align: Align) => { + dispatch( + updateNodeDataThunk({ + id, + data: { + align, + }, + controller, + }) + ); + setAnchorEl(undefined); + }, + [controller, dispatch, id] + ); + + return ( + <> + +
{ + ref.current && setAnchorEl(ref.current); + }} + > + {renderAlign(align)} +
+
+ e.stopPropagation()} + anchorEl={anchorEl} + onClose={() => setAnchorEl(undefined)} + PaperProps={{ + style: { + backgroundColor: '#1E1E1E', + opacity: 0.8, + }, + }} + > +
+ {[Align.Left, Align.Center, Align.Right].map((item: Align) => { + return ( +
{ + updateAlign(item); + }} + > + {renderAlign(item)} +
+ ); + })} +
+
+ + ); +} + +export default ImageAlign; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/ImageBlock/ImagePlaceholder.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/ImageBlock/ImagePlaceholder.tsx new file mode 100644 index 0000000000..50bc8b39ee --- /dev/null +++ b/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 ( +
+ {loading && } + {error && ( + + Error loading image + + )} + {isEmpty && ( +
+ + + + Add an image +
+ )} +
+ ); +} + +export default ImagePlaceholder; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/ImageBlock/ImageRender.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/ImageBlock/ImageRender.tsx new file mode 100644 index 0000000000..ede72586cb --- /dev/null +++ b/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; + width: number; + height: number; + alignSelf: string; + src: string; + onResizeStart: (e: React.MouseEvent, isLeft: boolean) => void; +}) { + const [toolbarOpen, setToolbarOpen] = useState(false); + + const renderResizer = useCallback( + (isLeft: boolean) => { + return ( +
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`} + > +
+
+ ); + }, + [onResizeStart, toolbarOpen] + ); + + return ( +
setToolbarOpen(true)} + onMouseLeave={() => setToolbarOpen(false)} + style={{ + width: width + 'px', + height: height + 'px', + alignSelf, + }} + className={`relative cursor-default`} + > + {src && ( + + )} + {renderResizer(true)} + {renderResizer(false)} + +
+ ); +} + +export default ImageRender; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/ImageBlock/ImageToolbar.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/ImageBlock/ImageToolbar.tsx new file mode 100644 index 0000000000..d8a5c0edbe --- /dev/null +++ b/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 ( + <> +
+ setPopoverOpen(true)} onClose={() => setPopoverOpen(false)} /> + +
{ + dispatch(deleteNodeThunk({ id, controller })); + }} + className='flex items-center justify-center bg-transparent p-1' + > + +
+
+
+ + ); +} + +export default ImageToolbar; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/ImageBlock/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/ImageBlock/index.tsx new file mode 100644 index 0000000000..98c90b865e --- /dev/null +++ b/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 }) { + const { url } = node.data; + const { displaySize, onResizeStart, src, alignSelf, loading, error } = useImageBlock(node); + + const renderPopoverContent = useCallback( + ({ onClose }: { onClose: () => void }) => { + return ; + }, + [node.id, url] + ); + + const { anchorElRef, contextHolder, openPopover } = useBlockPopover({ + id: node.id, + renderContent: renderPopoverContent, + }); + + const { width, height } = displaySize; + + return ( + <> +
+ + +
+ {contextHolder} + + ); +} + +export default ImageBlock; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/ImageBlock/useImageBlock.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/ImageBlock/useImageBlock.ts new file mode 100644 index 0000000000..80c0940f17 --- /dev/null +++ b/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) { + const { url, width, align, height } = node.data; + const dispatch = useAppDispatch(); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(false); + const { controller } = useSubscribeDocument(); + const [resizing, setResizing] = useState(false); + const startResizePoint = useRef<{ + left: boolean; + x: number; + y: number; + }>(); + const startResizeWidth = useRef(0); + + const [src, setSrc] = useState(''); + const [displaySize, setDisplaySize] = useState<{ + width: number; + height: number; + }>({ + width: width || 0, + height: height || 0, + }); + + const onResizeStart = useCallback( + (e: React.MouseEvent, 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, + }; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/Node/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/Node/index.tsx index b67919e65b..412ec04fce 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/Node/index.tsx +++ b/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 { NodeIdContext } from '$app/components/document/_shared/SubscribeNode.hooks'; import EquationBlock from '$app/components/document/EquationBlock'; +import ImageBlock from '$app/components/document/ImageBlock'; function NodeComponent({ id, ...props }: { id: string } & React.HTMLAttributes) { const { node, childIds, isSelected, ref } = useNode(id); @@ -64,6 +65,8 @@ function NodeComponent({ id, ...props }: { id: string } & React.HTMLAttributes; case BlockType.EquationBlock: return ; + case BlockType.ImageBlock: + return ; default: return ; } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/VirtualizedList/VirtualizedList.hooks.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/VirtualizedList/VirtualizedList.hooks.tsx index 59252a038b..25572d1322 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/VirtualizedList/VirtualizedList.hooks.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/VirtualizedList/VirtualizedList.hooks.tsx @@ -9,7 +9,7 @@ export function useVirtualizedList(count: number) { const virtualize = useVirtualizer({ count, getScrollElement: () => parentRef.current, - overscan: 5, + overscan: 10, estimateSize: () => { return defaultSize; }, diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/BlockPopover/BlockPopover.hooks.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/BlockPopover/BlockPopover.hooks.tsx new file mode 100644 index 0000000000..e38006bed9 --- /dev/null +++ b/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(null); + const { docId } = useSubscribeDocument(); + + const [anchorEl, setAnchorEl] = useState(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 ( + e.stopPropagation()} + onClose={closePopover} + open={open} + anchorEl={anchorEl} + > + {renderContent({ + onClose: closePopover, + })} + + ); + }, [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, + }; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeBlockEdit.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeBlockEdit.hooks.ts new file mode 100644 index 0000000000..59d97b6f94 --- /dev/null +++ b/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; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/UploadImage/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/UploadImage/index.tsx new file mode 100644 index 0000000000..d268060211 --- /dev/null +++ b/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(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + 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) => { + const files = e.target.files; + + if (!files || files.length === 0) return; + const file = files[0]; + + handleUpload(file); + }, + [handleUpload] + ); + + const handleDrop = useCallback( + (e: React.DragEvent) => { + 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) => { + e.preventDefault(); + }, []); + + const errorColor = error ? '#FB006D' : undefined; + + return ( +
+
{ + if (loading) return; + inputRef.current?.click(); + }} + tabIndex={0} + > + +
+
+ +
+
{isTauri() ? 'Click space to chose image' : 'Chose image or drag to space'}
+
+ + {loading ? : null} +
+
+ The maximum file size is 5MB. Supported formats: JPG, PNG, GIF, SVG. +
+
+ ); +} + +export default UploadImage; diff --git a/frontend/appflowy_tauri/src/appflowy_app/constants/document/config.ts b/frontend/appflowy_tauri/src/appflowy_app/constants/document/config.ts index 71721f11c8..7f4597ebe2 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/constants/document/config.ts +++ b/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 @@ -104,4 +104,14 @@ export const blockConfig: Record = { formula: '', }, }, + [BlockType.ImageBlock]: { + canAddChild: false, + defaultData: { + url: '', + align: Align.Center, + width: 0, + height: 0, + caption: [], + }, + }, }; diff --git a/frontend/appflowy_tauri/src/appflowy_app/constants/document/name.ts b/frontend/appflowy_tauri/src/appflowy_app/constants/document/name.ts index d4b8715bce..a48d9a2f63 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/constants/document/name.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/constants/document/name.ts @@ -1,5 +1,6 @@ export const DOCUMENT_NAME = 'document'; export const TEMPORARY_NAME = 'document/temporary'; +export const BLOCK_EDIT_NAME = 'document/block_edit'; export const RANGE_NAME = 'document/range'; export const RECT_RANGE_NAME = 'document/rect_range'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/interfaces/document.ts b/frontend/appflowy_tauri/src/appflowy_app/interfaces/document.ts index d03bb08896..9052a156f5 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/interfaces/document.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/interfaces/document.ts @@ -25,13 +25,10 @@ export enum BlockType { ToggleListBlock = 'toggle_list', CodeBlock = 'code', EquationBlock = 'math_equation', - EmbedBlock = 'embed', QuoteBlock = 'quote', CalloutBlock = 'callout', DividerBlock = 'divider', - MediaBlock = 'media', - TableBlock = 'table', - ColumnBlock = 'column', + ImageBlock = 'image', } export interface EauqtionBlockData { @@ -71,6 +68,20 @@ export interface TextBlockData { 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 BlockData = Type extends BlockType.HeadingBlock @@ -93,6 +104,8 @@ export type BlockData = Type extends BlockType.HeadingBlock ? CalloutBlockData : Type extends BlockType.EquationBlock ? EauqtionBlockData + : Type extends BlockType.ImageBlock + ? ImageBlockData : Type extends BlockType.TextBlock ? TextBlockData : any; @@ -142,6 +155,7 @@ export enum SlashCommandOptionKey { HEADING_1, HEADING_2, HEADING_3, + IMAGE, } export interface SlashCommandOption { @@ -153,6 +167,7 @@ export interface SlashCommandOption { export enum SlashCommandGroup { BASIC = 'Basic', MEDIA = 'Media', + ADVANCED = 'Advanced', } export interface RectSelectionState { diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/menu.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/menu.ts index 3d275b2212..d9214e164e 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/menu.ts +++ b/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 { RootState } from '$app/stores/store'; import { DOCUMENT_NAME } from '$app/constants/document/name'; +import { blockEditActions } from '$app_reducers/document/block_edit_slice'; /** * add block below click @@ -90,7 +91,7 @@ export const triggerSlashCommandActionThunk = createAsyncThunk( const defaultData = blockConfig[props.type].defaultData; if (node.type === BlockType.TextBlock && (text === '' || text === '/')) { - dispatch( + const { payload: newId } = await dispatch( turnToBlockThunk({ id, controller, @@ -101,6 +102,16 @@ export const triggerSlashCommandActionThunk = createAsyncThunk( }, }) ); + + dispatch( + blockEditActions.setBlockEditState({ + id: docId, + state: { + id: newId as string, + editing: true, + }, + }) + ); return; } @@ -122,10 +133,7 @@ export const triggerSlashCommandActionThunk = createAsyncThunk( id, controller, type: props.type, - data: { - ...defaultData, - ...props.data, - }, + data: defaultData, }) ); const newBlockId = insertNodePayload.payload as string; @@ -136,5 +144,14 @@ export const triggerSlashCommandActionThunk = createAsyncThunk( caret: { id: newBlockId, index: 0, length: 0 }, }) ); + dispatch( + blockEditActions.setBlockEditState({ + id: docId, + state: { + id: newBlockId, + editing: true, + }, + }) + ); } ); diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/block_edit_slice.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/block_edit_slice.ts new file mode 100644 index 0000000000..3aa80bfd36 --- /dev/null +++ b/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 = {}; + +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) => { + const docId = action.payload; + + state[docId] = { + ...state[docId], + editing: false, + }; + }, + }, +}); + +export const blockEditActions = blockEditSlice.actions; diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/slice.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/slice.ts index 96cece63e5..2b095a024c 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/slice.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/slice.ts @@ -19,6 +19,7 @@ import { SLASH_COMMAND_NAME, TEXT_LINK_NAME, } from '$app/constants/document/name'; +import { blockEditSlice } from '$app_reducers/document/block_edit_slice'; const initialState: Record = {}; @@ -425,6 +426,7 @@ export const documentReducers = { [slashCommandSlice.name]: slashCommandSlice.reducer, [linkPopoverSlice.name]: linkPopoverSlice.reducer, [temporarySlice.name]: temporarySlice.reducer, + [blockEditSlice.name]: blockEditSlice.reducer, }; export const documentActions = documentSlice.actions; diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/document/image.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/document/image.ts new file mode 100644 index 0000000000..71dad9a469 --- /dev/null +++ b/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); + } +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/env.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/env.ts index fd5aa54113..064dc042aa 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/utils/env.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/utils/env.ts @@ -1,3 +1,11 @@ export function isApple() { 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; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/views/DocumentPage.tsx b/frontend/appflowy_tauri/src/appflowy_app/views/DocumentPage.tsx index 8f42ef2593..8b5fd9d30a 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/views/DocumentPage.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/views/DocumentPage.tsx @@ -7,10 +7,14 @@ const muiTheme = createTheme({ typography: { fontFamily: ['Poppins'].join(','), fontSize: 12, + button: { + textTransform: 'none', + }, }, palette: { primary: { main: '#00BCF0', + light: '#00BCF0', }, }, });