feat: support gallery block (#6205)

This commit is contained in:
Kilu.He 2024-09-06 11:49:50 +08:00 committed by GitHub
parent c400fdc01d
commit 93ca6ac906
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
38 changed files with 854 additions and 68 deletions

View File

@ -56,6 +56,7 @@
"jest": "^29.5.0",
"js-base64": "^3.7.5",
"katex": "^0.16.7",
"lightgallery": "^2.7.2",
"lodash-es": "^4.17.21",
"nanoid": "^4.0.0",
"notistack": "^3.0.1",
@ -85,6 +86,7 @@
"react-virtualized-auto-sizer": "^1.0.20",
"react-vtree": "^2.0.4",
"react-window": "^1.8.10",
"react-zoom-pan-pinch": "^3.6.1",
"react18-input-otp": "^1.1.2",
"redux": "^4.2.1",
"rxjs": "^7.8.0",

View File

@ -101,6 +101,9 @@ dependencies:
katex:
specifier: ^0.16.7
version: 0.16.10
lightgallery:
specifier: ^2.7.2
version: 2.7.2
lodash-es:
specifier: ^4.17.21
version: 4.17.21
@ -188,6 +191,9 @@ dependencies:
react-window:
specifier: ^1.8.10
version: 1.8.10(react-dom@18.2.0)(react@18.2.0)
react-zoom-pan-pinch:
specifier: ^3.6.1
version: 3.6.1(react-dom@18.2.0)(react@18.2.0)
react18-input-otp:
specifier: ^1.1.2
version: 1.1.4(react-dom@18.2.0)(react@18.2.0)
@ -8227,6 +8233,11 @@ packages:
isomorphic.js: 0.2.5
dev: false
/lightgallery@2.7.2:
resolution: {integrity: sha512-Ewdcg9UPDqV0HGZeD7wNE4uYejwH2u0fMo5VAr6GHzlPYlhItJvjhLTR0cL0V1HjhMsH39PAom9iv69ewitLWw==}
engines: {node: '>=6.0.0'}
dev: false
/lilconfig@2.1.0:
resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==}
engines: {node: '>=10'}
@ -9824,6 +9835,17 @@ packages:
react-dom: 18.2.0(react@18.2.0)
dev: false
/react-zoom-pan-pinch@3.6.1(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-SdPqdk7QDSV7u/WulkFOi+cnza8rEZ0XX4ZpeH7vx3UZEg7DoyuAy3MCmm+BWv/idPQL2Oe73VoC0EhfCN+sZQ==}
engines: {node: '>=8', npm: '>=5'}
peerDependencies:
react: '*'
react-dom: '*'
dependencies:
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
dev: false
/react18-input-otp@1.1.4(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-35xvmTeuPWIxd0Z0Opx4z3OoMaTmKN4ubirQCx1YMZiNoe+2h1hsOSUco4aKPlGXWZCtXrfOFieAh46vqiK9mA==}
peerDependencies:

View File

