mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
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:
parent
ff8eb0d479
commit
2ec6250ddd
79
frontend/appflowy_tauri/src-tauri/Cargo.lock
generated
79
frontend/appflowy_tauri/src-tauri/Cargo.lock
generated
@ -3816,6 +3816,17 @@ dependencies = [
|
|||||||
"objc_exception",
|
"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]]
|
[[package]]
|
||||||
name = "objc_exception"
|
name = "objc_exception"
|
||||||
version = "0.1.2"
|
version = "0.1.2"
|
||||||
@ -4956,6 +4967,30 @@ dependencies = [
|
|||||||
"winreg 0.50.0",
|
"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]]
|
[[package]]
|
||||||
name = "ring"
|
name = "ring"
|
||||||
version = "0.16.20"
|
version = "0.16.20"
|
||||||
@ -5896,6 +5931,7 @@ dependencies = [
|
|||||||
"rand 0.8.5",
|
"rand 0.8.5",
|
||||||
"raw-window-handle",
|
"raw-window-handle",
|
||||||
"regex",
|
"regex",
|
||||||
|
"rfd",
|
||||||
"semver",
|
"semver",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@ -7050,6 +7086,19 @@ version = "0.4.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
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]]
|
[[package]]
|
||||||
name = "windows"
|
name = "windows"
|
||||||
version = "0.39.0"
|
version = "0.39.0"
|
||||||
@ -7205,6 +7254,12 @@ version = "0.52.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "cb7764e35d4db8a7921e09562a0304bf2f93e0a51bfccee0bd0bb0b666b015ea"
|
checksum = "cb7764e35d4db8a7921e09562a0304bf2f93e0a51bfccee0bd0bb0b666b015ea"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_aarch64_msvc"
|
||||||
|
version = "0.37.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2623277cb2d1c216ba3b578c0f3cf9cdebeddb6e66b1b218bb33596ea7769c3a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_aarch64_msvc"
|
name = "windows_aarch64_msvc"
|
||||||
version = "0.39.0"
|
version = "0.39.0"
|
||||||
@ -7229,6 +7284,12 @@ version = "0.52.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "bbaa0368d4f1d2aaefc55b6fcfee13f41544ddf36801e793edbbfd7d7df075ef"
|
checksum = "bbaa0368d4f1d2aaefc55b6fcfee13f41544ddf36801e793edbbfd7d7df075ef"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_gnu"
|
||||||
|
version = "0.37.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d3925fd0b0b804730d44d4b6278c50f9699703ec49bcd628020f46f4ba07d9e1"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_i686_gnu"
|
name = "windows_i686_gnu"
|
||||||
version = "0.39.0"
|
version = "0.39.0"
|
||||||
@ -7253,6 +7314,12 @@ version = "0.52.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a28637cb1fa3560a16915793afb20081aba2c92ee8af57b4d5f28e4b3e7df313"
|
checksum = "a28637cb1fa3560a16915793afb20081aba2c92ee8af57b4d5f28e4b3e7df313"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_msvc"
|
||||||
|
version = "0.37.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ce907ac74fe331b524c1298683efbf598bb031bc84d5e274db2083696d07c57c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_i686_msvc"
|
name = "windows_i686_msvc"
|
||||||
version = "0.39.0"
|
version = "0.39.0"
|
||||||
@ -7277,6 +7344,12 @@ version = "0.52.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ffe5e8e31046ce6230cc7215707b816e339ff4d4d67c65dffa206fd0f7aa7b9a"
|
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]]
|
[[package]]
|
||||||
name = "windows_x86_64_gnu"
|
name = "windows_x86_64_gnu"
|
||||||
version = "0.39.0"
|
version = "0.39.0"
|
||||||
@ -7319,6 +7392,12 @@ version = "0.52.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1a657e1e9d3f514745a572a6846d3c7aa7dbe1658c056ed9c3344c4109a6949e"
|
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]]
|
[[package]]
|
||||||
name = "windows_x86_64_msvc"
|
name = "windows_x86_64_msvc"
|
||||||
version = "0.39.0"
|
version = "0.39.0"
|
||||||
|
@ -34,7 +34,7 @@ lru = "0.12.0"
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
tauri = { version = "1.5", features = [
|
tauri = { version = "1.5", features = [ "dialog-all",
|
||||||
"clipboard-all",
|
"clipboard-all",
|
||||||
"fs-all",
|
"fs-all",
|
||||||
"shell-open",
|
"shell-open",
|
||||||
|
@ -20,8 +20,7 @@
|
|||||||
"fs": {
|
"fs": {
|
||||||
"all": true,
|
"all": true,
|
||||||
"scope": [
|
"scope": [
|
||||||
"$APPLOCALDATA/**",
|
"$APPLOCALDATA/**"
|
||||||
"$APPLOCALDATA/images/*"
|
|
||||||
],
|
],
|
||||||
"readFile": true,
|
"readFile": true,
|
||||||
"writeFile": true,
|
"writeFile": true,
|
||||||
@ -37,6 +36,14 @@
|
|||||||
"all": true,
|
"all": true,
|
||||||
"writeText": true,
|
"writeText": true,
|
||||||
"readText": true
|
"readText": true
|
||||||
|
},
|
||||||
|
"dialog": {
|
||||||
|
"all": true,
|
||||||
|
"ask": true,
|
||||||
|
"confirm": true,
|
||||||
|
"message": true,
|
||||||
|
"open": true,
|
||||||
|
"save": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"bundle": {
|
"bundle": {
|
||||||
|
@ -111,6 +111,7 @@ export interface MathEquationNode extends Element {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export enum ImageType {
|
export enum ImageType {
|
||||||
|
Local = 0,
|
||||||
Internal = 1,
|
Internal = 1,
|
||||||
External = 2,
|
External = 2,
|
||||||
}
|
}
|
||||||
|
@ -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} />;
|
||||||
|
});
|
@ -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() {
|
export function UploadImage({ onDone }: { onDone?: (url: string) => void }) {
|
||||||
return <div></div>;
|
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;
|
export default UploadImage;
|
||||||
|
@ -25,10 +25,12 @@ export function UploadTabs({
|
|||||||
tabOptions,
|
tabOptions,
|
||||||
popoverProps,
|
popoverProps,
|
||||||
containerStyle,
|
containerStyle,
|
||||||
|
extra,
|
||||||
}: {
|
}: {
|
||||||
containerStyle?: React.CSSProperties;
|
containerStyle?: React.CSSProperties;
|
||||||
tabOptions: TabOption[];
|
tabOptions: TabOption[];
|
||||||
popoverProps?: PopoverProps;
|
popoverProps?: PopoverProps;
|
||||||
|
extra?: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const [tabValue, setTabValue] = useState<TAB_KEY>(() => {
|
const [tabValue, setTabValue] = useState<TAB_KEY>(() => {
|
||||||
return tabOptions[0].key;
|
return tabOptions[0].key;
|
||||||
@ -82,20 +84,23 @@ export function UploadTabs({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={containerStyle} className={'flex flex-col gap-4 overflow-hidden'}>
|
<div style={containerStyle} className={'flex flex-col gap-4 overflow-hidden'}>
|
||||||
<ViewTabs
|
<div className={'flex w-full items-center justify-between gap-2 border-b border-line-divider'}>
|
||||||
value={tabValue}
|
<ViewTabs
|
||||||
onChange={handleTabChange}
|
value={tabValue}
|
||||||
scrollButtons={false}
|
onChange={handleTabChange}
|
||||||
variant='scrollable'
|
scrollButtons={false}
|
||||||
allowScrollButtonsMobile
|
variant='scrollable'
|
||||||
className={'min-h-[38px] border-b border-line-divider px-2'}
|
allowScrollButtonsMobile
|
||||||
>
|
className={'min-h-[38px] px-2'}
|
||||||
{tabOptions.map((tab) => {
|
>
|
||||||
const { key, label } = tab;
|
{tabOptions.map((tab) => {
|
||||||
|
const { key, label } = tab;
|
||||||
|
|
||||||
return <ViewTab key={key} iconPosition='start' color='inherit' label={label} value={key} />;
|
return <ViewTab key={key} iconPosition='start' color='inherit' label={label} value={key} />;
|
||||||
})}
|
})}
|
||||||
</ViewTabs>
|
</ViewTabs>
|
||||||
|
{extra}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className={'h-full w-full flex-1 overflow-y-auto overflow-x-hidden'}>
|
<div className={'h-full w-full flex-1 overflow-y-auto overflow-x-hidden'}>
|
||||||
<SwipeableViews
|
<SwipeableViews
|
||||||
|
@ -2,3 +2,4 @@ export * from './Unsplash';
|
|||||||
export * from './UploadImage';
|
export * from './UploadImage';
|
||||||
export * from './EmbedLink';
|
export * from './EmbedLink';
|
||||||
export * from './UploadTabs';
|
export * from './UploadTabs';
|
||||||
|
export * from './LocalImage';
|
||||||
|
@ -1,10 +1,11 @@
|
|||||||
import React, { useMemo } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { CoverType, PageCover } from '$app_reducers/pages/slice';
|
import { CoverType, PageCover } from '$app_reducers/pages/slice';
|
||||||
import { PopoverOrigin } from '@mui/material/Popover';
|
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 { useTranslation } from 'react-i18next';
|
||||||
import Colors from '$app/components/_shared/view_title/cover/Colors';
|
import Colors from '$app/components/_shared/view_title/cover/Colors';
|
||||||
import { ImageType } from '$app/application/document/document.types';
|
import { ImageType } from '$app/application/document/document.types';
|
||||||
|
import Button from '@mui/material/Button';
|
||||||
|
|
||||||
const initialOrigin: {
|
const initialOrigin: {
|
||||||
anchorOrigin: PopoverOrigin;
|
anchorOrigin: PopoverOrigin;
|
||||||
@ -25,11 +26,13 @@ function CoverPopover({
|
|||||||
open,
|
open,
|
||||||
onClose,
|
onClose,
|
||||||
onUpdateCover,
|
onUpdateCover,
|
||||||
|
onRemoveCover,
|
||||||
}: {
|
}: {
|
||||||
anchorEl: HTMLElement | null;
|
anchorEl: HTMLElement | null;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onUpdateCover?: (cover?: PageCover) => void;
|
onUpdateCover?: (cover?: PageCover) => void;
|
||||||
|
onRemoveCover?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const tabOptions: TabOption[] = useMemo(() => {
|
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'),
|
label: t('document.imageBlock.embedLink.label'),
|
||||||
key: TAB_KEY.EMBED_LINK,
|
key: TAB_KEY.EMBED_LINK,
|
||||||
@ -84,6 +100,11 @@ function CoverPopover({
|
|||||||
}}
|
}}
|
||||||
containerStyle={{ width: 433, maxHeight: 300 }}
|
containerStyle={{ width: 433, maxHeight: 300 }}
|
||||||
tabOptions={tabOptions}
|
tabOptions={tabOptions}
|
||||||
|
extra={
|
||||||
|
<Button color={'inherit'} size={'small'} className={'mr-4'} variant={'text'} onClick={onRemoveCover}>
|
||||||
|
{t('button.remove')}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -4,9 +4,15 @@ import { renderColor } from '$app/utils/color';
|
|||||||
import ViewCoverActions from '$app/components/_shared/view_title/cover/ViewCoverActions';
|
import ViewCoverActions from '$app/components/_shared/view_title/cover/ViewCoverActions';
|
||||||
import CoverPopover from '$app/components/_shared/view_title/cover/CoverPopover';
|
import CoverPopover from '$app/components/_shared/view_title/cover/CoverPopover';
|
||||||
import DefaultImage from '$app/assets/images/default_cover.jpg';
|
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 }) {
|
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 [showAction, setShowAction] = useState(false);
|
||||||
const actionRef = useRef<HTMLDivElement>(null);
|
const actionRef = useRef<HTMLDivElement>(null);
|
||||||
const [showPopover, setShowPopover] = useState(false);
|
const [showPopover, setShowPopover] = useState(false);
|
||||||
@ -44,9 +50,16 @@ export function ViewCover({ cover, onUpdateCover }: { cover: PageCover; onUpdate
|
|||||||
}}
|
}}
|
||||||
className={'relative flex h-[255px] w-full'}
|
className={'relative flex h-[255px] w-full'}
|
||||||
>
|
>
|
||||||
{type === CoverType.Asset ? renderCoverImage(DefaultImage) : null}
|
{source === ImageType.Local ? (
|
||||||
{type === CoverType.Color ? renderCoverColor(value) : null}
|
<LocalImage src={value} className={'h-full w-full object-cover'} />
|
||||||
{type === CoverType.Image ? renderCoverImage(value) : null}
|
) : (
|
||||||
|
<>
|
||||||
|
{type === CoverType.Asset ? renderCoverImage(DefaultImage) : null}
|
||||||
|
{type === CoverType.Color ? renderCoverColor(value) : null}
|
||||||
|
{type === CoverType.Image ? renderCoverImage(value) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<ViewCoverActions
|
<ViewCoverActions
|
||||||
show={showAction}
|
show={showAction}
|
||||||
ref={actionRef}
|
ref={actionRef}
|
||||||
@ -59,6 +72,7 @@ export function ViewCover({ cover, onUpdateCover }: { cover: PageCover; onUpdate
|
|||||||
onClose={() => setShowPopover(false)}
|
onClose={() => setShowPopover(false)}
|
||||||
anchorEl={actionRef.current}
|
anchorEl={actionRef.current}
|
||||||
onUpdateCover={onUpdateCover}
|
onUpdateCover={onUpdateCover}
|
||||||
|
onRemoveCover={handleRemoveCover}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
@ -39,7 +39,7 @@ function ImageEmpty({
|
|||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className={
|
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 />
|
<ImageIcon />
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { ImageNode } from '$app/application/document/document.types';
|
import { ImageNode, ImageType } from '$app/application/document/document.types';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { CircularProgress } from '@mui/material';
|
import { CircularProgress } from '@mui/material';
|
||||||
import { ErrorOutline } from '@mui/icons-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 { CustomEditor } from '$app/components/editor/command';
|
||||||
import { useSlateStatic } from 'slate-react';
|
import { useSlateStatic } from 'slate-react';
|
||||||
import ImageActions from '$app/components/editor/components/blocks/image/ImageActions';
|
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 }) {
|
function ImageRender({ selected, node }: { selected: boolean; node: ImageNode }) {
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@ -14,7 +15,7 @@ function ImageRender({ selected, node }: { selected: boolean; node: ImageNode })
|
|||||||
|
|
||||||
const imgRef = useRef<HTMLImageElement>(null);
|
const imgRef = useRef<HTMLImageElement>(null);
|
||||||
const editor = useSlateStatic();
|
const editor = useSlateStatic();
|
||||||
const { url, width: imageWidth } = node.data;
|
const { url = '', width: imageWidth, image_type: source } = node.data;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const blockId = node.blockId;
|
const blockId = node.blockId;
|
||||||
|
|
||||||
@ -35,56 +36,71 @@ function ImageRender({ selected, node }: { selected: boolean; node: ImageNode })
|
|||||||
setInitialWidth(imgRef.current.offsetWidth);
|
setInitialWidth(imgRef.current.offsetWidth);
|
||||||
}
|
}
|
||||||
}, [hasError, initialWidth, loading]);
|
}, [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 (
|
return (
|
||||||
<>
|
<div
|
||||||
<div
|
onMouseEnter={() => {
|
||||||
onMouseEnter={() => {
|
setShowActions(true);
|
||||||
setShowActions(true);
|
}}
|
||||||
}}
|
onMouseLeave={() => {
|
||||||
onMouseLeave={() => {
|
setShowActions(false);
|
||||||
setShowActions(false);
|
}}
|
||||||
}}
|
className={`relative min-h-[48px] ${hasError || (loading && source !== ImageType.Local) ? 'w-full' : ''}`}
|
||||||
className={'relative'}
|
>
|
||||||
>
|
{source === ImageType.Local ? (
|
||||||
<img
|
<LocalImage
|
||||||
ref={imgRef}
|
{...imageProps}
|
||||||
draggable={false}
|
renderErrorNode={() => {
|
||||||
loading={'lazy'}
|
|
||||||
onLoad={() => {
|
|
||||||
setHasError(false);
|
|
||||||
setLoading(false);
|
|
||||||
}}
|
|
||||||
onError={() => {
|
|
||||||
setHasError(true);
|
setHasError(true);
|
||||||
setLoading(false);
|
return null;
|
||||||
}}
|
}}
|
||||||
src={url}
|
loading={'lazy'}
|
||||||
alt={`image-${blockId}`}
|
|
||||||
className={'object-cover'}
|
|
||||||
style={{ width: loading || hasError ? '0' : imageWidth ?? '100%', opacity: selected ? 0.8 : 1 }}
|
|
||||||
/>
|
/>
|
||||||
{initialWidth && <ImageResizer width={imageWidth ?? initialWidth} onWidthChange={handleWidthChange} />}
|
) : (
|
||||||
{showActions && <ImageActions node={node} />}
|
<img loading={'lazy'} {...imageProps} alt={`image-${blockId}`} />
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
{loading && (
|
{initialWidth && <ImageResizer width={imageWidth ?? initialWidth} onWidthChange={handleWidthChange} />}
|
||||||
<div className={'flex h-[48px] w-full items-center justify-center gap-2 rounded bg-gray-100'}>
|
{showActions && <ImageActions node={node} />}
|
||||||
|
{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} />
|
<CircularProgress size={24} />
|
||||||
<div className={'text-text-caption'}>{t('editor.loading')}</div>
|
<div className={'text-text-caption'}>{t('editor.loading')}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : null}
|
||||||
{hasError && (
|
</div>
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -29,18 +29,16 @@ function ImageResizer({ width, onWidthChange }: { width: number; onWidthChange:
|
|||||||
const onResizeStart = useCallback(
|
const onResizeStart = useCallback(
|
||||||
(e: React.MouseEvent) => {
|
(e: React.MouseEvent) => {
|
||||||
startX.current = e.clientX;
|
startX.current = e.clientX;
|
||||||
|
originalWidth.current = width;
|
||||||
document.addEventListener('mousemove', onResize);
|
document.addEventListener('mousemove', onResize);
|
||||||
document.addEventListener('mouseup', onResizeEnd);
|
document.addEventListener('mouseup', onResizeEnd);
|
||||||
},
|
},
|
||||||
[onResize, onResizeEnd]
|
[onResize, onResizeEnd, width]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
onMouseDown={onResizeStart}
|
onMouseDown={onResizeStart}
|
||||||
onMouseUp={() => {
|
|
||||||
originalWidth.current = width;
|
|
||||||
}}
|
|
||||||
style={{
|
style={{
|
||||||
right: '2px',
|
right: '2px',
|
||||||
}}
|
}}
|
||||||
|
@ -3,7 +3,7 @@ import { PopoverOrigin } from '@mui/material/Popover/Popover';
|
|||||||
import usePopoverAutoPosition from '$app/components/_shared/popover/Popover.hooks';
|
import usePopoverAutoPosition from '$app/components/_shared/popover/Popover.hooks';
|
||||||
|
|
||||||
import { useTranslation } from 'react-i18next';
|
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 { CustomEditor } from '$app/components/editor/command';
|
||||||
import { useSlateStatic } from 'slate-react';
|
import { useSlateStatic } from 'slate-react';
|
||||||
import { ImageNode, ImageType } from '$app/application/document/document.types';
|
import { ImageNode, ImageType } from '$app/application/document/document.types';
|
||||||
@ -48,11 +48,18 @@ function UploadPopover({
|
|||||||
|
|
||||||
const tabOptions: TabOption[] = useMemo(() => {
|
const tabOptions: TabOption[] = useMemo(() => {
|
||||||
return [
|
return [
|
||||||
// {
|
{
|
||||||
// label: t('button.upload'),
|
label: t('button.upload'),
|
||||||
// key: TAB_KEY.UPLOAD,
|
key: TAB_KEY.UPLOAD,
|
||||||
// Component: UploadImage,
|
Component: UploadImage,
|
||||||
// },
|
onDone: (link: string) => {
|
||||||
|
CustomEditor.setImageBlockData(editor, node, {
|
||||||
|
url: link,
|
||||||
|
image_type: ImageType.Local,
|
||||||
|
});
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: t('document.imageBlock.embedLink.label'),
|
label: t('document.imageBlock.embedLink.label'),
|
||||||
key: TAB_KEY.EMBED_LINK,
|
key: TAB_KEY.EMBED_LINK,
|
||||||
|
@ -54,7 +54,7 @@ export const MathEquation = memo(
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
contentEditable={false}
|
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' : ''
|
selected ? 'border-fill-hover' : ''
|
||||||
} bg-content-blue-50 px-3`}
|
} bg-content-blue-50 px-3`}
|
||||||
>
|
>
|
||||||
|
@ -155,6 +155,11 @@ span[data-slate-placeholder="true"]:not(.inline-block-content) {
|
|||||||
::selection {
|
::selection {
|
||||||
@apply bg-transparent;
|
@apply bg-transparent;
|
||||||
}
|
}
|
||||||
|
&:hover {
|
||||||
|
.container-bg {
|
||||||
|
background: var(--content-blue-100) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.mention-inline {
|
.mention-inline {
|
||||||
|
@ -54,6 +54,10 @@ export const getDesignTokens = (isDark: boolean): ThemeOptions => {
|
|||||||
boxShadow: 'var(--shadow)',
|
boxShadow: 'var(--shadow)',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
outlinedInherit: {
|
||||||
|
color: 'var(--text-title)',
|
||||||
|
borderColor: 'var(--line-divider)',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
MuiButtonBase: {
|
MuiButtonBase: {
|
||||||
|
@ -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;
|
||||||
|
}
|
@ -915,8 +915,9 @@
|
|||||||
"error": {
|
"error": {
|
||||||
"invalidImage": "Invalid image",
|
"invalidImage": "Invalid image",
|
||||||
"invalidImageSize": "Image size must be less than 5MB",
|
"invalidImageSize": "Image size must be less than 5MB",
|
||||||
"invalidImageFormat": "Image format is not supported. Supported formats: JPEG, PNG, GIF, SVG",
|
"invalidImageFormat": "Image format is not supported. Supported formats: JPEG, PNG, JPG",
|
||||||
"invalidImageUrl": "Invalid image URL"
|
"invalidImageUrl": "Invalid image URL",
|
||||||
|
"noImage": "No such file or directory"
|
||||||
},
|
},
|
||||||
"embedLink": {
|
"embedLink": {
|
||||||
"label": "Embed link",
|
"label": "Embed link",
|
||||||
|
Loading…
Reference in New Issue
Block a user