mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
feat: support image block (#2912)
This commit is contained in:
parent
f0d5f51703
commit
452d7eb6d0
@ -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"] }
|
||||
|
@ -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": {
|
||||
|
@ -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';
|
||||
|
@ -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: <DataObject />,
|
||||
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) => {
|
||||
if (!searchText) return true;
|
||||
const match = (text: string) => {
|
||||
|
@ -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<number>(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,
|
||||
|
@ -17,7 +17,7 @@ export default function CalloutBlock({
|
||||
const { openEmojiSelect, open, closeEmojiSelect, id, anchorEl, onEmojiSelect } = useCalloutBlock(node.id);
|
||||
|
||||
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={'flex h-[calc(1.5em_+_2px)] w-[24px] select-none items-center justify-start'}>
|
||||
<IconButton
|
||||
|
@ -1,52 +1,82 @@
|
||||
import React from 'react';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { BlockType, NestedBlock } from '$app/interfaces/document';
|
||||
import KatexMath from '$app/components/document/_shared/KatexMath';
|
||||
import Popover from '@mui/material/Popover';
|
||||
import EquationEditContent from '$app/components/document/_shared/TemporaryInput/EquationEditContent';
|
||||
import { useEquationBlock } from '$app/components/document/EquationBlock/useEquationBlock';
|
||||
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> }) {
|
||||
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 (
|
||||
<>
|
||||
<div
|
||||
ref={ref}
|
||||
onClick={onOpenPopover}
|
||||
className={'flex min-h-[59px] cursor-pointer items-center justify-center overflow-hidden hover:bg-main-selector'}
|
||||
>
|
||||
{formula ? (
|
||||
<KatexMath latex={formula} />
|
||||
) : (
|
||||
<span className={'flex text-shade-2'}>
|
||||
<Functions />
|
||||
<span>Add a TeX equation</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Popover
|
||||
transformOrigin={{
|
||||
vertical: 'top',
|
||||
horizontal: 'center',
|
||||
}}
|
||||
onMouseDown={(e) => 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 (
|
||||
<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}
|
||||
onConfirm={async () => {
|
||||
await onConfirm();
|
||||
onClose();
|
||||
}}
|
||||
/>
|
||||
</Popover>
|
||||
);
|
||||
},
|
||||
[value, onChange, onConfirm]
|
||||
);
|
||||
|
||||
const { open, contextHolder, openPopover, anchorElRef } = useBlockPopover({
|
||||
id: node.id,
|
||||
renderContent,
|
||||
onAfterOpen,
|
||||
});
|
||||
const displayFormula = open ? value : formula;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={anchorElRef}
|
||||
onClick={openPopover}
|
||||
className={'my-1 flex min-h-[59px] cursor-pointer flex-col overflow-hidden rounded hover:bg-main-secondary'}
|
||||
>
|
||||
{displayFormula ? (
|
||||
<KatexMath latex={displayFormula} />
|
||||
) : (
|
||||
<div className={'flex h-[100%] w-[100%] flex-1 items-center bg-main-selector px-1 text-shade-2'}>
|
||||
<Functions />
|
||||
<span>Add a TeX equation</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{contextHolder}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -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,
|
||||
};
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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,
|
||||
};
|
||||
}
|
@ -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<HTMLDivElement>) {
|
||||
const { node, childIds, isSelected, ref } = useNode(id);
|
||||
@ -64,6 +65,8 @@ function NodeComponent({ id, ...props }: { id: string } & React.HTMLAttributes<H
|
||||
return <CodeBlock node={node} />;
|
||||
case BlockType.EquationBlock:
|
||||
return <EquationBlock node={node} />;
|
||||
case BlockType.ImageBlock:
|
||||
return <ImageBlock node={node} />;
|
||||
default:
|
||||
return <UnSupportedBlock />;
|
||||
}
|
||||
|
@ -9,7 +9,7 @@ export function useVirtualizedList(count: number) {
|
||||
const virtualize = useVirtualizer({
|
||||
count,
|
||||
getScrollElement: () => parentRef.current,
|
||||
overscan: 5,
|
||||
overscan: 10,
|
||||
estimateSize: () => {
|
||||
return defaultSize;
|
||||
},
|
||||
|
@ -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,
|
||||
};
|
||||
}
|
@ -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;
|
||||
}
|
@ -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;
|
@ -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<string, BlockConfig> = {
|
||||
formula: '',
|
||||
},
|
||||
},
|
||||
[BlockType.ImageBlock]: {
|
||||
canAddChild: false,
|
||||
defaultData: {
|
||||
url: '',
|
||||
align: Align.Center,
|
||||
width: 0,
|
||||
height: 0,
|
||||
caption: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
@ -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';
|
||||
|
@ -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> = Type extends BlockType.HeadingBlock
|
||||
@ -93,6 +104,8 @@ export type BlockData<Type> = 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 {
|
||||
|
@ -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,
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
);
|
||||
|
@ -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;
|
@ -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<string, DocumentState> = {};
|
||||
|
||||
@ -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;
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
|
@ -7,10 +7,14 @@ const muiTheme = createTheme({
|
||||
typography: {
|
||||
fontFamily: ['Poppins'].join(','),
|
||||
fontSize: 12,
|
||||
button: {
|
||||
textTransform: 'none',
|
||||
},
|
||||
},
|
||||
palette: {
|
||||
primary: {
|
||||
main: '#00BCF0',
|
||||
light: '#00BCF0',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
Loading…
Reference in New Issue
Block a user