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",
|
||||
]
|
||||
|
||||
[[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"
|
||||
|
@ -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",
|
||||
|
@ -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": {
|
||||
|
@ -111,6 +111,7 @@ export interface MathEquationNode extends Element {
|
||||
}
|
||||
|
||||
export enum ImageType {
|
||||
Local = 0,
|
||||
Internal = 1,
|
||||
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() {
|
||||
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;
|
||||
|
@ -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,20 +84,23 @@ export function UploadTabs({
|
||||
}}
|
||||
>
|
||||
<div style={containerStyle} className={'flex flex-col gap-4 overflow-hidden'}>
|
||||
<ViewTabs
|
||||
value={tabValue}
|
||||
onChange={handleTabChange}
|
||||
scrollButtons={false}
|
||||
variant='scrollable'
|
||||
allowScrollButtonsMobile
|
||||
className={'min-h-[38px] border-b border-line-divider px-2'}
|
||||
>
|
||||
{tabOptions.map((tab) => {
|
||||
const { key, label } = tab;
|
||||
<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] px-2'}
|
||||
>
|
||||
{tabOptions.map((tab) => {
|
||||
const { key, label } = tab;
|
||||
|
||||
return <ViewTab key={key} iconPosition='start' color='inherit' label={label} value={key} />;
|
||||
})}
|
||||
</ViewTabs>
|
||||
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
|
||||
|
@ -2,3 +2,4 @@ export * from './Unsplash';
|
||||
export * from './UploadImage';
|
||||
export * from './EmbedLink';
|
||||
export * from './UploadTabs';
|
||||
export * from './LocalImage';
|
||||
|
@ -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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -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'}
|
||||
>
|
||||
{type === CoverType.Asset ? renderCoverImage(DefaultImage) : null}
|
||||
{type === CoverType.Color ? renderCoverColor(value) : null}
|
||||
{type === CoverType.Image ? renderCoverImage(value) : null}
|
||||
{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>
|
||||
|
@ -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 />
|
||||
|
@ -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,56 +36,71 @@ 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);
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
setShowActions(false);
|
||||
}}
|
||||
className={'relative'}
|
||||
>
|
||||
<img
|
||||
ref={imgRef}
|
||||
draggable={false}
|
||||
loading={'lazy'}
|
||||
onLoad={() => {
|
||||
setHasError(false);
|
||||
setLoading(false);
|
||||
}}
|
||||
onError={() => {
|
||||
<div
|
||||
onMouseEnter={() => {
|
||||
setShowActions(true);
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
setShowActions(false);
|
||||
}}
|
||||
className={`relative min-h-[48px] ${hasError || (loading && source !== ImageType.Local) ? 'w-full' : ''}`}
|
||||
>
|
||||
{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'}
|
||||
/>
|
||||
{initialWidth && <ImageResizer width={imageWidth ?? initialWidth} onWidthChange={handleWidthChange} />}
|
||||
{showActions && <ImageActions node={node} />}
|
||||
</div>
|
||||
) : (
|
||||
<img loading={'lazy'} {...imageProps} alt={`image-${blockId}`} />
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<div className={'flex h-[48px] w-full items-center justify-center gap-2 rounded bg-gray-100'}>
|
||||
{initialWidth && <ImageResizer width={imageWidth ?? initialWidth} onWidthChange={handleWidthChange} />}
|
||||
{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} />
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -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',
|
||||
}}
|
||||
|
@ -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,
|
||||
|
@ -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`}
|
||||
>
|
||||
|
@ -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 {
|
||||
|
@ -54,6 +54,10 @@ export const getDesignTokens = (isDark: boolean): ThemeOptions => {
|
||||
boxShadow: 'var(--shadow)',
|
||||
},
|
||||
},
|
||||
outlinedInherit: {
|
||||
color: 'var(--text-title)',
|
||||
borderColor: 'var(--line-divider)',
|
||||
},
|
||||
},
|
||||
},
|
||||
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": {
|
||||
"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",
|
||||
|
Loading…
Reference in New Issue
Block a user