feat: support uploading local image (#4820)

* feat: support uploading local image

* fix: code review

* fix: add hover style to empty image block
This commit is contained in:
Kilu.He 2024-03-05 16:59:30 +08:00 committed by GitHub
parent ff8eb0d479
commit 2ec6250ddd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 398 additions and 82 deletions

View File

@ -3816,6 +3816,17 @@ dependencies = [
"objc_exception",
]
[[package]]
name = "objc-foundation"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1add1b659e36c9607c7aab864a76c7a4c2760cd0cd2e120f3fb8b952c7e22bf9"
dependencies = [
"block",
"objc",
"objc_id",
]
[[package]]
name = "objc_exception"
version = "0.1.2"
@ -4956,6 +4967,30 @@ dependencies = [
"winreg 0.50.0",
]
[[package]]
name = "rfd"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0149778bd99b6959285b0933288206090c50e2327f47a9c463bfdbf45c8823ea"
dependencies = [
"block",
"dispatch",
"glib-sys",
"gobject-sys",
"gtk-sys",
"js-sys",
"lazy_static",
"log",
"objc",
"objc-foundation",
"objc_id",
"raw-window-handle",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"windows 0.37.0",
]
[[package]]
name = "ring"
version = "0.16.20"
@ -5896,6 +5931,7 @@ dependencies = [
"rand 0.8.5",
"raw-window-handle",
"regex",
"rfd",
"semver",
"serde",
"serde_json",
@ -7050,6 +7086,19 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows"
version = "0.37.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57b543186b344cc61c85b5aab0d2e3adf4e0f99bc076eff9aa5927bcc0b8a647"
dependencies = [
"windows_aarch64_msvc 0.37.0",
"windows_i686_gnu 0.37.0",
"windows_i686_msvc 0.37.0",
"windows_x86_64_gnu 0.37.0",
"windows_x86_64_msvc 0.37.0",
]
[[package]]
name = "windows"
version = "0.39.0"
@ -7205,6 +7254,12 @@ version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb7764e35d4db8a7921e09562a0304bf2f93e0a51bfccee0bd0bb0b666b015ea"
[[package]]
name = "windows_aarch64_msvc"
version = "0.37.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2623277cb2d1c216ba3b578c0f3cf9cdebeddb6e66b1b218bb33596ea7769c3a"
[[package]]
name = "windows_aarch64_msvc"
version = "0.39.0"
@ -7229,6 +7284,12 @@ version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbaa0368d4f1d2aaefc55b6fcfee13f41544ddf36801e793edbbfd7d7df075ef"
[[package]]
name = "windows_i686_gnu"
version = "0.37.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3925fd0b0b804730d44d4b6278c50f9699703ec49bcd628020f46f4ba07d9e1"
[[package]]
name = "windows_i686_gnu"
version = "0.39.0"
@ -7253,6 +7314,12 @@ version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a28637cb1fa3560a16915793afb20081aba2c92ee8af57b4d5f28e4b3e7df313"
[[package]]
name = "windows_i686_msvc"
version = "0.37.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce907ac74fe331b524c1298683efbf598bb031bc84d5e274db2083696d07c57c"
[[package]]
name = "windows_i686_msvc"
version = "0.39.0"
@ -7277,6 +7344,12 @@ version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ffe5e8e31046ce6230cc7215707b816e339ff4d4d67c65dffa206fd0f7aa7b9a"
[[package]]
name = "windows_x86_64_gnu"
version = "0.37.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2babfba0828f2e6b32457d5341427dcbb577ceef556273229959ac23a10af33d"
[[package]]
name = "windows_x86_64_gnu"
version = "0.39.0"
@ -7319,6 +7392,12 @@ version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a657e1e9d3f514745a572a6846d3c7aa7dbe1658c056ed9c3344c4109a6949e"
[[package]]
name = "windows_x86_64_msvc"
version = "0.37.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f4dd6dc7df2d84cf7b33822ed5b86318fb1781948e9663bacd047fc9dd52259d"
[[package]]
name = "windows_x86_64_msvc"
version = "0.39.0"

View File

@ -34,7 +34,7 @@ lru = "0.12.0"
[dependencies]
serde_json.workspace = true
serde.workspace = true
tauri = { version = "1.5", features = [
tauri = { version = "1.5", features = [ "dialog-all",
"clipboard-all",
"fs-all",
"shell-open",

View File

@ -20,8 +20,7 @@
"fs": {
"all": true,
"scope": [
"$APPLOCALDATA/**",
"$APPLOCALDATA/images/*"
"$APPLOCALDATA/**"
],
"readFile": true,
"writeFile": true,
@ -37,6 +36,14 @@
"all": true,
"writeText": true,
"readText": true
},
"dialog": {
"all": true,
"ask": true,
"confirm": true,
"message": true,
"open": true,
"save": true
}
},
"bundle": {

View File

@ -111,6 +111,7 @@ export interface MathEquationNode extends Element {
}
export enum ImageType {
Local = 0,
Internal = 1,
External = 2,
}

View File

@ -0,0 +1,60 @@
import React, { forwardRef, useCallback, useEffect, useRef, useState } from 'react';
import { CircularProgress } from '@mui/material';
import { useTranslation } from 'react-i18next';
import { ErrorOutline } from '@mui/icons-material';
export const LocalImage = forwardRef<
HTMLImageElement,
{
renderErrorNode?: () => React.ReactElement | null;
} & React.ImgHTMLAttributes<HTMLImageElement>
>((localImageProps, ref) => {
const { src, renderErrorNode, ...props } = localImageProps;
const imageRef = useRef<HTMLImageElement>(null);
const { t } = useTranslation();
const [imageURL, setImageURL] = useState<string>('');
const [loading, setLoading] = useState<boolean>(true);
const [isError, setIsError] = useState<boolean>(false);
const loadLocalImage = useCallback(async () => {
if (!src) return;
setLoading(true);
setIsError(false);
const { readBinaryFile, BaseDirectory } = await import('@tauri-apps/api/fs');
try {
const buffer = await readBinaryFile(src, { dir: BaseDirectory.AppLocalData });
const blob = new Blob([buffer]);
setImageURL(URL.createObjectURL(blob));
} catch (e) {
setIsError(true);
}
setLoading(false);
}, [src]);
useEffect(() => {
void loadLocalImage();
}, [loadLocalImage]);
if (loading) {
return (
<div className={`flex h-full w-full items-center justify-center gap-2`}>
<CircularProgress size={16} />
{t('editor.loading')}...
</div>
);
}
if (isError) {
if (renderErrorNode) return renderErrorNode();
return (
<div className={'flex h-full w-full items-center justify-center gap-2 bg-red-50'}>
<ErrorOutline className={'text-function-error'} />
<div className={'text-function-error'}>{t('editor.imageLoadFailed')}</div>
</div>
);
}
return <img ref={ref ?? imageRef} draggable={false} loading={'lazy'} alt={'local image'} {...props} src={imageURL} />;
});

View File

@ -1,7 +1,95 @@
import React from 'react';
import React, { useCallback } from 'react';
import Button from '@mui/material/Button';
import { useTranslation } from 'react-i18next';
import CloudUploadIcon from '@mui/icons-material/CloudUploadOutlined';
import { notify } from '$app/components/_shared/notify';
import { isTauri } from '$app/utils/env';
import { getFileName, IMAGE_DIR, ALLOWED_IMAGE_EXTENSIONS, MAX_IMAGE_SIZE } from '$app/utils/upload_image';
export function UploadImage() {
return <div></div>;
export function UploadImage({ onDone }: { onDone?: (url: string) => void }) {
const { t } = useTranslation();
const checkTauriFile = useCallback(
async (url: string) => {
const { readBinaryFile } = await import('@tauri-apps/api/fs');
const buffer = await readBinaryFile(url);
const blob = new Blob([buffer]);
if (blob.size > MAX_IMAGE_SIZE) {
notify.error(t('document.imageBlock.error.invalidImageSize'));
return false;
}
return true;
},
[t]
);
const uploadTauriLocalImage = useCallback(
async (url: string) => {
const { copyFile, BaseDirectory, exists, createDir } = await import('@tauri-apps/api/fs');
const checked = await checkTauriFile(url);
if (!checked) return;
try {
const existDir = await exists(IMAGE_DIR, { dir: BaseDirectory.AppLocalData });
if (!existDir) {
await createDir(IMAGE_DIR, { dir: BaseDirectory.AppLocalData });
}
const filename = getFileName(url);
await copyFile(url, `${IMAGE_DIR}/${filename}`, { dir: BaseDirectory.AppLocalData });
const newUrl = `${IMAGE_DIR}/${filename}`;
onDone?.(newUrl);
} catch (e) {
notify.error(t('document.plugins.image.imageUploadFailed'));
}
},
[checkTauriFile, onDone, t]
);
const handleClickUpload = useCallback(async () => {
if (!isTauri()) return;
const { open } = await import('@tauri-apps/api/dialog');
const url = await open({
multiple: false,
directory: false,
filters: [
{
name: 'Image',
extensions: ALLOWED_IMAGE_EXTENSIONS,
},
],
});
if (!url || typeof url !== 'string') return;
await uploadTauriLocalImage(url);
}, [uploadTauriLocalImage]);
return (
<div className={'w-full px-4 pb-4'}>
<Button
component='label'
role={undefined}
tabIndex={-1}
variant={'outlined'}
startIcon={<CloudUploadIcon />}
className={'w-full'}
color={'inherit'}
onClick={handleClickUpload}
>
{t('document.imageBlock.upload.placeholder')}
</Button>
</div>
);
}
export default UploadImage;

View File

@ -25,10 +25,12 @@ export function UploadTabs({
tabOptions,
popoverProps,
containerStyle,
extra,
}: {
containerStyle?: React.CSSProperties;
tabOptions: TabOption[];
popoverProps?: PopoverProps;
extra?: React.ReactNode;
}) {
const [tabValue, setTabValue] = useState<TAB_KEY>(() => {
return tabOptions[0].key;
@ -82,13 +84,14 @@ export function UploadTabs({
}}
>
<div style={containerStyle} className={'flex flex-col gap-4 overflow-hidden'}>
<div className={'flex w-full items-center justify-between gap-2 border-b border-line-divider'}>
<ViewTabs
value={tabValue}
onChange={handleTabChange}
scrollButtons={false}
variant='scrollable'
allowScrollButtonsMobile
className={'min-h-[38px] border-b border-line-divider px-2'}
className={'min-h-[38px] px-2'}
>
{tabOptions.map((tab) => {
const { key, label } = tab;
@ -96,6 +99,8 @@ export function UploadTabs({
return <ViewTab key={key} iconPosition='start' color='inherit' label={label} value={key} />;
})}
</ViewTabs>
{extra}
</div>
<div className={'h-full w-full flex-1 overflow-y-auto overflow-x-hidden'}>
<SwipeableViews

View File

@ -2,3 +2,4 @@ export * from './Unsplash';
export * from './UploadImage';
export * from './EmbedLink';
export * from './UploadTabs';
export * from './LocalImage';

View File

@ -1,10 +1,11 @@
import React, { useMemo } from 'react';
import { CoverType, PageCover } from '$app_reducers/pages/slice';
import { PopoverOrigin } from '@mui/material/Popover';
import { EmbedLink, Unsplash, UploadTabs, TabOption, TAB_KEY } from '$app/components/_shared/image_upload';
import { EmbedLink, Unsplash, UploadTabs, TabOption, TAB_KEY, UploadImage } from '$app/components/_shared/image_upload';
import { useTranslation } from 'react-i18next';
import Colors from '$app/components/_shared/view_title/cover/Colors';
import { ImageType } from '$app/application/document/document.types';
import Button from '@mui/material/Button';
const initialOrigin: {
anchorOrigin: PopoverOrigin;
@ -25,11 +26,13 @@ function CoverPopover({
open,
onClose,
onUpdateCover,
onRemoveCover,
}: {
anchorEl: HTMLElement | null;
open: boolean;
onClose: () => void;
onUpdateCover?: (cover?: PageCover) => void;
onRemoveCover?: () => void;
}) {
const { t } = useTranslation();
const tabOptions: TabOption[] = useMemo(() => {
@ -46,6 +49,19 @@ function CoverPopover({
});
},
},
{
label: t('button.upload'),
key: TAB_KEY.UPLOAD,
Component: UploadImage,
onDone: (value: string) => {
onUpdateCover?.({
cover_selection_type: CoverType.Image,
cover_selection: value,
image_type: ImageType.Local,
});
onClose();
},
},
{
label: t('document.imageBlock.embedLink.label'),
key: TAB_KEY.EMBED_LINK,
@ -84,6 +100,11 @@ function CoverPopover({
}}
containerStyle={{ width: 433, maxHeight: 300 }}
tabOptions={tabOptions}
extra={
<Button color={'inherit'} size={'small'} className={'mr-4'} variant={'text'} onClick={onRemoveCover}>
{t('button.remove')}
</Button>
}
/>
);
}

View File

@ -4,9 +4,15 @@ import { renderColor } from '$app/utils/color';
import ViewCoverActions from '$app/components/_shared/view_title/cover/ViewCoverActions';
import CoverPopover from '$app/components/_shared/view_title/cover/CoverPopover';
import DefaultImage from '$app/assets/images/default_cover.jpg';
import { ImageType } from '$app/application/document/document.types';
import { LocalImage } from '$app/components/_shared/image_upload';
export function ViewCover({ cover, onUpdateCover }: { cover: PageCover; onUpdateCover?: (cover?: PageCover) => void }) {
const { cover_selection_type: type, cover_selection: value = '' } = useMemo(() => cover || {}, [cover]);
const {
cover_selection_type: type,
cover_selection: value = '',
image_type: source,
} = useMemo(() => cover || {}, [cover]);
const [showAction, setShowAction] = useState(false);
const actionRef = useRef<HTMLDivElement>(null);
const [showPopover, setShowPopover] = useState(false);
@ -44,9 +50,16 @@ export function ViewCover({ cover, onUpdateCover }: { cover: PageCover; onUpdate
}}
className={'relative flex h-[255px] w-full'}
>
{source === ImageType.Local ? (
<LocalImage src={value} className={'h-full w-full object-cover'} />
) : (
<>
{type === CoverType.Asset ? renderCoverImage(DefaultImage) : null}
{type === CoverType.Color ? renderCoverColor(value) : null}
{type === CoverType.Image ? renderCoverImage(value) : null}
</>
)}
<ViewCoverActions
show={showAction}
ref={actionRef}
@ -59,6 +72,7 @@ export function ViewCover({ cover, onUpdateCover }: { cover: PageCover; onUpdate
onClose={() => setShowPopover(false)}
anchorEl={actionRef.current}
onUpdateCover={onUpdateCover}
onRemoveCover={handleRemoveCover}
/>
)}
</div>

View File

@ -39,7 +39,7 @@ function ImageEmpty({
<>
<div
className={
'flex h-[48px] w-full cursor-pointer select-none items-center gap-[10px] bg-content-blue-50 px-4 text-text-caption'
'container-bg flex h-[48px] w-full cursor-pointer select-none items-center gap-[10px] bg-content-blue-50 px-4 text-text-caption'
}
>
<ImageIcon />

View File

@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { ImageNode } from '$app/application/document/document.types';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { ImageNode, ImageType } from '$app/application/document/document.types';
import { useTranslation } from 'react-i18next';
import { CircularProgress } from '@mui/material';
import { ErrorOutline } from '@mui/icons-material';
@ -7,6 +7,7 @@ import ImageResizer from '$app/components/editor/components/blocks/image/ImageRe
import { CustomEditor } from '$app/components/editor/command';
import { useSlateStatic } from 'slate-react';
import ImageActions from '$app/components/editor/components/blocks/image/ImageActions';
import { LocalImage } from '$app/components/_shared/image_upload';
function ImageRender({ selected, node }: { selected: boolean; node: ImageNode }) {
const [loading, setLoading] = useState(true);
@ -14,7 +15,7 @@ function ImageRender({ selected, node }: { selected: boolean; node: ImageNode })
const imgRef = useRef<HTMLImageElement>(null);
const editor = useSlateStatic();
const { url, width: imageWidth } = node.data;
const { url = '', width: imageWidth, image_type: source } = node.data;
const { t } = useTranslation();
const blockId = node.blockId;
@ -35,9 +36,38 @@ function ImageRender({ selected, node }: { selected: boolean; node: ImageNode })
setInitialWidth(imgRef.current.offsetWidth);
}
}, [hasError, initialWidth, loading]);
const imageProps: React.ImgHTMLAttributes<HTMLImageElement> = useMemo(() => {
return {
style: { width: loading || hasError ? '0' : imageWidth ?? '100%', opacity: selected ? 0.8 : 1 },
className: 'object-cover',
ref: imgRef,
src: url,
draggable: false,
onLoad: () => {
setHasError(false);
setLoading(false);
},
onError: () => {
setHasError(true);
setLoading(false);
},
};
}, [url, imageWidth, loading, hasError, selected]);
const renderErrorNode = useCallback(() => {
return (
<div
className={'flex h-full w-full items-center justify-center gap-2 rounded border border-function-error bg-red-50'}
>
<ErrorOutline className={'text-function-error'} />
<div className={'text-function-error'}>{t('editor.imageLoadFailed')}</div>
</div>
);
}, [t]);
if (!url) return null;
return (
<>
<div
onMouseEnter={() => {
setShowActions(true);
@ -45,46 +75,32 @@ function ImageRender({ selected, node }: { selected: boolean; node: ImageNode })
onMouseLeave={() => {
setShowActions(false);
}}
className={'relative'}
className={`relative min-h-[48px] ${hasError || (loading && source !== ImageType.Local) ? 'w-full' : ''}`}
>
<img
ref={imgRef}
draggable={false}
loading={'lazy'}
onLoad={() => {
setHasError(false);
setLoading(false);
}}
onError={() => {
{source === ImageType.Local ? (
<LocalImage
{...imageProps}
renderErrorNode={() => {
setHasError(true);
setLoading(false);
return null;
}}
src={url}
alt={`image-${blockId}`}
className={'object-cover'}
style={{ width: loading || hasError ? '0' : imageWidth ?? '100%', opacity: selected ? 0.8 : 1 }}
loading={'lazy'}
/>
) : (
<img loading={'lazy'} {...imageProps} alt={`image-${blockId}`} />
)}
{initialWidth && <ImageResizer width={imageWidth ?? initialWidth} onWidthChange={handleWidthChange} />}
{showActions && <ImageActions node={node} />}
</div>
{loading && (
<div className={'flex h-[48px] w-full items-center justify-center gap-2 rounded bg-gray-100'}>
{hasError ? (
renderErrorNode()
) : loading && source !== ImageType.Local ? (
<div className={'flex h-full w-full items-center justify-center gap-2 rounded bg-gray-100'}>
<CircularProgress size={24} />
<div className={'text-text-caption'}>{t('editor.loading')}</div>
</div>
)}
{hasError && (
<div
className={
'flex h-[48px] w-full items-center justify-center gap-2 rounded border border-function-error bg-red-50'
}
>
<ErrorOutline className={'text-function-error'} />
<div className={'text-function-error'}>{t('editor.imageLoadFailed')}</div>
) : null}
</div>
)}
</>
);
}

View File

@ -29,18 +29,16 @@ function ImageResizer({ width, onWidthChange }: { width: number; onWidthChange:
const onResizeStart = useCallback(
(e: React.MouseEvent) => {
startX.current = e.clientX;
originalWidth.current = width;
document.addEventListener('mousemove', onResize);
document.addEventListener('mouseup', onResizeEnd);
},
[onResize, onResizeEnd]
[onResize, onResizeEnd, width]
);
return (
<div
onMouseDown={onResizeStart}
onMouseUp={() => {
originalWidth.current = width;
}}
style={{
right: '2px',
}}

View File

@ -3,7 +3,7 @@ import { PopoverOrigin } from '@mui/material/Popover/Popover';
import usePopoverAutoPosition from '$app/components/_shared/popover/Popover.hooks';
import { useTranslation } from 'react-i18next';
import { EmbedLink, Unsplash, UploadTabs, TabOption, TAB_KEY } from '$app/components/_shared/image_upload';
import { EmbedLink, Unsplash, UploadTabs, TabOption, TAB_KEY, UploadImage } from '$app/components/_shared/image_upload';
import { CustomEditor } from '$app/components/editor/command';
import { useSlateStatic } from 'slate-react';
import { ImageNode, ImageType } from '$app/application/document/document.types';
@ -48,11 +48,18 @@ function UploadPopover({
const tabOptions: TabOption[] = useMemo(() => {
return [
// {
// label: t('button.upload'),
// key: TAB_KEY.UPLOAD,
// Component: UploadImage,
// },
{
label: t('button.upload'),
key: TAB_KEY.UPLOAD,
Component: UploadImage,
onDone: (link: string) => {
CustomEditor.setImageBlockData(editor, node, {
url: link,
image_type: ImageType.Local,
});
onClose();
},
},
{
label: t('document.imageBlock.embedLink.label'),
key: TAB_KEY.EMBED_LINK,

View File

@ -54,7 +54,7 @@ export const MathEquation = memo(
>
<div
contentEditable={false}
className={`w-full select-none rounded border border-line-divider ${
className={`container-bg w-full select-none rounded border border-line-divider ${
selected ? 'border-fill-hover' : ''
} bg-content-blue-50 px-3`}
>

View File

@ -155,6 +155,11 @@ span[data-slate-placeholder="true"]:not(.inline-block-content) {
::selection {
@apply bg-transparent;
}
&:hover {
.container-bg {
background: var(--content-blue-100) !important;
}
}
}
.mention-inline {

View File

@ -54,6 +54,10 @@ export const getDesignTokens = (isDark: boolean): ThemeOptions => {
boxShadow: 'var(--shadow)',
},
},
outlinedInherit: {
color: 'var(--text-title)',
borderColor: 'var(--line-divider)',
},
},
},
MuiButtonBase: {

View File

@ -0,0 +1,9 @@
export const MAX_IMAGE_SIZE = 10 * 1024 * 1024; // 10MB
export const ALLOWED_IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png'];
export const IMAGE_DIR = 'images';
export function getFileName(url: string) {
const [...parts] = url.split('/');
return parts.pop() ?? url;
}

View File

@ -915,8 +915,9 @@
"error": {
"invalidImage": "Invalid image",
"invalidImageSize": "Image size must be less than 5MB",
"invalidImageFormat": "Image format is not supported. Supported formats: JPEG, PNG, GIF, SVG",
"invalidImageUrl": "Invalid image URL"
"invalidImageFormat": "Image format is not supported. Supported formats: JPEG, PNG, JPG",
"invalidImageUrl": "Invalid image URL",
"noImage": "No such file or directory"
},
"embedLink": {
"label": "Embed link",