feat: support adding cover to document (#4814)

This commit is contained in:
Kilu.He 2024-03-05 10:57:52 +08:00 committed by GitHub
parent 5daf9d23f5
commit c0210a5778
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
49 changed files with 906 additions and 370 deletions

View File

@ -2,7 +2,8 @@ import { Op } from 'quill-delta';
import { HTMLAttributes } from 'react'; import { HTMLAttributes } from 'react';
import { Element } from 'slate'; import { Element } from 'slate';
import { ViewIconTypePB, ViewLayoutPB } from '@/services/backend'; import { ViewIconTypePB, ViewLayoutPB } from '@/services/backend';
import { YXmlText } from 'yjs/dist/src/types/YXmlText'; import { PageCover } from '$app_reducers/pages/slice';
import * as Y from 'yjs';
export interface EditorNode { export interface EditorNode {
id: string; id: string;
@ -162,14 +163,22 @@ export interface MentionPage {
} }
export interface EditorProps { export interface EditorProps {
id: string;
sharedType?: YXmlText;
title?: string; title?: string;
cover?: PageCover;
onTitleChange?: (title: string) => void; onTitleChange?: (title: string) => void;
onCoverChange?: (cover?: PageCover) => void;
showTitle?: boolean; showTitle?: boolean;
id: string;
disableFocus?: boolean; disableFocus?: boolean;
} }
export interface LocalEditorProps {
disableFocus?: boolean;
sharedType: Y.XmlText;
id: string;
caretColor?: string;
}
export enum EditorNodeType { export enum EditorNodeType {
Text = 'text', Text = 'text',
Paragraph = 'paragraph', Paragraph = 'paragraph',
@ -221,7 +230,9 @@ export enum MentionType {
export interface Mention { export interface Mention {
// inline page ref id // inline page ref id
page?: string; page_id?: string;
// reminder date ref id // reminder date ref id
date?: string; date?: string;
type: MentionType;
} }

View File

@ -1,3 +1,5 @@
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M2.5 1H12.5C13.3284 1 14 1.67157 14 2.5V12.5C14 13.3284 13.3284 14 12.5 14H2.5C1.67157 14 1 13.3284 1 12.5V2.5C1 1.67157 1.67157 1 2.5 1ZM2.5 2C2.22386 2 2 2.22386 2 2.5V8.3636L3.6818 6.6818C3.76809 6.59551 3.88572 6.54797 4.00774 6.55007C4.12975 6.55216 4.24568 6.60372 4.32895 6.69293L7.87355 10.4901L10.6818 7.6818C10.8575 7.50607 11.1425 7.50607 11.3182 7.6818L13 9.3636V2.5C13 2.22386 12.7761 2 12.5 2H2.5ZM2 12.5V9.6364L3.98887 7.64753L7.5311 11.4421L8.94113 13H2.5C2.22386 13 2 12.7761 2 12.5ZM12.5 13H10.155L8.48336 11.153L11 8.6364L13 10.6364V12.5C13 12.7761 12.7761 13 12.5 13ZM6.64922 5.5C6.64922 5.03013 7.03013 4.64922 7.5 4.64922C7.96987 4.64922 8.35078 5.03013 8.35078 5.5C8.35078 5.96987 7.96987 6.35078 7.5 6.35078C7.03013 6.35078 6.64922 5.96987 6.64922 5.5ZM7.5 3.74922C6.53307 3.74922 5.74922 4.53307 5.74922 5.5C5.74922 6.46693 6.53307 7.25078 7.5 7.25078C8.46693 7.25078 9.25078 6.46693 9.25078 5.5C9.25078 4.53307 8.46693 3.74922 7.5 3.74922Z" fill="black"/> <rect x="1.5" y="3" width="13" height="10" rx="1.5" stroke="#333333"/>
<circle cx="5.5" cy="6.5" r="1" stroke="#333333"/>
<path d="M5 13L10.112 8.45603C10.4211 8.18126 10.8674 8.12513 11.235 8.31482L14.5 10" stroke="#333333"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 342 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 275 KiB

View File

@ -1,7 +0,0 @@
import React from 'react';
export function Colors() {
return <div></div>;
}
export default Colors;

View File

@ -1,13 +1,22 @@
import React, { useCallback, useState } from 'react'; import React, { useCallback, useState } from 'react';
import TextField from '@mui/material/TextField'; import TextField from '@mui/material/TextField';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { pattern } from '$app/utils/open_url';
import Button from '@mui/material/Button'; import Button from '@mui/material/Button';
export function EmbedLink({ onDone, onEscape }: { onDone?: (value: string) => void; onEscape?: () => void }) { const urlPattern = /^https?:\/\/.+/;
export function EmbedLink({
onDone,
onEscape,
defaultLink,
}: {
defaultLink?: string;
onDone?: (value: string) => void;
onEscape?: () => void;
}) {
const { t } = useTranslation(); const { t } = useTranslation();
const [value, setValue] = useState(''); const [value, setValue] = useState(defaultLink ?? '');
const [error, setError] = useState(false); const [error, setError] = useState(false);
const handleChange = useCallback( const handleChange = useCallback(
@ -15,7 +24,7 @@ export function EmbedLink({ onDone, onEscape }: { onDone?: (value: string) => vo
const value = e.target.value; const value = e.target.value;
setValue(value); setValue(value);
setError(!pattern.test(value)); setError(!urlPattern.test(value));
}, },
[setValue, setError] [setValue, setError]
); );

View File

@ -0,0 +1,123 @@
import React, { SyntheticEvent, useCallback, useState } from 'react';
import Popover, { PopoverProps } from '@mui/material/Popover';
import { TabPanel, ViewTab, ViewTabs } from '$app/components/database/components/tab_bar/ViewTabs';
import SwipeableViews from 'react-swipeable-views';
import { PopoverCommonProps } from '$app/components/editor/components/tools/popover';
export enum TAB_KEY {
Colors = 'colors',
UPLOAD = 'upload',
EMBED_LINK = 'embed_link',
UNSPLASH = 'unsplash',
}
export type TabOption = {
key: TAB_KEY;
label: string;
Component: React.ComponentType<{
onDone?: (value: string) => void;
onEscape?: () => void;
}>;
onDone?: (value: string) => void;
};
export function UploadTabs({
tabOptions,
popoverProps,
containerStyle,
}: {
containerStyle?: React.CSSProperties;
tabOptions: TabOption[];
popoverProps?: PopoverProps;
}) {
const [tabValue, setTabValue] = useState<TAB_KEY>(() => {
return tabOptions[0].key;
});
const handleTabChange = useCallback((_: SyntheticEvent, newValue: string) => {
setTabValue(newValue as TAB_KEY);
}, []);
const selectedIndex = tabOptions.findIndex((tab) => tab.key === tabValue);
const onKeyDown = useCallback(
(e: React.KeyboardEvent) => {
e.stopPropagation();
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
popoverProps?.onClose?.({}, 'escapeKeyDown');
}
if (e.key === 'Tab') {
e.preventDefault();
e.stopPropagation();
setTabValue((prev) => {
const currentIndex = tabOptions.findIndex((tab) => tab.key === prev);
let nextIndex = currentIndex + 1;
if (e.shiftKey) {
nextIndex = currentIndex - 1;
}
return tabOptions[nextIndex % tabOptions.length]?.key ?? tabOptions[0].key;
});
}
},
[popoverProps, tabOptions]
);
return (
<Popover
{...popoverProps}
{...PopoverCommonProps}
open={popoverProps?.open ?? false}
disableAutoFocus={false}
onKeyDown={onKeyDown}
PaperProps={{
style: {
padding: 0,
},
}}
>
<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;
return <ViewTab key={key} iconPosition='start' color='inherit' label={label} value={key} />;
})}
</ViewTabs>
<div className={'h-full w-full flex-1 overflow-y-auto overflow-x-hidden'}>
<SwipeableViews
slideStyle={{
overflow: 'hidden',
height: '100%',
}}
axis={'x'}
index={selectedIndex}
>
{tabOptions.map((tab, index) => {
const { key, Component, onDone } = tab;
return (
<TabPanel className={'flex h-full w-full flex-col'} key={key} index={index} value={selectedIndex}>
<Component onDone={onDone} onEscape={() => popoverProps?.onClose?.({}, 'escapeKeyDown')} />
</TabPanel>
);
})}
</SwipeableViews>
</div>
</div>
</Popover>
);
}

View File

@ -1,4 +1,4 @@
export * from './Unsplash'; export * from './Unsplash';
export * from './UploadImage'; export * from './UploadImage';
export * from './EmbedLink'; export * from './EmbedLink';
export * from './Colors'; export * from './UploadTabs';

View File

@ -1,33 +1,52 @@
import ViewIconGroup from '$app/components/_shared/view_title/ViewIconGroup'; import ViewIconGroup from '$app/components/_shared/view_title/ViewIconGroup';
import { PageIcon } from '$app_reducers/pages/slice'; import { PageCover, PageIcon } from '$app_reducers/pages/slice';
import ViewIcon from '$app/components/_shared/view_title/ViewIcon'; import ViewIcon from '$app/components/_shared/view_title/ViewIcon';
import { ViewCover } from '$app/components/_shared/view_title/cover';
function ViewBanner({ function ViewBanner({
icon, icon,
hover, hover,
onUpdateIcon, onUpdateIcon,
showCover,
cover,
onUpdateCover,
}: { }: {
icon?: PageIcon; icon?: PageIcon;
hover: boolean; hover: boolean;
onUpdateIcon: (icon: string) => void; onUpdateIcon: (icon: string) => void;
showCover: boolean;
cover?: PageCover;
onUpdateCover?: (cover?: PageCover) => void;
}) { }) {
return ( return (
<> <div className={'view-banner flex w-full flex-col'}>
<div {showCover && cover && <ViewCover cover={cover} onUpdateCover={onUpdateCover} />}
style={{
display: icon ? 'flex' : 'none', <div className={'relative min-h-[65px] px-16 pt-4'}>
}} <div
> style={{
<ViewIcon onUpdateIcon={onUpdateIcon} icon={icon} /> display: icon ? 'flex' : 'none',
position: cover ? 'absolute' : 'relative',
bottom: cover ? '24px' : 'auto',
}}
>
<ViewIcon onUpdateIcon={onUpdateIcon} icon={icon} />
</div>
<div
style={{
opacity: hover ? 1 : 0,
}}
>
<ViewIconGroup
icon={icon}
onUpdateIcon={onUpdateIcon}
showCover={showCover}
cover={cover}
onUpdateCover={onUpdateCover}
/>
</div>
</div> </div>
<div </div>
style={{
opacity: hover ? 1 : 0,
}}
>
<ViewIconGroup icon={icon} onUpdateIcon={onUpdateIcon} />
</div>
</>
); );
} }

View File

@ -29,7 +29,7 @@ function ViewIcon({ icon, onUpdateIcon }: { icon?: PageIcon; onUpdateIcon: (icon
if (!icon) return null; if (!icon) return null;
return ( return (
<> <>
<div className={`-ml-2 flex rounded p-2 hover:bg-content-blue-50`}> <div className={`view-icon -ml-2 flex rounded p-2`}>
<div onClick={onOpen} className={'h-full w-full cursor-pointer rounded text-6xl'}> <div onClick={onOpen} className={'h-full w-full cursor-pointer rounded text-6xl'}>
{icon.value} {icon.value}
</div> </div>

View File

@ -1,31 +1,42 @@
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { PageIcon } from '$app_reducers/pages/slice'; import { CoverType, PageCover, PageIcon } from '$app_reducers/pages/slice';
import React, { useCallback } from 'react'; import React, { useCallback } from 'react';
import { randomEmoji } from '$app/utils/emoji'; import { randomEmoji } from '$app/utils/emoji';
import { EmojiEmotionsOutlined } from '@mui/icons-material'; import { EmojiEmotionsOutlined } from '@mui/icons-material';
import Button from '@mui/material/Button'; import Button from '@mui/material/Button';
import { ReactComponent as ImageIcon } from '$app/assets/image.svg';
import { ImageType } from '$app/application/document/document.types';
interface Props { interface Props {
icon?: PageIcon; icon?: PageIcon;
// onUpdateCover: (coverType: CoverType, cover: string) => void;
onUpdateIcon: (icon: string) => void; onUpdateIcon: (icon: string) => void;
showCover: boolean;
cover?: PageCover;
onUpdateCover?: (cover: PageCover) => void;
} }
function ViewIconGroup({ icon, onUpdateIcon }: Props) {
const defaultCover = {
cover_selection_type: CoverType.Asset,
cover_selection: 'app_flowy_abstract_cover_2.jpeg',
image_type: ImageType.Internal,
};
function ViewIconGroup({ icon, onUpdateIcon, showCover, cover, onUpdateCover }: Props) {
const { t } = useTranslation(); const { t } = useTranslation();
const showAddIcon = !icon?.value; const showAddIcon = !icon?.value;
const showAddCover = !cover && showCover;
const onAddIcon = useCallback(() => { const onAddIcon = useCallback(() => {
const emoji = randomEmoji(); const emoji = randomEmoji();
onUpdateIcon(emoji); onUpdateIcon(emoji);
}, [onUpdateIcon]); }, [onUpdateIcon]);
// const onAddCover = useCallback(() => { const onAddCover = useCallback(() => {
// const color = randomColor(); onUpdateCover?.(defaultCover);
// }, [onUpdateCover]);
// onUpdateCover(CoverType.Color, color);
// }, []);
return ( return (
<div className={'flex items-center py-2'}> <div className={'flex items-center py-2'}>
@ -34,11 +45,11 @@ function ViewIconGroup({ icon, onUpdateIcon }: Props) {
{t('document.plugins.cover.addIcon')} {t('document.plugins.cover.addIcon')}
</Button> </Button>
)} )}
{/*{showAddCover && (*/} {showAddCover && (
{/* <Button onClick={onAddCover} color={'inherit'} startIcon={<ImageOutlined />}>*/} <Button onClick={onAddCover} color={'inherit'} startIcon={<ImageIcon />}>
{/* {t('document.plugins.cover.addCover')}*/} {t('document.plugins.cover.addCover')}
{/* </Button>*/} </Button>
{/*)}*/} )}
</div> </div>
); );
} }

View File

@ -1,6 +1,6 @@
import React, { useCallback, useEffect, useState } from 'react'; import React, { useCallback, useEffect, useState } from 'react';
import ViewBanner from '$app/components/_shared/view_title/ViewBanner'; import ViewBanner from '$app/components/_shared/view_title/ViewBanner';
import { Page, PageIcon } from '$app_reducers/pages/slice'; import { Page, PageCover, PageIcon } from '$app_reducers/pages/slice';
import { ViewIconTypePB } from '@/services/backend'; import { ViewIconTypePB } from '@/services/backend';
import ViewTitleInput from '$app/components/_shared/view_title/ViewTitleInput'; import ViewTitleInput from '$app/components/_shared/view_title/ViewTitleInput';
@ -9,9 +9,20 @@ interface Props {
showTitle?: boolean; showTitle?: boolean;
onTitleChange?: (title: string) => void; onTitleChange?: (title: string) => void;
onUpdateIcon?: (icon: PageIcon) => void; onUpdateIcon?: (icon: PageIcon) => void;
forceHover?: boolean;
showCover?: boolean;
onUpdateCover?: (cover?: PageCover) => void;
} }
function ViewTitle({ view, onTitleChange, showTitle = true, onUpdateIcon: onUpdateIconProp }: Props) { function ViewTitle({
view,
forceHover = false,
onTitleChange,
showTitle = true,
onUpdateIcon: onUpdateIconProp,
showCover = false,
onUpdateCover,
}: Props) {
const [hover, setHover] = useState(false); const [hover, setHover] = useState(false);
const [icon, setIcon] = useState<PageIcon | undefined>(view.icon); const [icon, setIcon] = useState<PageIcon | undefined>(view.icon);
@ -38,7 +49,14 @@ function ViewTitle({ view, onTitleChange, showTitle = true, onUpdateIcon: onUpda
onMouseEnter={() => setHover(true)} onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)} onMouseLeave={() => setHover(false)}
> >
<ViewBanner icon={icon} hover={hover} onUpdateIcon={onUpdateIcon} /> <ViewBanner
showCover={showCover}
cover={view.cover}
icon={icon}
hover={hover || forceHover}
onUpdateIcon={onUpdateIcon}
onUpdateCover={onUpdateCover}
/>
{showTitle && ( {showTitle && (
<div className='relative'> <div className='relative'>
<ViewTitleInput value={view.name} onChange={onTitleChange} /> <ViewTitleInput value={view.name} onChange={onTitleChange} />

View File

@ -0,0 +1,21 @@
import React from 'react';
import { colorMap } from '$app/utils/color';
const colors = Object.entries(colorMap);
function Colors({ onDone }: { onDone?: (value: string) => void }) {
return (
<div className={'flex flex-wrap justify-center gap-2 p-2 pb-6'}>
{colors.map(([name, value]) => (
<div
key={name}
className={'h-9 w-9 cursor-pointer rounded rounded-full'}
style={{ backgroundColor: value }}
onClick={() => onDone?.(name)}
/>
))}
</div>
);
}
export default Colors;

View File

@ -0,0 +1,91 @@
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 { useTranslation } from 'react-i18next';
import Colors from '$app/components/_shared/view_title/cover/Colors';
import { ImageType } from '$app/application/document/document.types';
const initialOrigin: {
anchorOrigin: PopoverOrigin;
transformOrigin: PopoverOrigin;
} = {
anchorOrigin: {
vertical: 'bottom',
horizontal: 'center',
},
transformOrigin: {
vertical: 'top',
horizontal: 'center',
},
};
function CoverPopover({
anchorEl,
open,
onClose,
onUpdateCover,
}: {
anchorEl: HTMLElement | null;
open: boolean;
onClose: () => void;
onUpdateCover?: (cover?: PageCover) => void;
}) {
const { t } = useTranslation();
const tabOptions: TabOption[] = useMemo(() => {
return [
{
label: t('document.plugins.cover.colors'),
key: TAB_KEY.Colors,
Component: Colors,
onDone: (value: string) => {
onUpdateCover?.({
cover_selection_type: CoverType.Color,
cover_selection: value,
image_type: ImageType.Internal,
});
},
},
{
label: t('document.imageBlock.embedLink.label'),
key: TAB_KEY.EMBED_LINK,
Component: EmbedLink,
onDone: (value: string) => {
onUpdateCover?.({
cover_selection_type: CoverType.Image,
cover_selection: value,
image_type: ImageType.External,
});
onClose();
},
},
{
key: TAB_KEY.UNSPLASH,
label: t('document.imageBlock.unsplash.label'),
Component: Unsplash,
onDone: (value: string) => {
onUpdateCover?.({
cover_selection_type: CoverType.Image,
cover_selection: value,
image_type: ImageType.External,
});
},
},
];
}, [onClose, onUpdateCover, t]);
return (
<UploadTabs
popoverProps={{
anchorEl,
open,
onClose,
...initialOrigin,
}}
containerStyle={{ width: 433, maxHeight: 300 }}
tabOptions={tabOptions}
/>
);
}
export default CoverPopover;

View File

@ -0,0 +1,66 @@
import React, { useCallback, useMemo, useRef, useState } from 'react';
import { CoverType, PageCover } from '$app_reducers/pages/slice';
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';
export function ViewCover({ cover, onUpdateCover }: { cover: PageCover; onUpdateCover?: (cover?: PageCover) => void }) {
const { cover_selection_type: type, cover_selection: value = '' } = useMemo(() => cover || {}, [cover]);
const [showAction, setShowAction] = useState(false);
const actionRef = useRef<HTMLDivElement>(null);
const [showPopover, setShowPopover] = useState(false);
const renderCoverColor = useCallback((color: string) => {
return (
<div
style={{
backgroundColor: renderColor(color),
}}
className={`h-full w-full`}
/>
);
}, []);
const renderCoverImage = useCallback((url: string) => {
return <img draggable={false} src={url} alt={''} className={'h-full w-full object-cover'} />;
}, []);
const handleRemoveCover = useCallback(() => {
onUpdateCover?.(null);
}, [onUpdateCover]);
const handleClickChange = useCallback(() => {
setShowPopover(true);
}, []);
return (
<div
onMouseEnter={() => {
setShowAction(true);
}}
onMouseLeave={() => {
setShowAction(false);
}}
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}
<ViewCoverActions
show={showAction}
ref={actionRef}
onRemove={handleRemoveCover}
onClickChange={handleClickChange}
/>
{showPopover && (
<CoverPopover
open={showPopover}
onClose={() => setShowPopover(false)}
anchorEl={actionRef.current}
onUpdateCover={onUpdateCover}
/>
)}
</div>
);
}

View File

@ -0,0 +1,42 @@
import React, { forwardRef } from 'react';
import Button from '@mui/material/Button';
import { useTranslation } from 'react-i18next';
import { ReactComponent as DeleteIcon } from '$app/assets/delete.svg';
function ViewCoverActions(
{ show, onRemove, onClickChange }: { show: boolean; onRemove: () => void; onClickChange: () => void },
ref: React.ForwardedRef<HTMLDivElement>
) {
const { t } = useTranslation();
return (
<div ref={ref} className={`absolute ${show ? 'flex' : 'opacity-0'} bottom-4 right-10 items-center space-x-2 p-2`}>
<div className={'flex items-center space-x-2'}>
<Button
onClick={onClickChange}
className={'min-w-0 p-1.5'}
size={'small'}
variant={'contained'}
color={'inherit'}
>
{t('document.plugins.cover.changeCover')}
</Button>
<Button
variant={'contained'}
size={'small'}
className={'min-h-0 min-w-0 p-1.5'}
sx={{
'.MuiButton-startIcon': {
marginRight: '0px',
},
}}
onClick={onRemove}
color={'inherit'}
startIcon={<DeleteIcon />}
/>
</div>
</div>
);
}
export default forwardRef(ViewCoverActions);

View File

@ -0,0 +1 @@
export * from './ViewCover';

View File

@ -1,12 +1,14 @@
import React, { useCallback } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import Editor from '$app/components/editor/Editor'; import Editor from '$app/components/editor/Editor';
import { DocumentHeader } from 'src/appflowy_app/components/document/document_header'; import { DocumentHeader } from 'src/appflowy_app/components/document/document_header';
import { useAppDispatch, useAppSelector } from '$app/stores/store'; import { useAppDispatch, useAppSelector } from '$app/stores/store';
import { updatePageName } from '$app_reducers/pages/async_actions'; import { updatePageName } from '$app_reducers/pages/async_actions';
import { PageCover } from '$app_reducers/pages/slice';
export function Document({ id }: { id: string }) { export function Document({ id }: { id: string }) {
const page = useAppSelector((state) => state.pages.pageMap[id]); const page = useAppSelector((state) => state.pages.pageMap[id]);
const [cover, setCover] = useState<PageCover | undefined>(undefined);
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const onTitleChange = useCallback( const onTitleChange = useCallback(
@ -21,12 +23,29 @@ export function Document({ id }: { id: string }) {
[dispatch, id] [dispatch, id]
); );
const view = useMemo(() => {
return {
...page,
cover,
};
}, [page, cover]);
useEffect(() => {
return () => {
setCover(undefined);
};
}, [id]);
if (!page) return null; if (!page) return null;
return ( return (
<div className={'relative'}> <div className={'relative w-full'}>
<DocumentHeader page={page} /> <DocumentHeader onUpdateCover={setCover} page={view} />
<Editor id={id} onTitleChange={onTitleChange} title={page.name} /> <div className={'flex w-full justify-center'}>
<div className={'max-w-screen w-[964px] min-w-0'}>
<Editor id={id} cover={cover} onCoverChange={setCover} onTitleChange={onTitleChange} title={page.name} />
</div>
</div>
</div> </div>
); );
} }

View File

@ -1,15 +1,18 @@
import React, { memo, useCallback } from 'react'; import React, { memo, useCallback, useEffect, useRef, useState } from 'react';
import { Page, PageIcon } from '$app_reducers/pages/slice'; import { Page, PageCover, PageIcon } from '$app_reducers/pages/slice';
import ViewTitle from '$app/components/_shared/view_title/ViewTitle'; import ViewTitle from '$app/components/_shared/view_title/ViewTitle';
import { updatePageIcon } from '$app/application/folder/page.service'; import { updatePageIcon } from '$app/application/folder/page.service';
interface DocumentHeaderProps { interface DocumentHeaderProps {
page: Page; page: Page;
onUpdateCover: (cover?: PageCover) => void;
} }
export function DocumentHeader({ page }: DocumentHeaderProps) { export function DocumentHeader({ page, onUpdateCover }: DocumentHeaderProps) {
const pageId = page.id; const pageId = page.id;
const ref = useRef<HTMLDivElement>(null);
const [forceHover, setForceHover] = useState(false);
const onUpdateIcon = useCallback( const onUpdateIcon = useCallback(
async (icon: PageIcon) => { async (icon: PageIcon) => {
await updatePageIcon(pageId, icon.value ? icon : undefined); await updatePageIcon(pageId, icon.value ? icon : undefined);
@ -17,10 +20,39 @@ export function DocumentHeader({ page }: DocumentHeaderProps) {
[pageId] [pageId]
); );
useEffect(() => {
const parent = ref.current?.parentElement;
if (!parent) return;
const documentDom = parent.querySelector('.appflowy-editor') as HTMLElement;
if (!documentDom) return;
const handleMouseMove = (e: MouseEvent) => {
const isMoveInTitle = Boolean(e.target instanceof HTMLElement && e.target.closest('.document-title'));
const isMoveInHeader = Boolean(e.target instanceof HTMLElement && e.target.closest('.document-header'));
setForceHover(isMoveInTitle || isMoveInHeader);
};
documentDom.addEventListener('mousemove', handleMouseMove);
return () => {
documentDom.removeEventListener('mousemove', handleMouseMove);
};
}, []);
if (!page) return null; if (!page) return null;
return ( return (
<div className={'document-header select-none px-16 pt-4'}> <div ref={ref} className={'document-header select-none'}>
<ViewTitle showTitle={false} onUpdateIcon={onUpdateIcon} view={page} /> <ViewTitle
showCover
showTitle={false}
forceHover={forceHover}
onUpdateCover={onUpdateCover}
onUpdateIcon={onUpdateIcon}
view={page}
/>
</div> </div>
); );
} }

View File

@ -49,6 +49,12 @@ export function wrapFormula(editor: ReactEditor, formula?: string) {
Transforms.insertNodes(editor, formulaElement, { Transforms.insertNodes(editor, formulaElement, {
select: true, select: true,
}); });
const path = editor.selection?.anchor.path;
if (path) {
editor.select(path);
}
} }
export function unwrapFormula(editor: ReactEditor) { export function unwrapFormula(editor: ReactEditor) {

View File

@ -254,17 +254,23 @@ export const CustomEditor = {
}, },
insertMention(editor: ReactEditor, mention: Mention) { insertMention(editor: ReactEditor, mention: Mention) {
const mentionElement = { const mentionElement = [
type: EditorInlineNodeType.Mention, {
children: [{ text: '@' }], type: EditorInlineNodeType.Mention,
data: { children: [{ text: '$' }],
...mention, data: {
...mention,
},
}, },
}; ];
Transforms.insertNodes(editor, mentionElement, { Transforms.insertNodes(editor, mentionElement, {
select: true, select: true,
}); });
editor.collapse({
edge: 'end',
});
}, },
toggleTodo(editor: ReactEditor, node: TodoListNode) { toggleTodo(editor: ReactEditor, node: TodoListNode) {

View File

@ -9,10 +9,8 @@ export const Callout = memo(
<div contentEditable={false} className={'absolute w-full select-none px-2 pt-[15px]'}> <div contentEditable={false} className={'absolute w-full select-none px-2 pt-[15px]'}>
<CalloutIcon node={node} /> <CalloutIcon node={node} />
</div> </div>
<div {...attributes} ref={ref} className={`${attributes.className ?? ''} w-full bg-bg-body py-2`}> <div ref={ref} className={`${attributes.className ?? ''} w-full bg-bg-body py-2`}>
<div <div {...attributes} className={`flex w-full flex-col rounded bg-content-blue-50 py-2 pl-10`}>
className={`flex w-full flex-col rounded border border-solid border-line-divider bg-content-blue-50 py-2 pl-10`}
>
{children} {children}
</div> </div>
</div> </div>

View File

@ -24,9 +24,9 @@ export const ImageBlock = memo(
onClick={() => { onClick={() => {
if (!selected) onFocusNode(); if (!selected) onFocusNode();
}} }}
className={`${className} image-block relative w-full cursor-pointer py-1`} className={`${className} image-block relative w-full cursor-pointer py-1`}
> >
<div ref={ref} className={'absolute left-0 top-0 h-full w-full caret-transparent'}> <div ref={ref} className={'absolute left-0 top-0 h-full w-full select-none caret-transparent'}>
{children} {children}
</div> </div>
<div <div

View File

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

View File

@ -1,20 +1,13 @@
import React, { useCallback, useMemo, SyntheticEvent, useState } from 'react'; import React, { useMemo } from 'react';
import Popover, { PopoverOrigin } from '@mui/material/Popover/Popover'; import { PopoverOrigin } from '@mui/material/Popover/Popover';
import usePopoverAutoPosition from '$app/components/_shared/popover/Popover.hooks'; import usePopoverAutoPosition from '$app/components/_shared/popover/Popover.hooks';
import { PopoverCommonProps } from '$app/components/editor/components/tools/popover';
import { TabPanel, ViewTab, ViewTabs } from '$app/components/database/components/tab_bar/ViewTabs';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { EmbedLink, Unsplash } from '$app/components/_shared/image_upload'; import { EmbedLink, Unsplash, UploadTabs, TabOption, TAB_KEY } from '$app/components/_shared/image_upload';
import SwipeableViews from 'react-swipeable-views';
import { CustomEditor } from '$app/components/editor/command'; import { CustomEditor } from '$app/components/editor/command';
import { useSlateStatic } from 'slate-react'; import { useSlateStatic } from 'slate-react';
import { ImageNode, ImageType } from '$app/application/document/document.types'; import { ImageNode, ImageType } from '$app/application/document/document.types';
enum TAB_KEY {
UPLOAD = 'upload',
EMBED_LINK = 'embed_link',
UNSPLASH = 'unsplash',
}
const initialOrigin: { const initialOrigin: {
transformOrigin: PopoverOrigin; transformOrigin: PopoverOrigin;
anchorOrigin: PopoverOrigin; anchorOrigin: PopoverOrigin;
@ -53,7 +46,7 @@ function UploadPopover({
open, open,
}); });
const tabOptions = useMemo(() => { const tabOptions: TabOption[] = useMemo(() => {
return [ return [
// { // {
// label: t('button.upload'), // label: t('button.upload'),
@ -87,102 +80,25 @@ function UploadPopover({
]; ];
}, [editor, node, onClose, t]); }, [editor, node, onClose, t]);
const [tabValue, setTabValue] = useState<TAB_KEY>(tabOptions[0].key);
const handleTabChange = useCallback((_: SyntheticEvent, newValue: string) => {
setTabValue(newValue as TAB_KEY);
}, []);
const selectedIndex = tabOptions.findIndex((tab) => tab.key === tabValue);
const onKeyDown = useCallback(
(e: React.KeyboardEvent) => {
e.stopPropagation();
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
onClose();
}
if (e.key === 'Tab') {
e.preventDefault();
e.stopPropagation();
setTabValue((prev) => {
const currentIndex = tabOptions.findIndex((tab) => tab.key === prev);
const nextIndex = (currentIndex + 1) % tabOptions.length;
return tabOptions[nextIndex]?.key ?? tabOptions[0].key;
});
}
},
[onClose, tabOptions]
);
return ( return (
<Popover <UploadTabs
{...PopoverCommonProps} popoverProps={{
disableAutoFocus={false} anchorEl,
open={open && isEntered} open: open && isEntered,
anchorEl={anchorEl} onClose,
transformOrigin={transformOrigin} transformOrigin,
anchorOrigin={anchorOrigin} anchorOrigin,
onClose={onClose} onMouseDown: (e) => {
onMouseDown={(e) => { e.stopPropagation();
e.stopPropagation();
}}
onKeyDown={onKeyDown}
PaperProps={{
style: {
padding: 0,
}, },
}} }}
> containerStyle={{
<div maxWidth: paperWidth,
style={{ maxHeight: paperHeight,
maxWidth: paperWidth, overflow: 'hidden',
maxHeight: paperHeight, }}
overflow: 'hidden', tabOptions={tabOptions}
}} />
className={'flex flex-col gap-4'}
>
<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;
return <ViewTab key={key} iconPosition='start' color='inherit' label={label} value={key} />;
})}
</ViewTabs>
<div className={'h-full w-full flex-1 overflow-y-auto overflow-x-hidden'}>
<SwipeableViews
slideStyle={{
overflow: 'hidden',
height: '100%',
}}
axis={'x'}
index={selectedIndex}
>
{tabOptions.map((tab, index) => {
const { key, Component, onDone } = tab;
return (
<TabPanel className={'flex h-full w-full flex-col'} key={key} index={index} value={selectedIndex}>
<Component onDone={onDone} onEscape={onClose} />
</TabPanel>
);
})}
</SwipeableViews>
</div>
</div>
</Popover>
); );
} }

View File

@ -4,7 +4,7 @@ import { EditorElementProps, PageNode } from '$app/application/document/document
export const Page = memo( export const Page = memo(
forwardRef<HTMLDivElement, EditorElementProps<PageNode>>(({ node: _, children, ...attributes }, ref) => { forwardRef<HTMLDivElement, EditorElementProps<PageNode>>(({ node: _, children, ...attributes }, ref) => {
const className = useMemo(() => { const className = useMemo(() => {
return `${attributes.className ?? ''} pb-3 text-4xl font-bold`; return `${attributes.className ?? ''} document-title pb-3 text-4xl font-bold`;
}, [attributes.className]); }, [attributes.className]);
return ( return (

View File

@ -5,62 +5,87 @@ import { EditorProps } from '$app/application/document/document.types';
import { Provider } from '$app/components/editor/provider'; import { Provider } from '$app/components/editor/provider';
import { YXmlText } from 'yjs/dist/src/types/YXmlText'; import { YXmlText } from 'yjs/dist/src/types/YXmlText';
import { getInsertTarget, getYTarget } from '$app/components/editor/provider/utils/relation'; import { getInsertTarget, getYTarget } from '$app/components/editor/provider/utils/relation';
import isEqual from 'lodash-es/isEqual';
export const CollaborativeEditor = memo(({ id, title, showTitle = true, onTitleChange, disableFocus }: EditorProps) => { export const CollaborativeEditor = memo(
const [sharedType, setSharedType] = useState<YXmlText | null>(null); ({ id, title, cover, showTitle = true, onTitleChange, onCoverChange, ...props }: EditorProps) => {
const provider = useMemo(() => { const [sharedType, setSharedType] = useState<YXmlText | null>(null);
setSharedType(null); const provider = useMemo(() => {
return new Provider(id, showTitle); setSharedType(null);
}, [id, showTitle]); return new Provider(id, showTitle);
}, [id, showTitle]);
const root = useMemo(() => { const root = useMemo(() => {
if (!showTitle || !sharedType || !sharedType.doc) return null; if (!showTitle || !sharedType || !sharedType.doc) return null;
return getYTarget(sharedType?.doc, [0]); return getYTarget(sharedType?.doc, [0]);
}, [sharedType, showTitle]); }, [sharedType, showTitle]);
const rootText = useMemo(() => { const rootText = useMemo(() => {
if (!root) return null; if (!root) return null;
return getInsertTarget(root, [0]); return getInsertTarget(root, [0]);
}, [root]); }, [root]);
useEffect(() => { useEffect(() => {
if (!rootText || rootText.toString() === title) return; if (!rootText || rootText.toString() === title) return;
if (rootText.length > 0) { if (rootText.length > 0) {
rootText.delete(0, rootText.length); rootText.delete(0, rootText.length);
}
rootText.insert(0, title || '');
}, [title, rootText]);
useEffect(() => {
if (!root) return;
const originalCover = root.getAttribute('data')?.cover;
if (cover === undefined) return;
if (isEqual(originalCover, cover)) return;
root.setAttribute('data', { cover: cover ? cover : undefined });
}, [cover, root]);
useEffect(() => {
if (!root) return;
const rootId = root.getAttribute('blockId');
if (!rootId) return;
const getCover = () => {
const data = root.getAttribute('data');
onCoverChange?.(data?.cover);
};
getCover();
const onChange = () => {
onTitleChange?.(root.toString());
getCover();
};
root.observeDeep(onChange);
return () => root.unobserveDeep(onChange);
}, [onTitleChange, root, onCoverChange]);
useEffect(() => {
provider.connect();
const handleConnected = () => {
setSharedType(provider.sharedType);
};
provider.on('ready', handleConnected);
return () => {
setSharedType(null);
provider.off('ready', handleConnected);
provider.disconnect();
};
}, [provider]);
if (!sharedType || id !== provider.id) {
return null;
} }
rootText.insert(0, title || ''); return <Editor sharedType={sharedType} id={id} {...props} />;
}, [title, rootText]);
useEffect(() => {
if (!root) return;
const onChange = () => {
onTitleChange?.(root.toString());
};
root.observeDeep(onChange);
return () => root.unobserveDeep(onChange);
}, [onTitleChange, root]);
useEffect(() => {
provider.connect();
const handleConnected = () => {
setSharedType(provider.sharedType);
};
provider.on('ready', handleConnected);
return () => {
setSharedType(null);
provider.off('ready', handleConnected);
provider.disconnect();
};
}, [provider]);
if (!sharedType || id !== provider.id) {
return null;
} }
);
return <Editor sharedType={sharedType} id={id} disableFocus={disableFocus} />;
});

View File

@ -11,7 +11,6 @@ import { useShortcuts } from 'src/appflowy_app/components/editor/plugins/shortcu
import { BlockActionsToolbar } from '$app/components/editor/components/tools/block_actions'; import { BlockActionsToolbar } from '$app/components/editor/components/tools/block_actions';
import { CircularProgress } from '@mui/material'; import { CircularProgress } from '@mui/material';
import * as Y from 'yjs';
import { NodeEntry } from 'slate'; import { NodeEntry } from 'slate';
import { import {
DecorateStateProvider, DecorateStateProvider,
@ -22,8 +21,9 @@ import {
} from '$app/components/editor/stores'; } from '$app/components/editor/stores';
import CommandPanel from '../tools/command_panel/CommandPanel'; import CommandPanel from '../tools/command_panel/CommandPanel';
import { EditorBlockStateProvider } from '$app/components/editor/stores/block'; import { EditorBlockStateProvider } from '$app/components/editor/stores/block';
import { LocalEditorProps } from '$app/application/document/document.types';
function Editor({ sharedType, disableFocus }: { sharedType: Y.XmlText; id: string; disableFocus?: boolean }) { function Editor({ sharedType, disableFocus, caretColor = 'var(--text-title)' }: LocalEditorProps) {
const { editor, initialValue, handleOnClickEnd, ...props } = useEditor(sharedType); const { editor, initialValue, handleOnClickEnd, ...props } = useEditor(sharedType);
const decorateCodeHighlight = useDecorateCodeHighlight(editor); const decorateCodeHighlight = useDecorateCodeHighlight(editor);
const { onKeyDown: onShortcutsKeyDown } = useShortcuts(editor); const { onKeyDown: onShortcutsKeyDown } = useShortcuts(editor);
@ -74,7 +74,10 @@ function Editor({ sharedType, disableFocus }: { sharedType: Y.XmlText; id: strin
disableFocus={disableFocus} disableFocus={disableFocus}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
decorate={decorate} decorate={decorate}
className={'px-16 caret-text-title outline-none focus:outline-none'} style={{
caretColor,
}}
className={`px-16 outline-none focus:outline-none`}
/> />
<CommandPanel /> <CommandPanel />
<div onClick={handleOnClickEnd} className={'relative bottom-0 left-0 h-10 w-full cursor-text'} /> <div onClick={handleOnClickEnd} className={'relative bottom-0 left-0 h-10 w-full cursor-text'} />

View File

@ -27,6 +27,7 @@ import { Text as TextComponent } from '../blocks/text';
import { Page } from '../blocks/page'; import { Page } from '../blocks/page';
import { useElementState } from '$app/components/editor/components/editor/Element.hooks'; import { useElementState } from '$app/components/editor/components/editor/Element.hooks';
import UnSupportBlock from '$app/components/editor/components/blocks/_shared/unSupportBlock'; import UnSupportBlock from '$app/components/editor/components/blocks/_shared/unSupportBlock';
import { renderColor } from '$app/utils/color';
function Element({ element, attributes, children }: RenderElementProps) { function Element({ element, attributes, children }: RenderElementProps) {
const node = element; const node = element;
@ -98,8 +99,8 @@ function Element({ element, attributes, children }: RenderElementProps) {
const data = (node.data as BlockData) || {}; const data = (node.data as BlockData) || {};
return { return {
backgroundColor: data.bg_color, backgroundColor: data.bg_color ? renderColor(data.bg_color) : undefined,
color: data.font_color, color: data.font_color ? renderColor(data.font_color) : undefined,
}; };
}, [node.data]); }, [node.data]);

View File

@ -1,6 +1,7 @@
import React, { CSSProperties } from 'react'; import React, { CSSProperties } from 'react';
import { RenderLeafProps } from 'slate-react'; import { RenderLeafProps } from 'slate-react';
import { Link } from '$app/components/editor/components/inline_nodes/link'; import { Link } from '$app/components/editor/components/inline_nodes/link';
import { renderColor } from '$app/utils/color';
export function Leaf({ attributes, children, leaf }: RenderLeafProps) { export function Leaf({ attributes, children, leaf }: RenderLeafProps) {
let newChildren = children; let newChildren = children;
@ -39,11 +40,11 @@ export function Leaf({ attributes, children, leaf }: RenderLeafProps) {
const style: CSSProperties = {}; const style: CSSProperties = {};
if (leaf.font_color) { if (leaf.font_color) {
style['color'] = leaf.font_color.replace('0x', '#'); style['color'] = renderColor(leaf.font_color);
} }
if (leaf.bg_color) { if (leaf.bg_color) {
style['backgroundColor'] = leaf.bg_color.replace('0x', '#'); style['backgroundColor'] = renderColor(leaf.bg_color);
} }
if (leaf.href) { if (leaf.href) {

View File

@ -3,10 +3,10 @@ import React from 'react';
// Put this at the start and end of an inline component to work around this Chromium bug: // Put this at the start and end of an inline component to work around this Chromium bug:
// https://bugs.chromium.org/p/chromium/issues/detail?id=1249405 // https://bugs.chromium.org/p/chromium/issues/detail?id=1249405
export const InlineChromiumBugfix = () => ( export const InlineChromiumBugfix = ({ className }: { className?: string }) => (
<span <span
contentEditable={false} contentEditable={false}
className={'absolute caret-transparent'} className={`absolute caret-transparent ${className ?? ''}`}
style={{ style={{
fontSize: 0, fontSize: 0,
}} }}

View File

@ -1,6 +1,6 @@
import React, { forwardRef, memo, useCallback, MouseEvent, useRef } from 'react'; import React, { forwardRef, memo, useCallback, MouseEvent, useRef, useEffect } from 'react';
import { ReactEditor, useSelected, useSlate } from 'slate-react'; import { ReactEditor, useSelected, useSlate } from 'slate-react';
import { Transforms } from 'slate'; import { Editor, Range, Transforms } from 'slate';
import { EditorElementProps, FormulaNode } from '$app/application/document/document.types'; import { EditorElementProps, FormulaNode } from '$app/application/document/document.types';
import FormulaLeaf from '$app/components/editor/components/inline_nodes/inline_formula/FormulaLeaf'; import FormulaLeaf from '$app/components/editor/components/inline_nodes/inline_formula/FormulaLeaf';
import FormulaEditPopover from '$app/components/editor/components/inline_nodes/inline_formula/FormulaEditPopover'; import FormulaEditPopover from '$app/components/editor/components/inline_nodes/inline_formula/FormulaEditPopover';
@ -18,17 +18,27 @@ export const InlineFormula = memo(
const selected = useSelected(); const selected = useSelected();
const open = Boolean(popoverOpen && selected); const open = Boolean(popoverOpen && selected);
const isCollapsed = editor.selection && Range.isCollapsed(editor.selection);
useEffect(() => {
if (selected && isCollapsed && !open) {
const afterPoint = editor.selection ? editor.after(editor.selection) : undefined;
const afterStart = afterPoint ? Editor.start(editor, afterPoint) : undefined;
if (afterStart) {
editor.select(afterStart);
}
}
}, [editor, isCollapsed, selected, open]);
const handleClick = useCallback( const handleClick = useCallback(
(e: MouseEvent<HTMLSpanElement>) => { (e: MouseEvent<HTMLSpanElement>) => {
const target = e.currentTarget; const target = e.currentTarget;
const path = getNodePath(editor, target); const path = getNodePath(editor, target);
ReactEditor.focus(editor); setRange(path);
Transforms.select(editor, path); openPopover();
if (editor.selection) {
setRange(editor.selection);
openPopover();
}
}, },
[editor, openPopover, setRange] [editor, openPopover, setRange]
); );
@ -103,9 +113,9 @@ export const InlineFormula = memo(
selected ? 'selected' : '' selected ? 'selected' : ''
}`} }`}
> >
<InlineChromiumBugfix /> <InlineChromiumBugfix className={'left-0'} />
<FormulaLeaf formula={formula}>{children}</FormulaLeaf> <FormulaLeaf formula={formula}>{children}</FormulaLeaf>
<InlineChromiumBugfix /> <InlineChromiumBugfix className={'right-0'} />
</span> </span>
{open && ( {open && (
<FormulaEditPopover <FormulaEditPopover

View File

@ -7,13 +7,12 @@ import { InlineChromiumBugfix } from '$app/components/editor/components/inline_n
export const Mention = memo( export const Mention = memo(
forwardRef<HTMLSpanElement, EditorElementProps<MentionNode>>(({ node, children, ...attributes }, ref) => { forwardRef<HTMLSpanElement, EditorElementProps<MentionNode>>(({ node, children, ...attributes }, ref) => {
return ( return (
<> <span {...attributes} contentEditable={false} className={`relative cursor-pointer`} ref={ref}>
<span {...attributes} contentEditable={false} ref={ref}> <InlineChromiumBugfix className={'left-0'} />
<InlineChromiumBugfix /> <span className={'absolute right-0 top-0 h-full w-0 opacity-0'}>{children}</span>
<MentionLeaf mention={node.data}>{children}</MentionLeaf> <MentionLeaf mention={node.data} />
<InlineChromiumBugfix /> <InlineChromiumBugfix className={'right-0'} />
</span> </span>
</>
); );
}) })
); );

View File

@ -5,23 +5,44 @@ import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { pageTypeMap } from '$app_reducers/pages/slice'; import { pageTypeMap } from '$app_reducers/pages/slice';
import { getPage } from '$app/application/folder/page.service'; import { getPage } from '$app/application/folder/page.service';
import { useSelected } from 'slate-react'; import { useSelected, useSlate } from 'slate-react';
import { ReactComponent as EyeClose } from '$app/assets/eye_close.svg'; import { ReactComponent as EyeClose } from '$app/assets/eye_close.svg';
import { notify } from 'src/appflowy_app/components/_shared/notify'; import { notify } from 'src/appflowy_app/components/_shared/notify';
import { subscribeNotifications } from '$app/application/notification'; import { subscribeNotifications } from '$app/application/notification';
import { FolderNotification } from '@/services/backend'; import { FolderNotification } from '@/services/backend';
import { Editor, Range } from 'slate';
export function MentionLeaf({ children, mention }: { mention: Mention; children: React.ReactNode }) { export function MentionLeaf({ mention }: { mention: Mention }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [page, setPage] = useState<MentionPage | null>(null); const [page, setPage] = useState<MentionPage | null>(null);
const [error, setError] = useState<boolean>(false); const [error, setError] = useState<boolean>(false);
const navigate = useNavigate(); const navigate = useNavigate();
const editor = useSlate();
const selected = useSelected(); const selected = useSelected();
const isCollapsed = editor.selection && Range.isCollapsed(editor.selection);
useEffect(() => {
if (selected && isCollapsed && page) {
const afterPoint = editor.selection ? editor.after(editor.selection) : undefined;
const afterStart = afterPoint ? Editor.start(editor, afterPoint) : undefined;
if (afterStart) {
editor.select(afterStart);
}
}
}, [editor, isCollapsed, selected, page]);
const loadPage = useCallback(async () => { const loadPage = useCallback(async () => {
setError(true); setError(true);
if (!mention.page) return; // keep old field for backward compatibility
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
const pageId = mention.page_id ?? mention.page;
if (!pageId) return;
try { try {
const page = await getPage(mention.page); const page = await getPage(pageId);
setPage(page); setPage(page);
setError(false); setError(false);
@ -29,7 +50,7 @@ export function MentionLeaf({ children, mention }: { mention: Mention; children:
setPage(null); setPage(null);
setError(true); setError(true);
} }
}, [mention.page]); }, [mention]);
useEffect(() => { useEffect(() => {
void loadPage(); void loadPage();
@ -94,31 +115,27 @@ export function MentionLeaf({ children, mention }: { mention: Mention; children:
}, [page]); }, [page]);
return ( return (
<span className={'relative'}> <span
<span className={`mention-inline mx-1 inline-flex select-none items-center gap-1`}
className={'relative mx-1 inline-flex cursor-pointer items-center hover:rounded hover:bg-content-blue-100'} onClick={openPage}
onClick={openPage} contentEditable={false}
style={{ style={{
backgroundColor: selected ? 'var(--content-blue-100)' : undefined, backgroundColor: selected ? 'var(--content-blue-100)' : undefined,
}} }}
> >
{page && ( {error ? (
<>
<EyeClose />
<span className={'mr-0.5 text-text-caption underline'}>{t('document.mention.deleted')}</span>
</>
) : (
page && (
<> <>
<span className={'text-sx absolute left-0.5'}>{page.icon?.value || <DocumentSvg />}</span> {page.icon?.value || <DocumentSvg />}
<span className={'ml-6 mr-0.5 underline'}>{page.name || t('document.title.placeholder')}</span> <span className={'mr-1 underline'}>{page.name || t('document.title.placeholder')}</span>
</> </>
)} )
{error && ( )}
<>
<span className={'text-sx absolute left-0.5'}>
<EyeClose />
</span>
<span className={'ml-6 mr-0.5 text-text-caption underline'}>{t('document.mention.deleted')}</span>
</>
)}
</span>
<span className={'invisible'}>{children}</span>
</span> </span>
); );
} }

View File

@ -6,6 +6,7 @@ import KeyboardNavigation, {
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { TitleOutlined } from '@mui/icons-material'; import { TitleOutlined } from '@mui/icons-material';
import { EditorMarkFormat } from '$app/application/document/document.types'; import { EditorMarkFormat } from '$app/application/document/document.types';
import { ColorEnum, renderColor } from '$app/utils/color';
export interface ColorPickerProps { export interface ColorPickerProps {
onChange?: (format: EditorMarkFormat.FontColor | EditorMarkFormat.BgColor, color: string) => void; onChange?: (format: EditorMarkFormat.FontColor | EditorMarkFormat.BgColor, color: string) => void;
@ -39,8 +40,8 @@ export function ColorPicker({ onEscape, onChange, disableFocus }: ColorPickerPro
> >
<div <div
style={{ style={{
backgroundColor: backgroundColor ?? 'transparent', backgroundColor: backgroundColor ? renderColor(backgroundColor) : 'transparent',
color: color === '' ? 'var(--text-title)' : color, color: color === '' ? 'var(--text-title)' : renderColor(color),
}} }}
className={'flex h-5 w-5 items-center justify-center rounded border border-line-divider'} className={'flex h-5 w-5 items-center justify-center rounded border border-line-divider'}
> >
@ -118,40 +119,40 @@ export function ColorPicker({ onEscape, onChange, disableFocus }: ColorPickerPro
content: renderColorItem(t('editor.backgroundColorDefault'), '', ''), content: renderColorItem(t('editor.backgroundColorDefault'), '', ''),
}, },
{ {
key: `bg-gray-rgba(161,161,159,0.61)`, key: `bg-lime-${ColorEnum.Lime}`,
content: renderColorItem(t('editor.backgroundColorGray'), '', 'rgba(161,161,159,0.61)'), content: renderColorItem(t('editor.backgroundColorLime'), '', ColorEnum.Lime),
}, },
{ {
key: `bg-brown-rgba(178,93,37,0.65)`, key: `bg-aqua-${ColorEnum.Aqua}`,
content: renderColorItem(t('editor.backgroundColorBrown'), '', 'rgba(178,93,37,0.65)'), content: renderColorItem(t('editor.backgroundColorAqua'), '', ColorEnum.Aqua),
}, },
{ {
key: `bg-orange-rgba(248,156,71,0.65)`, key: `bg-orange-${ColorEnum.Orange}`,
content: renderColorItem(t('editor.backgroundColorOrange'), '', 'rgba(248,156,71,0.65)'), content: renderColorItem(t('editor.backgroundColorOrange'), '', ColorEnum.Orange),
}, },
{ {
key: `bg-yellow-rgba(229,197,137,0.6)`, key: `bg-yellow-${ColorEnum.Yellow}`,
content: renderColorItem(t('editor.backgroundColorYellow'), '', 'rgba(229,197,137,0.6)'), content: renderColorItem(t('editor.backgroundColorYellow'), '', ColorEnum.Yellow),
}, },
{ {
key: `bg-green-rgba(124,189,111,0.65)`, key: `bg-green-${ColorEnum.Green}`,
content: renderColorItem(t('editor.backgroundColorGreen'), '', 'rgba(124,189,111,0.65)'), content: renderColorItem(t('editor.backgroundColorGreen'), '', ColorEnum.Green),
}, },
{ {
key: `bg-blue-rgba(100,174,199,0.71)`, key: `bg-blue-${ColorEnum.Blue}`,
content: renderColorItem(t('editor.backgroundColorBlue'), '', 'rgba(100,174,199,0.71)'), content: renderColorItem(t('editor.backgroundColorBlue'), '', ColorEnum.Blue),
}, },
{ {
key: `bg-purple-rgba(182,114,234,0.63)`, key: `bg-purple-${ColorEnum.Purple}`,
content: renderColorItem(t('editor.backgroundColorPurple'), '', 'rgba(182,114,234,0.63)'), content: renderColorItem(t('editor.backgroundColorPurple'), '', ColorEnum.Purple),
}, },
{ {
key: `bg-pink-rgba(238,142,179,0.6)`, key: `bg-pink-${ColorEnum.Pink}`,
content: renderColorItem(t('editor.backgroundColorPink'), '', 'rgba(238,142,179,0.6)'), content: renderColorItem(t('editor.backgroundColorPink'), '', ColorEnum.Pink),
}, },
{ {
key: `bg-red-rgba(238,88,98,0.64)`, key: `bg-red-${ColorEnum.LightPink}`,
content: renderColorItem(t('editor.backgroundColorRed'), '', 'rgba(238,88,98,0.64)'), content: renderColorItem(t('editor.backgroundColorRed'), '', ColorEnum.LightPink),
}, },
], ],
}, },

View File

@ -26,6 +26,7 @@ export const canSetColorBlocks: EditorNodeType[] = [
EditorNodeType.NumberedListBlock, EditorNodeType.NumberedListBlock,
EditorNodeType.ToggleListBlock, EditorNodeType.ToggleListBlock,
EditorNodeType.QuoteBlock, EditorNodeType.QuoteBlock,
EditorNodeType.CalloutBlock,
]; ];
export function BlockOperationMenu({ export function BlockOperationMenu({

View File

@ -52,7 +52,8 @@ export function useMentionPanel({
closePanel(true); closePanel(true);
CustomEditor.insertMention(editor, { CustomEditor.insertMention(editor, {
page: id, page_id: id,
type: MentionType.PageRef,
}); });
}, },
[closePanel, editor] [closePanel, editor]

View File

@ -17,7 +17,6 @@ import { ReactComponent as GridIcon } from '$app/assets/grid.svg';
import { ReactComponent as ImageIcon } from '$app/assets/image.svg'; import { ReactComponent as ImageIcon } from '$app/assets/image.svg';
import { DataObjectOutlined, FunctionsOutlined, HorizontalRuleOutlined, MenuBookOutlined } from '@mui/icons-material'; import { DataObjectOutlined, FunctionsOutlined, HorizontalRuleOutlined, MenuBookOutlined } from '@mui/icons-material';
import { CustomEditor } from '$app/components/editor/command'; import { CustomEditor } from '$app/components/editor/command';
import { randomEmoji } from '$app/utils/emoji';
import { KeyboardNavigationOption } from '$app/components/_shared/keyboard_navigation/KeyboardNavigation'; import { KeyboardNavigationOption } from '$app/components/_shared/keyboard_navigation/KeyboardNavigation';
import { YjsEditor } from '@slate-yjs/core'; import { YjsEditor } from '@slate-yjs/core';
import { useEditorBlockDispatch } from '$app/components/editor/stores/block'; import { useEditorBlockDispatch } from '$app/components/editor/stores/block';
@ -133,7 +132,7 @@ export function useSlashCommandPanel({
if (nodeType === EditorNodeType.CalloutBlock) { if (nodeType === EditorNodeType.CalloutBlock) {
Object.assign(data, { Object.assign(data, {
icon: randomEmoji(), icon: '📌',
}); });
} }

View File

@ -27,6 +27,8 @@ export function Formula() {
} }
requestAnimationFrame(() => { requestAnimationFrame(() => {
const selection = editor.selection;
if (!selection) return; if (!selection) return;
setRange(selection); setRange(selection);

View File

@ -83,7 +83,7 @@ span[data-slate-placeholder="true"]:not(.inline-block-content) {
} }
.text-content { .text-content, [data-dark-mode="true"] .text-content {
&.empty-content { &.empty-content {
@apply min-w-[1px]; @apply min-w-[1px];
span { span {
@ -94,7 +94,7 @@ span[data-slate-placeholder="true"]:not(.inline-block-content) {
} }
} }
.text-element:has(.text-placeholder), .divider-node { .text-element:has(.text-placeholder), .divider-node, [data-dark-mode="true"] .text-element:has(.text-placeholder), [data-dark-mode="true"] .divider-node {
::selection { ::selection {
@apply bg-transparent; @apply bg-transparent;
} }
@ -151,8 +151,14 @@ span[data-slate-placeholder="true"]:not(.inline-block-content) {
} }
} }
.image-block, .math-equation-block { .image-block, .math-equation-block, [data-dark-mode="true"] .image-block, [data-dark-mode="true"] .math-equation-block {
::selection { ::selection {
@apply bg-transparent; @apply bg-transparent;
} }
}
.mention-inline {
&:hover {
@apply bg-fill-list-active rounded;
}
} }

View File

@ -28,8 +28,11 @@ export class Provider extends EventEmitter {
sharedType.applyDelta(delta); sharedType.applyDelta(delta);
const rootId = this.dataClient.rootId as string; const rootId = this.dataClient.rootId as string;
const root = delta[0].insert as Y.XmlText;
const data = root.getAttribute('data');
sharedType.setAttribute('blockId', rootId); sharedType.setAttribute('blockId', rootId);
sharedType.setAttribute('data', data);
this.sharedType = sharedType; this.sharedType = sharedType;
this.sharedType?.observeDeep(this.onChange); this.sharedType?.observeDeep(this.onChange);

View File

@ -7,76 +7,90 @@ export function generateId() {
return nanoid(10); return nanoid(10);
} }
export function transformToInlineElement(op: Op): Element | null { export function transformToInlineElement(op: Op): Element[] {
const attributes = op.attributes; const attributes = op.attributes;
if (!attributes) return null; if (!attributes) return [];
const { formula, mention, ...attrs } = attributes; const { formula, mention, ...attrs } = attributes;
if (formula) { if (formula) {
return { const texts = (op.insert as string).split('');
type: EditorInlineNodeType.Formula,
data: formula, return texts.map((text) => {
children: [ return {
{ type: EditorInlineNodeType.Formula,
text: op.insert as string, data: formula,
...attrs, children: [
}, {
], text,
}; ...attrs,
},
],
};
});
} }
if (mention) { if (mention) {
return { const texts = (op.insert as string).split('');
type: EditorInlineNodeType.Mention,
children: [ return texts.map((text) => {
{ return {
text: op.insert as string, type: EditorInlineNodeType.Mention,
...attrs, children: [
{
text,
...attrs,
},
],
data: {
...(mention as Mention),
}, },
], };
data: { });
...(mention as Mention),
},
};
} }
return null; return [];
} }
export function getInlinesWithDelta(delta?: Op[]): (Text | Element)[] { export function getInlinesWithDelta(delta?: Op[]): (Text | Element)[] {
return delta && delta.length > 0 const newDelta: (Text | Element)[] = [];
? delta.map((op) => {
const matchInline = transformToInlineElement(op);
if (matchInline) { if (!delta || !delta.length)
return matchInline; return [
} {
text: '',
},
];
if (op.attributes) { delta.forEach((op) => {
if ('font_color' in op.attributes && op.attributes['font_color'] === '') { const matchInlines = transformToInlineElement(op);
delete op.attributes['font_color'];
}
if ('bg_color' in op.attributes && op.attributes['bg_color'] === '') { if (matchInlines.length > 0) {
delete op.attributes['bg_color']; newDelta.push(...matchInlines);
} return;
}
if ('code' in op.attributes && !op.attributes['code']) { if (op.attributes) {
delete op.attributes['code']; if ('font_color' in op.attributes && op.attributes['font_color'] === '') {
} delete op.attributes['font_color'];
} }
return { if ('bg_color' in op.attributes && op.attributes['bg_color'] === '') {
text: op.insert as string, delete op.attributes['bg_color'];
...op.attributes, }
};
}) if ('code' in op.attributes && !op.attributes['code']) {
: [ delete op.attributes['code'];
{ }
text: '', }
},
]; newDelta.push({
text: op.insert as string,
...op.attributes,
});
});
return newDelta;
} }
export function convertToSlateValue(data: EditorData, includeRoot: boolean): Element[] { export function convertToSlateValue(data: EditorData, includeRoot: boolean): Element[] {

View File

@ -1,12 +1,31 @@
* {
margin: 0;
padding: 0;
}
.appflowy-scroll-container {
&::-webkit-scrollbar {
width: 0px;
}
}
.workspaces { .workspaces {
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 0px; width: 0px;
} }
} }
.MuiPopover-root, .MuiPaper-root { .MuiPopover-root, .MuiPaper-root {
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 0; width: 0;
height: 0; height: 0;
} }
}
.view-icon {
&:hover {
background-color: rgba(156, 156, 156, 0.20);
}
} }

View File

@ -1,6 +1,8 @@
import { ViewIconTypePB, ViewLayoutPB, ViewPB } from '@/services/backend'; import { ViewIconTypePB, ViewLayoutPB, ViewPB } from '@/services/backend';
import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import isEqual from 'lodash-es/isEqual'; import isEqual from 'lodash-es/isEqual';
import { ImageType } from '$app/application/document/document.types';
import { Nullable } from 'unsplash-js/dist/helpers/typescript';
export const pageTypeMap = { export const pageTypeMap = {
[ViewLayoutPB.Document]: 'document', [ViewLayoutPB.Document]: 'document',
@ -14,6 +16,7 @@ export interface Page {
name: string; name: string;
layout: ViewLayoutPB; layout: ViewLayoutPB;
icon?: PageIcon; icon?: PageIcon;
cover?: PageCover;
} }
export interface PageIcon { export interface PageIcon {
@ -21,6 +24,17 @@ export interface PageIcon {
value: string; value: string;
} }
export enum CoverType {
Color = 'CoverType.color',
Image = 'CoverType.file',
Asset = 'CoverType.asset',
}
export type PageCover = Nullable<{
image_type?: ImageType;
cover_selection_type?: CoverType;
cover_selection?: string;
}>;
export function parserViewPBToPage(view: ViewPB): Page { export function parserViewPBToPage(view: ViewPB): Page {
const icon = view.icon; const icon = view.icon;

View File

@ -0,0 +1,31 @@
export enum ColorEnum {
Purple = 'appflowy_them_color_tint1',
Pink = 'appflowy_them_color_tint2',
LightPink = 'appflowy_them_color_tint3',
Orange = 'appflowy_them_color_tint4',
Yellow = 'appflowy_them_color_tint5',
Lime = 'appflowy_them_color_tint6',
Green = 'appflowy_them_color_tint7',
Aqua = 'appflowy_them_color_tint8',
Blue = 'appflowy_them_color_tint9',
}
export const colorMap = {
[ColorEnum.Purple]: 'var(--tint-purple)',
[ColorEnum.Pink]: 'var(--tint-pink)',
[ColorEnum.LightPink]: 'var(--tint-red)',
[ColorEnum.Orange]: 'var(--tint-orange)',
[ColorEnum.Yellow]: 'var(--tint-yellow)',
[ColorEnum.Lime]: 'var(--tint-lime)',
[ColorEnum.Green]: 'var(--tint-green)',
[ColorEnum.Aqua]: 'var(--tint-aqua)',
[ColorEnum.Blue]: 'var(--tint-blue)',
};
export function renderColor(color: string) {
if (colorMap[color as ColorEnum]) {
return colorMap[color as ColorEnum];
}
return color.replace('0x', '#');
}

View File

@ -39,12 +39,21 @@ export const getDesignTokens = (isDark: boolean): ThemeOptions => {
styleOverrides: { styleOverrides: {
contained: { contained: {
color: 'var(--content-on-fill)', color: 'var(--content-on-fill)',
boxShadow: 'var(--shadow)',
}, },
containedPrimary: { containedPrimary: {
'&:hover': { '&:hover': {
backgroundColor: 'var(--fill-default)', backgroundColor: 'var(--fill-default)',
}, },
}, },
containedInherit: {
color: 'var(--text-title)',
backgroundColor: isDark ? 'rgba(0, 0, 0, 0.4)' : 'rgba(255, 255, 255, 0.4)',
'&:hover': {
backgroundColor: 'var(--bg-body)',
boxShadow: 'var(--shadow)',
},
},
}, },
}, },
MuiButtonBase: { MuiButtonBase: {

View File

@ -8,13 +8,7 @@ function DocumentPage() {
const documentId = params.id; const documentId = params.id;
if (!documentId) return null; if (!documentId) return null;
return ( return <Document id={documentId} />;
<div className={'flex w-full justify-center'}>
<div className={'max-w-screen w-[964px] min-w-0'}>
<Document id={documentId} />
</div>
</div>
);
} }
export default DocumentPage; export default DocumentPage;

View File

@ -92,7 +92,7 @@
--fill-active: #e0f8ff; --fill-active: #e0f8ff;
--fill-list-hover: #e0f8ff; --fill-list-hover: #e0f8ff;
--fill-list-active: #edeef2; --fill-list-active: #edeef2;
--content-blue-400: #00bcf0; --content-blue-400: rgb(0, 188, 240);
--content-blue-300: #52d1f4; --content-blue-300: #52d1f4;
--content-blue-600: #009fd1; --content-blue-600: #009fd1;
--content-blue-100: #e0f8ff; --content-blue-100: #e0f8ff;
@ -111,7 +111,7 @@
--function-info: #00bcf0; --function-info: #00bcf0;
--tint-purple: #e8e0ff; --tint-purple: #e8e0ff;
--tint-pink: #ffe7ee; --tint-pink: #ffe7ee;
--tint-red: #ffe7ee; --tint-red: #ffdddd;
--tint-lime: #f5ffdc; --tint-lime: #f5ffdc;
--tint-green: #ddffd6; --tint-green: #ddffd6;
--tint-aqua: #defff1; --tint-aqua: #defff1;

View File

@ -134,7 +134,7 @@
"type": "color" "type": "color"
}, },
"red": { "red": {
"value": "#ffe7ee", "value": "#ffdddd",
"type": "color" "type": "color"
} }
} }

View File

@ -1239,6 +1239,8 @@
"backgroundColorPurple": "Purple background", "backgroundColorPurple": "Purple background",
"backgroundColorPink": "Pink background", "backgroundColorPink": "Pink background",
"backgroundColorRed": "Red background", "backgroundColorRed": "Red background",
"backgroundColorLime": "Lime background",
"backgroundColorAqua": "Aqua background",
"done": "Done", "done": "Done",
"cancel": "Cancel", "cancel": "Cancel",
"tint1": "Tint 1", "tint1": "Tint 1",