feat: support image block (#2912)

This commit is contained in:
Kilu.He 2023-07-03 10:04:40 +08:00 committed by GitHub
parent f0d5f51703
commit 452d7eb6d0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
29 changed files with 1131 additions and 135 deletions

View File

@ -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"] }

View File

@ -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": {

View File

@ -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';

View File

@ -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) => {

View File

@ -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,

View File

@ -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

View File

@ -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}
</>
);
}

View File

@ -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,
};
}

View File

@ -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>
);
}

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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,
};
}

View File

@ -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 />;
}

View File

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

View File

@ -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,
};
}

View File

@ -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;
}

View File

@ -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;

View File

@ -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: [],
},
},
};

View File

@ -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';

View File

@ -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 {

View File

@ -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,
},
})
);
}
);

View File

@ -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;

View File

@ -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;

View File

@ -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);
}
}

View File

@ -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;
}

View File

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