@ -35,6 +35,7 @@ export enum BlockType {
TableCell = 'table/cell',
LinkPreview = 'link_preview',
FileBlock = 'file',
GalleryBlock = 'multi_image',
}
export enum InlineBlockType {
@ -112,6 +113,19 @@ export interface ImageBlockData extends BlockData {
height?: number;
}
export enum GalleryLayout {
Carousel = 0,
Grid = 1,
}
export interface GalleryBlockData extends BlockData {
images: {
type: ImageType,
url: string,
}[];
layout: GalleryLayout;
}
export interface OutlineBlockData extends BlockData {
depth?: number;
}

View File

@ -3,7 +3,7 @@ import axios, { AxiosInstance } from 'axios';
let axiosInstance: AxiosInstance | null = null;
export function initGrantService(baseURL: string) {
export function initGrantService (baseURL: string) {
if (axiosInstance) {
return;
}
@ -21,7 +21,7 @@ export function initGrantService(baseURL: string) {
});
}
export async function refreshToken(refresh_token: string) {
export async function refreshToken (refresh_token: string) {
const response = await axiosInstance?.post<{
access_token: string;
expires_at: number;
@ -39,7 +39,7 @@ export async function refreshToken(refresh_token: string) {
return newToken;
}
export async function signInWithMagicLink(email: string, authUrl: string) {
export async function signInWithMagicLink (email: string, authUrl: string) {
const res = await axiosInstance?.post(
'/magiclink',
{
@ -52,19 +52,19 @@ export async function signInWithMagicLink(email: string, authUrl: string) {
headers: {
Redirect_to: authUrl,
},
}
},
);
return res?.data;
}
export async function settings() {
export async function settings () {
const res = await axiosInstance?.get('/settings');
return res?.data;
}
export function signInGoogle(authUrl: string) {
export function signInGoogle (authUrl: string) {
const provider = 'google';
const redirectTo = encodeURIComponent(authUrl);
const accessType = 'offline';
@ -75,7 +75,16 @@ export function signInGoogle(authUrl: string) {
window.open(url, '_current');
}
export function signInGithub(authUrl: string) {
export function signInApple (authUrl: string) {
const provider = 'apple';
const redirectTo = encodeURIComponent(authUrl);
const baseURL = axiosInstance?.defaults.baseURL;
const url = `${baseURL}/authorize?provider=${provider}&redirect_to=${redirectTo}`;
window.open(url, '_current');
}
export function signInGithub (authUrl: string) {
const provider = 'github';
const redirectTo = encodeURIComponent(authUrl);
const baseURL = axiosInstance?.defaults.baseURL;
@ -84,7 +93,7 @@ export function signInGithub(authUrl: string) {
window.open(url, '_current');
}
export function signInDiscord(authUrl: string) {
export function signInDiscord (authUrl: string) {
const provider = 'discord';
const redirectTo = encodeURIComponent(authUrl);
const baseURL = axiosInstance?.defaults.baseURL;

View File

@ -191,6 +191,11 @@ export class AFClientService implements AFService {
return APIService.signInGoogle(AUTH_CALLBACK_URL);
}
@withSignIn()
async signInApple (_: { redirectTo: string }) {
return APIService.signInApple(AUTH_CALLBACK_URL);
}
@withSignIn()
async signInGithub (_: { redirectTo: string }) {
return APIService.signInGithub(AUTH_CALLBACK_URL);

View File

@ -51,6 +51,7 @@ export interface PublishService {
signInGoogle: (params: { redirectTo: string }) => Promise<void>;
signInGithub: (params: { redirectTo: string }) => Promise<void>;
signInDiscord: (params: { redirectTo: string }) => Promise<void>;
signInApple: (params: { redirectTo: string }) => Promise<void>;
getWorkspaces: () => Promise<Workspace[]>;
getWorkspaceFolder: (workspaceId: string) => Promise<FolderView>;

View File

@ -52,6 +52,10 @@ export class AFClientService implements AFService {
return Promise.reject('Method not implemented');
}
signInApple (_params: { redirectTo: string }): Promise<void> {
return Promise.reject('Method not implemented');
}
signInMagicLink (_params: { email: string; redirectTo: string }): Promise<void> {
return Promise.reject('Method not implemented');
}

View File

@ -1,5 +1,5 @@
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Icons/ Arrow / right" opacity="0.5">
<g id="Icons/ Arrow / right" opacity="1">
<path id="Vector 15" d="M4.5 9.375L7.875 6L4.5 2.625" stroke="currentColor" stroke-width="0.9"
stroke-linecap="round" stroke-linejoin="round"/>
</g>

Before

Width:  |  Height:  |  Size: 326 B

After

Width:  |  Height:  |  Size: 324 B

View File

@ -1,5 +1,5 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<g opacity="0.7">
<g opacity="1">
<path d="M15.1924 15.1924C15.4853 14.8995 15.4875 14.4268 15.1946 14.1339L10.061 9.00027L15.1945 3.86672C15.4874 3.57382 15.4853 3.10107 15.1924 2.80818C14.8995 2.51529 14.4268 2.51316 14.1339 2.80606C13.841 3.09895 9.00031 7.93961 9.00031 7.93961L3.86671 2.80601C3.57382 2.51312 3.10107 2.51524 2.80817 2.80814C2.51528 3.10103 2.51316 3.57378 2.80605 3.86667L7.93965 9.00027L2.80601 14.1339C2.51312 14.4268 2.51524 14.8996 2.80814 15.1924C3.10103 15.4853 3.57378 15.4875 3.86667 15.1946L9.00031 10.0609L14.1339 15.1945C14.4268 15.4874 14.8995 15.4853 15.1924 15.1924Z"
fill="currentColor"/>
</g>

Before

Width:  |  Height:  |  Size: 747 B

After

Width:  |  Height:  |  Size: 745 B

View File

@ -0,0 +1,6 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 13H3V10" stroke="#333333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M10 3H13V6" stroke="#333333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M3 13L7 9" stroke="#333333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M13 3L9 7" stroke="#333333" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 449 B

View File

@ -0,0 +1,46 @@
<svg fill="#000000" height="800px" width="800px" version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 22.773 22.773" xml:space="preserve">
<g>
<g>
<path d="M15.769,0c0.053,0,0.106,0,0.162,0c0.13,1.606-0.483,2.806-1.228,3.675c-0.731,0.863-1.732,1.7-3.351,1.573
c-0.108-1.583,0.506-2.694,1.25-3.561C13.292,0.879,14.557,0.16,15.769,0z"/>
<path d="M20.67,16.716c0,0.016,0,0.03,0,0.045c-0.455,1.378-1.104,2.559-1.896,3.655c-0.723,0.995-1.609,2.334-3.191,2.334
c-1.367,0-2.275-0.879-3.676-0.903c-1.482-0.024-2.297,0.735-3.652,0.926c-0.155,0-0.31,0-0.462,0
c-0.995-0.144-1.798-0.932-2.383-1.642c-1.725-2.098-3.058-4.808-3.306-8.276c0-0.34,0-0.679,0-1.019
c0.105-2.482,1.311-4.5,2.914-5.478c0.846-0.52,2.009-0.963,3.304-0.765c0.555,0.086,1.122,0.276,1.619,0.464
c0.471,0.181,1.06,0.502,1.618,0.485c0.378-0.011,0.754-0.208,1.135-0.347c1.116-0.403,2.21-0.865,3.652-0.648
c1.733,0.262,2.963,1.032,3.723,2.22c-1.466,0.933-2.625,2.339-2.427,4.74C17.818,14.688,19.086,15.964,20.67,16.716z"/>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="12" y="7.5" width="1" height="8" rx="0.5" transform="rotate(90 12 7.5)" fill="#333333"/>
</svg>

After

Width:  |  Height:  |  Size: 201 B

View File

@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 8C3 5.23858 5.23858 3 8 3C10.7614 3 13 5.23858 13 8C13 10.7614 10.7614 13 8 13C6.69875 13 5.51361 12.5029 4.62408 11.6883M3 8L4.5 7M3 8L2 6.5" stroke="#333333" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 324 B

View File

@ -12,11 +12,10 @@ function AppFlowyPower ({
return (
<div
style={{
backdropFilter: 'saturate(180%) blur(16px)',
width,
boxShadow: 'var(--footer) 0px -4px 14px 13px',
boxShadow: 'var(--bg-footer) 0px -4px 14px 13px',
}}
className={'flex bg-bg-body sticky bottom-0 w-full flex-col items-center justify-center'}
className={'flex bg-bg-body sticky bottom-[-0.5px] w-full flex-col items-center justify-center'}
>
{divider && <Divider className={'w-full my-0'} />}

View File

@ -0,0 +1,184 @@
import { notify } from '@/components/_shared/notify';
import { copyTextToClipboard } from '@/utils/copy';
import { IconButton, Portal, Tooltip } from '@mui/material';
import React, { memo, useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { TransformWrapper, TransformComponent } from 'react-zoom-pan-pinch';
import { ReactComponent as RightIcon } from '@/assets/arrow_right.svg';
import { ReactComponent as ReloadIcon } from '@/assets/reload.svg';
import { ReactComponent as AddIcon } from '@/assets/add.svg';
import { ReactComponent as MinusIcon } from '@/assets/minus.svg';
import { ReactComponent as LinkIcon } from '@/assets/link.svg';
import { ReactComponent as DownloadIcon } from '@/assets/download.svg';
import { ReactComponent as CloseIcon } from '@/assets/close.svg';
export interface GalleryImage {
src: string;
thumb: string;
responsive: string;
}
export interface GalleryPreviewProps {
images: GalleryImage[];
open: boolean;
onClose: () => void;
previewIndex: number;
}
const buttonClassName = 'p-1 hover:bg-transparent text-white hover:text-content-blue-400 p-0';
function GalleryPreview ({
images,
open,
onClose,
previewIndex,
}: GalleryPreviewProps) {
const { t } = useTranslation();
const [index, setIndex] = useState(previewIndex);
const handleToPrev = useCallback(() => {
setIndex((prev) => prev === 0 ? images.length - 1 : prev - 1);
}, [images.length]);
const handleToNext = useCallback(() => {
setIndex((prev) => prev === images.length - 1 ? 0 : prev + 1);
}, [images.length]);
const handleCopy = useCallback(async () => {
const image = images[index];
if (!image) {
return;
}
await copyTextToClipboard(image.src);
notify.success(t('publish.copy.imageBlock'));
}, [images, index, t]);
const handleDownload = useCallback(() => {
const image = images[index];
if (!image) {
return;
}
window.open(image.src, '_blank');
}, [images, index]);
const handleKeydown = useCallback((e: KeyboardEvent) => {
e.preventDefault();
e.stopPropagation();
switch (true) {
case e.key === 'ArrowLeft':
case e.key === 'ArrowUp':
handleToPrev();
break;
case e.key === 'ArrowRight':
case e.key === 'ArrowDown':
handleToNext();
break;
case e.key === 'Escape':
onClose();
break;
}
}, [handleToNext, handleToPrev, onClose]);
useEffect(() => {
(document.activeElement as HTMLElement)?.blur();
window.addEventListener('keydown', handleKeydown);
return () => {
window.removeEventListener('keydown', handleKeydown);
};
}, [handleKeydown]);
if (!open) {
return null;
}
return (
<Portal container={document.body}>
<div className={'fixed inset-0 bg-black bg-opacity-80 z-50'} onClick={onClose}>
<TransformWrapper
initialScale={1}
centerOnInit={true}
maxScale={1.5}
minScale={0.5}
>
{({ zoomIn, zoomOut, resetTransform }) => (
<React.Fragment>
<div className="absolute bottom-20 left-1/2 z-10 transform flex gap-4 -translate-x-1/2 p-4"
onClick={e => e.stopPropagation()}
>
{images.length > 1 &&
<div className={'flex gap-2 w-fit bg-bg-mask rounded-[8px] p-2'}>
<Tooltip title={t('gallery.prev')}>
<IconButton size={'small'} onClick={handleToPrev} className={buttonClassName}>
<RightIcon className={'transform rotate-180'} />
</IconButton>
</Tooltip>
<span className={'text-text-caption'}>{index + 1}/{images.length}</span>
<Tooltip title={t('gallery.next')}>
<IconButton size={'small'} onClick={handleToNext} className={buttonClassName}>
<RightIcon />
</IconButton>
</Tooltip>
</div>}
<div className={'flex items-center gap-2 w-fit bg-bg-mask rounded-[8px] p-2'}>
<Tooltip title={t('gallery.zoomIn')}>
<IconButton size={'small'} onClick={() => zoomIn()} className={buttonClassName}>
<AddIcon />
</IconButton>
</Tooltip>
{/*<Button color={'inherit'} size={'small'}>*/}
{/* {scale * 100}%*/}
{/*</Button>*/}
<Tooltip title={t('gallery.zoomOut')}>
<IconButton size={'small'} onClick={() => zoomOut()} className={buttonClassName}>
<MinusIcon />
</IconButton>
</Tooltip>
<Tooltip title={t('gallery.resetZoom')}>
<IconButton size={'small'} onClick={() => resetTransform()} className={buttonClassName}>
<ReloadIcon />
</IconButton>
</Tooltip>
</div>
<div className={'flex gap-2 w-fit bg-bg-mask rounded-[8px] p-2'}>
<Tooltip title={t('gallery.copy')}>
<IconButton size={'small'} className={buttonClassName} onClick={handleCopy}>
<LinkIcon />
</IconButton>
</Tooltip>
<Tooltip title={t('button.download')}>
<IconButton size={'small'} className={buttonClassName} onClick={handleDownload}>
<DownloadIcon />
</IconButton>
</Tooltip>
</div>
<Tooltip title={t('button.close')}>
<IconButton
size={'small'} onClick={onClose}
className={'bg-bg-mask px-3.5 rounded-[8px] text-white hover:text-content-blue-400'}
>
<CloseIcon className={'w-3.5 h-3.5'} />
</IconButton>
</Tooltip>
</div>
<TransformComponent contentProps={{
onClick: e => e.stopPropagation(),
}} wrapperStyle={{ width: '100%', height: '100%' }}
>
<img src={images[index].src} alt={images[index].src}
/>
</TransformComponent>
</React.Fragment>
)}
</TransformWrapper>
</div>
</Portal>
);
}
export default memo(GalleryPreview);

View File

@ -0,0 +1,3 @@
import { lazy } from 'react';
export const GalleryPreview = lazy(() => import('./GalleryPreview'));

View File

@ -0,0 +1,13 @@
.gallery-preview {
.lg-outer .lg-thumb-item {
@apply rounded-[8px];
img {
@apply rounded-[6px];
}
}
.lg-outer .lg-thumb-item.active, .lg-outer .lg-thumb-item:hover {
border: 2px solid var(--content-blue-400);
padding: 2px;
}
}

View File

@ -0,0 +1,80 @@
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import LightGallery from 'lightgallery/react';
import 'lightgallery/css/lightgallery.css';
import 'lightgallery/css/lg-thumbnail.css';
import 'lightgallery/css/lg-autoplay.css';
import './carousel.scss';
import lgThumbnail from 'lightgallery/plugins/thumbnail';
import lgAutoplay from 'lightgallery/plugins/autoplay';
const plugins = [lgThumbnail, lgAutoplay];
function Carousel ({ images, onPreview, autoplay }: {
images: {
src: string;
thumb: string;
responsive: string;
}[];
onPreview: (index: number) => void;
autoplay?: boolean;
}) {
const containerRef = useRef<HTMLDivElement>(null);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const instance = useRef<any | null>(null);
const [rendered, setRendered] = useState(false);
useEffect(() => {
setRendered(true);
}, []);
useEffect(() => {
if (!instance.current) return;
if (autoplay) {
instance.current.plugins[1].startAutoPlay();
} else {
instance.current.plugins[1].stopAutoPlay();
}
}, [autoplay]);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const handleInit = useCallback((detail: any) => {
instance.current = detail.instance;
detail.instance.openGallery(Math.ceil(images.length / 2) - 1);
}, [images]);
const handleAfterSlide = useCallback((detail: { index: number }) => {
onPreview(detail.index);
}, [onPreview]);
const renderCarousel = useMemo(() => {
if (!containerRef.current || !rendered) return;
return <LightGallery
container={containerRef.current}
onInit={handleInit}
onAfterSlide={handleAfterSlide}
plugins={plugins}
dynamic={true}
dynamicEl={images}
autoplay={true}
progressBar={false}
speed={500}
slideDelay={0}
thumbWidth={90}
thumbMargin={6}
closable={false}
/>;
}, [handleAfterSlide, handleInit, images, rendered]);
return (
<div className={'flex flex-col images-carousel'}>
<div className={'relative carousel-container'} ref={containerRef}></div>
{renderCarousel}
</div>
);
}
export default memo(Carousel);

View File

@ -0,0 +1,113 @@
import { GalleryLayout } from '@/application/collab.type';
import { GalleryPreview } from '@/components/_shared/gallery-preview';
import { notify } from '@/components/_shared/notify';
import Carousel from '@/components/editor/components/blocks/gallery/Carousel';
import GalleryToolbar from '@/components/editor/components/blocks/gallery/GalleryToolbar';
import ImageGallery from '@/components/editor/components/blocks/gallery/ImageGallery';
import { EditorElementProps, GalleryBlockNode } from '@/components/editor/editor.type';
import { copyTextToClipboard } from '@/utils/copy';
import React, { forwardRef, memo, useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
const GalleryBlock = memo(
forwardRef<HTMLDivElement, EditorElementProps<GalleryBlockNode>>(({
node,
children,
...attributes
}, ref) => {
const { t } = useTranslation();
const { images, layout } = useMemo(() => node.data || {}, [node.data]);
const [openPreview, setOpenPreview] = React.useState(false);
const previewIndexRef = React.useRef(0);
const [hovered, setHovered] = useState(false);
const className = useMemo(() => {
const classList = ['gallery-block', 'relative', 'w-full', 'cursor-default', attributes.className || ''];
return classList.join(' ');
}, [attributes.className]);
const photos = useMemo(() => {
return images.map(image => {
const url = new URL(image.url);
url.searchParams.set('auto', 'format');
url.searchParams.set('fit', 'crop');
return {
src: image.url,
thumb: url.toString() + '&w=240&q=80',
responsive: [
url.toString() + '&w=480&q=80 480',
url.toString() + '&w=800&q=80 800',
].join(', '),
};
});
}, [images]);
const handleOpenPreview = useCallback(() => {
setOpenPreview(true);
}, []);
const handleCopy = useCallback(async () => {
const image = photos[previewIndexRef.current];
if (!image) {
return;
}
await copyTextToClipboard(image.src);
notify.success(t('publish.copy.imageBlock'));
}, [photos, t]);
const handleDownload = useCallback(() => {
const image = photos[previewIndexRef.current];
if (!image) {
return;
}
window.open(image.src, '_blank');
}, [photos]);
const handlePreviewIndex = useCallback((index: number) => {
previewIndexRef.current = index;
}, []);
return (
<div ref={ref} {...attributes} className={className} onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
<div className={'absolute left-0 top-0 h-full w-full pointer-events-none'}>
{children}
</div>
{photos.length > 0 ?
(layout === GalleryLayout.Carousel ?
<Carousel
onPreview={handlePreviewIndex}
images={photos}
autoplay={!openPreview}
/> :
<ImageGallery
onPreview={(index) => {
previewIndexRef.current = index;
handleOpenPreview();
}} images={photos}
/>
) : null}
{hovered &&
<GalleryToolbar onCopy={handleCopy} onDownload={handleDownload} onOpenPreview={handleOpenPreview} />}
{openPreview && <GalleryPreview
images={photos}
previewIndex={previewIndexRef.current}
open={openPreview}
onClose={() => {
setOpenPreview(false);
}}
/>}
</div>
);
}));
export default GalleryBlock;

View File

@ -0,0 +1,42 @@
import { IconButton, Tooltip } from '@mui/material';
import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { ReactComponent as CopyIcon } from '@/assets/link.svg';
import { ReactComponent as DownloadIcon } from '@/assets/download.svg';
import { ReactComponent as PreviewIcon } from '@/assets/full_view.svg';
function GalleryToolbar ({
onOpenPreview,
onDownload,
onCopy,
}: {
onOpenPreview: () => void;
onDownload: () => void;
onCopy: () => void;
}) {
const { t } = useTranslation();
const buttons = useMemo(() => [
{ label: t('gallery.preview'), onClick: onOpenPreview, Icon: PreviewIcon },
{ label: t('gallery.copy'), onClick: onCopy, Icon: CopyIcon },
{ label: t('gallery.download'), onClick: onDownload, Icon: DownloadIcon },
], [t, onOpenPreview, onDownload, onCopy]);
return (
<div className={'absolute z-10 top-0 right-0'}>
<div className={'flex space-x-1 rounded-[8px] p-1 bg-bg-body shadow border border-line-divider '}>
{buttons.map(({ label, onClick, Icon }, index) => (
<Tooltip title={label} key={index}>
<IconButton
size={'small'} onClick={onClick}
className={'p-1 hover:bg-transparent hover:text-content-blue-400'}
>
<Icon className={'h-5 w-5'} />
</IconButton>
</Tooltip>
))}
</div>
</div>
);
}
export default GalleryToolbar;

View File

@ -0,0 +1,123 @@
import React, { useCallback, useMemo } from 'react';
const GROUP_SIZE = 4;
interface ImageType {
src: string;
thumb: string;
responsive: string;
}
interface ImageGalleryProps {
images: ImageType[];
onPreview: (index: number) => void;
}
const ImageGallery: React.FC<ImageGalleryProps> = ({ images, onPreview }) => {
const optimizeImageUrl = useCallback((src: string, width: number, height: number): string => {
const url = new URL(src);
url.searchParams.set('auto', 'format');
url.searchParams.set('fit', 'crop');
url.searchParams.set('q', '80');
return `${url.toString()}&w=${width}&h=${height}`;
}, []);
const groupImages = useCallback((images: ImageType[], groupSize: number = GROUP_SIZE): string[][] => {
return images.reduce((acc, _, index) => {
if (index % groupSize === 0) {
acc.push(images.slice(index, index + groupSize).map(img => img.src));
}
return acc;
}, [] as string[][]);
}, []);
const imageGroups = useMemo(() => groupImages(images), [images, groupImages]);
const renderImage = useCallback((image: string, width: number, height: number, index: number) => (
<img
key={image}
alt={`Image ${index + 1}`}
src={optimizeImageUrl(image, width, height)}
className="w-full h-full object-cover rounded cursor-pointer transition-transform hover:scale-105"
onClick={() => onPreview(index)}
/>
), [optimizeImageUrl, onPreview]);
const renderGroup = useCallback((group: string[], groupIndex: number) => {
const startIndex = groupIndex * GROUP_SIZE;
const isOdd = groupIndex % 2 !== 0;
const renderLargeImage = (image: string, index: number) => (
<div className="w-1/2 h-96 p-1">
{renderImage(image, 600, 800, index)}
</div>
);
const renderSmallImages = (images: string[], startIdx: number) => (
<div className="w-1/2 h-96 flex flex-col">
{images.length === 2 ? (
<>
<div className="h-1/2 p-1">{images[0] && renderImage(images[0], 600, 400, startIdx)}</div>
<div className="h-1/2 p-1">{images[1] && renderImage(images[1], 600, 400, startIdx + 1)}</div>
</>
) : (
<>
<div className="h-1/2 flex">
<div className="w-1/2 p-1">{images[0] && renderImage(images[0], 300, 400, startIdx)}</div>
<div className="w-1/2 p-1">{images[1] && renderImage(images[1], 300, 400, startIdx + 1)}</div>
</div>
<div className="h-1/2 p-1">{images[2] && renderImage(images[2], 600, 400, startIdx + 2)}</div>
</>
)}
</div>
);
if (group.length === 1) {
return <div className="w-full h-96 p-1">
{renderImage(group[0], 1200, 800, startIndex)}
</div>;
}
if (group.length === 2) {
return (
<>
{renderLargeImage(group[0], startIndex)}
{renderLargeImage(group[1], startIndex + 1)}
</>
);
}
if (isOdd) {
const smallImages = group.length === 3 ? [group[0], group[2]] : [group[0], group[1], group[3]];
const largeImage = group.length === 3 ? group[1] : group[2];
return (
<>
{renderSmallImages(smallImages, startIndex)}
{largeImage && renderLargeImage(largeImage, startIndex + 2)}
</>
);
} else {
return (
<>
{renderLargeImage(group[0], startIndex)}
{renderSmallImages(group.slice(1), startIndex + 1)}
</>
);
}
}, [renderImage]);
return (
<div className="container mx-auto">
{imageGroups.map((group, groupIndex) => (
<div key={groupIndex} className="flex -mx-1">
{renderGroup(group, groupIndex)}
</div>
))}
</div>
);
};
export default ImageGallery;

View File

@ -0,0 +1,48 @@
.images-carousel {
.carousel-container {
@apply h-[600px] max-sm:h-[360px];
}
.lg-thumb-item {
display: flex;
}
.lg-toolbar {
display: none;
}
.lg-backdrop, .lg-outer .lg-thumb-outer {
background: var(--bg-body);
}
.lg-outer .lg-thumb-outer {
max-height: 100px;
}
.lg-outer.lg-animate-thumb .lg-thumb {
@apply max-sm:top-[-46px] top-[-50px];
}
.lg-outer .lg-thumb-item {
@apply rounded-[8px];
img {
@apply rounded-[6px];
}
}
.lg-outer .lg-thumb-item.active, .lg-outer .lg-thumb-item:hover {
border: 2px solid var(--content-blue-400);
padding: 2px;
}
.lg-next, .lg-prev {
background: transparent;
color: var(--text-caption);
@apply max-md:hidden;
&:hover:not(.disabled) {
color: var(--text-title);
}
}
}

View File

@ -0,0 +1,3 @@
import { lazy } from 'react';
export const GalleryBlock = lazy(() => import('./GalleryBlock'));

View File

@ -4,6 +4,7 @@ import { Callout } from '@/components/editor/components/blocks/callout';
import { CodeBlock } from '@/components/editor/components/blocks/code';
import { DatabaseBlock } from '@/components/editor/components/blocks/database';
import { DividerNode } from '@/components/editor/components/blocks/divider';
import { GalleryBlock } from '@/components/editor/components/blocks/gallery';
import { Heading } from '@/components/editor/components/blocks/heading';
import { ImageBlock } from '@/components/editor/components/blocks/image';
import { LinkPreview } from '@/components/editor/components/blocks/link-preview';
@ -77,6 +78,8 @@ export const Element = ({
return LinkPreview;
case BlockType.FileBlock:
return FileBlock;
case BlockType.GalleryBlock:
return GalleryBlock;
default:
return UnSupportedBlock;
}

View File

@ -17,7 +17,7 @@ import {
BlockId,
BlockData,
DatabaseNodeData,
LinkPreviewBlockData, FileBlockData,
LinkPreviewBlockData, FileBlockData, GalleryBlockData,
} from '@/application/collab.type';
import { HTMLAttributes } from 'react';
import { Element } from 'slate';
@ -116,6 +116,12 @@ export interface ImageBlockNode extends BlockNode {
data: ImageBlockData;
}
export interface GalleryBlockNode extends BlockNode {
type: BlockType.GalleryBlock;
blockId: string;
data: GalleryBlockData;
}
export interface OutlineNode extends BlockNode {
type: BlockType.OutlineBlock;
blockId: string;

View File

@ -4,10 +4,10 @@ import { Button } from '@mui/material';
export const ErrorModal = ({ message, onClose }: { message: string; onClose: () => void }) => {
return (
<div className={'fixed inset-0 z-10 flex items-center justify-center bg-white/30 backdrop-blur-sm'}>
<div className={'fixed inset-0 z-10 flex items-center justify-center bg-bg-mask backdrop-blur-sm'}>
<div
className={
'border-shade-5 relative flex flex-col items-center gap-8 rounded-xl border bg-white px-16 py-8 shadow-md'
'border-shade-5 relative flex flex-col items-center gap-8 rounded-xl border border-line-divider bg-bg-body px-16 py-8 shadow-md'
}
>
<button

View File

@ -1,14 +1,16 @@
import { notify } from '@/components/_shared/notify';
import { AFConfigContext } from '@/components/app/app.hooks';
import { Button } from '@mui/material';
import React, { useContext, useMemo } from 'react';
import { Button, Collapse, Divider } from '@mui/material';
import React, { useCallback, useContext, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { ReactComponent as GoogleSvg } from '@/assets/login/google.svg';
import { ReactComponent as GithubSvg } from '@/assets/login/github.svg';
import { ReactComponent as DiscordSvg } from '@/assets/login/discord.svg';
import { ReactComponent as AppleSvg } from '@/assets/login/apple.svg';
function LoginProvider({ redirectTo }: { redirectTo: string }) {
function LoginProvider ({ redirectTo }: { redirectTo: string }) {
const { t } = useTranslation();
const [expand, setExpand] = React.useState(false);
const options = useMemo(
() => [
{
@ -16,6 +18,11 @@ function LoginProvider({ redirectTo }: { redirectTo: string }) {
Icon: GoogleSvg,
value: 'google',
},
{
label: t('web.continueWithApple'),
Icon: AppleSvg,
value: 'apple',
},
{
label: t('web.continueWithGithub'),
value: 'github',
@ -27,16 +34,19 @@ function LoginProvider({ redirectTo }: { redirectTo: string }) {
Icon: DiscordSvg,
},
],
[t]
[t],
);
const service = useContext(AFConfigContext)?.service;
const handleClick = async (option: string) => {
const handleClick = useCallback(async (option: string) => {
try {
switch (option) {
case 'google':
await service?.signInGoogle({ redirectTo });
break;
case 'apple':
await service?.signInApple({ redirectTo });
break;
case 'github':
await service?.signInGithub({ redirectTo });
break;
@ -47,24 +57,38 @@ function LoginProvider({ redirectTo }: { redirectTo: string }) {
} catch (e) {
notify.error(t('web.signInError'));
}
};
}, [service, t, redirectTo]);
return (
<div className={'flex w-full flex-col items-center justify-center gap-[10px]'}>
{options.map((option) => (
<Button
const renderOption = useCallback((option: typeof options[0]) => {
return <Button
key={option.value}
color={'inherit'}
variant={'outlined'}
onClick={() => handleClick(option.value)}
className={
'flex h-[46px] w-[380px] items-center justify-center gap-[10px] rounded-[12px] border border-line-divider text-sm font-medium text-text-title max-sm:w-full'
`flex h-[46px] w-[380px] items-center justify-center gap-[10px] rounded-[12px] border border-line-divider text-sm font-medium max-sm:w-full ${option.value === 'apple' ? 'text-black bg-white hover:bg-fill-list-hover' : 'text-text-title'}`
}
>
<option.Icon className={'h-[20px] w-[20px]'} />
{option.label}
</Button>;
}, [handleClick]);
return (
<div className={'flex w-full flex-col items-center justify-center gap-[10px]'}>
{options.slice(0, 2).map(renderOption)}
<Button
color={'inherit'}
size={'small'}
onClick={() => setExpand(!expand)}
className={'text-sm w-full flex gap-2 items-center hover:bg-transparent hover:text-text-title font-medium text-text-caption'}
>
<Divider className={'flex-1'} />
{expand ? t('web.collapse') : t('web.moreOptions')}
<Divider className={'flex-1'} />
</Button>
))}
<Collapse in={expand}>{options.slice(2).map(renderOption)}</Collapse>
</div>
);
}

View File

@ -48,7 +48,12 @@ function BreadcrumbItem ({ crumb, disableClick = false }: { crumb: Crumb; disabl
try {
await onNavigateToView?.(viewId);
} catch (e) {
notify.default(t('publish.hasNotBeenPublished'));
if (extraObj.is_space) {
notify.warning(t('publish.spaceHasNotBeenPublished'));
return;
}
notify.warning(t('publish.hasNotBeenPublished'));
}
}}
>

View File

@ -105,7 +105,7 @@ export function PublishViewHeader ({
<div
style={{
backdropFilter: 'saturate(180%) blur(16px)',
background: 'var(--header)',
background: 'var(--bg-header)',
height: HEADER_HEIGHT,
}}
className={'appflowy-top-bar sticky top-0 z-10 flex px-5'}

View File

@ -44,13 +44,6 @@ function DuplicateModal ({ open, onClose }: { open: boolean; onClose: () => void
loadSpaces,
} = useLoadWorkspaces();
useEffect(() => {
if (!open) {
setSelectedWorkspaceId(workspaceList[0]?.id || '');
setSelectedSpaceId('');
}
}, [open, setSelectedSpaceId, setSelectedWorkspaceId, workspaceList]);
useEffect(() => {
if (open) {
void loadWorkspaces();
@ -122,7 +115,7 @@ function DuplicateModal ({ open, onClose }: { open: boolean; onClose: () => void
maxWidth: 420,
},
}}
okText={t('publish.openApp')}
okText={t('publish.useThisTemplate')}
cancelText={t('publish.downloadIt')}
onOk={() => window.open(openAppFlowySchema, '_self')}
onCancel={() => {

View File

@ -15,7 +15,7 @@ export interface SelectWorkspaceProps {
loading?: boolean;
}
function stringAvatar(name: string) {
function stringAvatar (name: string) {
return {
sx: {
bgcolor: stringToColor(name),
@ -24,7 +24,7 @@ function stringAvatar(name: string) {
};
}
function SelectWorkspace({ loading, value, onChange, workspaceList }: SelectWorkspaceProps) {
function SelectWorkspace ({ loading, value, onChange, workspaceList }: SelectWorkspaceProps) {
const { t } = useTranslation();
const email = useContext(AFConfigContext)?.currentUser?.email || '';
const selectedWorkspace = useMemo(() => {
@ -58,7 +58,7 @@ function SelectWorkspace({ loading, value, onChange, workspaceList }: SelectWork
</div>
);
},
[t]
[t],
);
return (
@ -120,6 +120,7 @@ function SelectWorkspace({ loading, value, onChange, workspaceList }: SelectWork
onClick={() => {
onChange?.(workspace.id);
setSelectOpen(false);
localStorage.setItem('duplicate_selected_workspace', workspace.id);
}}
className={'w-full px-3 py-2'}
variant={'text'}

View File

@ -49,7 +49,9 @@ export function useDuplicate () {
export function useLoadWorkspaces () {
const [spaceLoading, setSpaceLoading] = useState<boolean>(false);
const [workspaceLoading, setWorkspaceLoading] = useState<boolean>(false);
const [selectedWorkspaceId, setSelectedWorkspaceId] = useState<string>('');
const [selectedWorkspaceId, setSelectedWorkspaceId] = useState<string>(() => {
return localStorage.getItem('duplicate_selected_workspace') || '';
});
const [selectedSpaceId, setSelectedSpaceId] = useState<string>('');
const [workspaceList, setWorkspaceList] = useState<Workspace[]>([]);
@ -65,7 +67,10 @@ export function useLoadWorkspaces () {
if (workspaces) {
setWorkspaceList(workspaces);
setSelectedWorkspaceId(workspaces[0].id);
setSelectedWorkspaceId(prev => {
if (!prev || !workspaces.find(item => item.id === prev)) return workspaces[0].id;
return prev;
});
} else {
setWorkspaceList([]);
setSelectedWorkspaceId('');

View File

@ -75,7 +75,12 @@ function OutlineItem ({ view, level = 0, width }: { view: View; width: number; l
try {
await navigateToView?.(view_id);
} catch (e) {
notify.default(t('publish.hasNotBeenPublished'));
if (isSpace) {
notify.warning(t('publish.spaceHasNotBeenPublished'));
return;
}
notify.warning(t('publish.hasNotBeenPublished'));
}
}}
style={{

View File

@ -19,7 +19,7 @@
--fill-toolbar: #0F111C;
--fill-selector: #232b38;
--fill-list-active: #3c4557;
--fill-list-hover: rgba(255, 255, 255, 0.1);
--fill-list-hover: #FFFFFF19;
--content-blue-400: #00bcf0;
--content-blue-300: #52d1f4;
--content-blue-600: #009fd1;
@ -29,7 +29,7 @@
--content-blue-50: #232b38;
--bg-body: #1a202c;
--bg-base: #232b38;
--bg-mask: rgba(0, 0, 0, 0.7);
--bg-mask: #000000B2;
--bg-tips: #005174;
--bg-brand: #2c144b;
--function-error: #d32772;
@ -61,6 +61,6 @@
--gradient5: linear-gradient(56.2deg, #5749CA 0%, #BB4A97 100%);
--gradient6: linear-gradient(180deg, #036FFA 0%, #00B8E5 100%);
--gradient7: linear-gradient(38.2deg, #F0C6CF 0%, #DECCE2 40.4754%, #CAD3F9 100%);
--header: rgba(0, 0, 0, 0.7);
--footer: rgba(0, 0, 0, 0);
--bg-header: #1a202ccc;
--bg-footer: #00000000;
}

View File

@ -20,7 +20,7 @@
--fill-hover: #52d1f4;
--fill-pressed: #009fd1;
--fill-active: #e0f8ff;
--fill-list-hover: rgba(31, 35, 41, 6%);
--fill-list-hover: #1F23290F;
--fill-list-active: #f9fafd;
--content-blue-400: #00bcf0;
--content-blue-300: #52d1f4;
@ -32,7 +32,7 @@
--content-on-tag: #4f4f4f;
--bg-body: #ffffff;
--bg-base: #f9fafd;
--bg-mask: rgba(0, 0, 0, 0.55);
--bg-mask: #0000008C;
--bg-tips: #e0f8ff;
--bg-brand: #2c144b;
--function-error: #fb006d;
@ -64,6 +64,6 @@
--gradient5: linear-gradient(56.2deg, #5749CA 0%, #BB4A97 100%);
--gradient6: linear-gradient(180deg, #036FFA 0%, #00B8E5 100%);
--gradient7: linear-gradient(38.2deg, #F0C6CF 0%, #DECCE2 40.4754%, #CAD3F9 100%);
--header: rgba(255, 255, 255, 0.8);
--footer: rgba(255, 255, 255, 0.8);
--bg-header: #FFFFFFCC;
--bg-footer: #FFFFFFCC;
}

View File

@ -1,7 +1,7 @@
/**
* Do not edit directly
* Generated on Mon, 27 May 2024 06:26:20 GMT
* Generated on Fri, 06 Sep 2024 02:15:53 GMT
* Generated from $pnpm css:variables
*/

View File

@ -1,7 +1,7 @@
/**
* Do not edit directly
* Generated on Mon, 27 May 2024 06:26:20 GMT
* Generated on Fri, 06 Sep 2024 02:15:53 GMT
* Generated from $pnpm css:variables
*/
@ -50,13 +50,19 @@ module.exports = {
"bg": {
"body": "var(--bg-body)",
"base": "var(--bg-base)",
"mask": "var(--bg-mask)",
"tips": "var(--bg-tips)",
"brand": "var(--bg-brand)"
"brand": "var(--bg-brand)",
"header": "var(--bg-header)",
"footer": "var(--bg-footer)"
},
"function": {
"error": "var(--function-error)",
"error-hover": "var(--function-error-hover)",
"waring": "var(--function-waring)",
"waring-hover": "var(--function-waring-hover)",
"success": "var(--function-success)",
"success-hover": "var(--function-success-hover)",
"info": "var(--function-info)"
},
"tint": {

View File

@ -2361,6 +2361,7 @@
},
"publish": {
"hasNotBeenPublished": "This page hasn't been published yet",
"spaceHasNotBeenPublished": "Haven't supported publishing a space yet",
"reportPage": "Report page",
"databaseHasNotBeenPublished": "Publishing a database is not supported yet.",
"createdWith": "Created with",
@ -2394,8 +2395,8 @@
"duplicateTitle": "Where would you like to add",
"selectWorkspace": "Select a workspace",
"addTo": "Add to",
"duplicateSuccessfully": "Duplicated success. Want to view documents?",
"duplicateSuccessfullyDescription": "Don't have the app? Your download will begin automatically after clicking the 'Download'.",
"duplicateSuccessfully": "Added to your workspace",
"duplicateSuccessfullyDescription": "Don't have AppFlowy installed? The download will start automatically after you click 'Download'.",
"downloadIt": "Download",
"openApp": "Open in app",
"duplicateFailed": "Duplicated failed",
@ -2404,7 +2405,8 @@
"one": "1 member",
"many": "{count} members",
"other": "{count} members"
}
},
"useThisTemplate": "Use the template"
},
"web": {
"continue": "Continue",
@ -2412,6 +2414,9 @@
"continueWithGoogle": "Continue with Google",
"continueWithGithub": "Continue with GitHub",
"continueWithDiscord": "Continue with Discord",
"continueWithApple": "Continue with Apple",
"moreOptions": "More options",
"collapse": "Collapse",
"signInAgreement": "By clicking \"Continue\" above, you agreed to AppFlowy's",
"and": "and",
"termOfUse": "Terms",
@ -2535,5 +2540,15 @@
"uploadSuccessDescription": "The file has been uploaded successfully",
"uploadFailedDescription": "The file upload failed",
"uploadingDescription": "The file is being uploaded"
},
"gallery": {
"preview": "Open in full screen",
"copy": "Copy",
"download": "Download",
"prev": "Previous",
"next": "Next",
"resetZoom": "Reset zoom",
"zoomIn": "Zoom in",
"zoomOut": "Zoom out"
}
}