mirror of
				https://github.com/AppFlowy-IO/AppFlowy.git
				synced 2025-11-03 19:43:52 +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…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user