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 { Element } from 'slate';
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 {
id: string;
@ -162,14 +163,22 @@ export interface MentionPage {
}
export interface EditorProps {
id: string;
sharedType?: YXmlText;
title?: string;
cover?: PageCover;
onTitleChange?: (title: string) => void;
onCoverChange?: (cover?: PageCover) => void;
showTitle?: boolean;
id: string;
disableFocus?: boolean;
}
export interface LocalEditorProps {
disableFocus?: boolean;
sharedType: Y.XmlText;
id: string;
caretColor?: string;
}
export enum EditorNodeType {
Text = 'text',
Paragraph = 'paragraph',
@ -221,7 +230,9 @@ export enum MentionType {
export interface Mention {
// inline page ref id
page?: string;
page_id?: string;
// reminder date ref id
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">
<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"/>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<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>

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 TextField from '@mui/material/TextField';
import { useTranslation } from 'react-i18next';
import { pattern } from '$app/utils/open_url';
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 [value, setValue] = useState('');
const [value, setValue] = useState(defaultLink ?? '');
const [error, setError] = useState(false);
const handleChange = useCallback(
@ -15,7 +24,7 @@ export function EmbedLink({ onDone, onEscape }: { onDone?: (value: string) => vo
const value = e.target.value;
setValue(value);
setError(!pattern.test(value));
setError(!urlPattern.test(value));
},
[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 './UploadImage';
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 { PageIcon } from '$app_reducers/pages/slice';
import { PageCover, PageIcon } from '$app_reducers/pages/slice';
import ViewIcon from '$app/components/_shared/view_title/ViewIcon';
import { ViewCover } from '$app/components/_shared/view_title/cover';
function ViewBanner({
icon,
hover,
onUpdateIcon,
showCover,
cover,
onUpdateCover,
}: {
icon?: PageIcon;
hover: boolean;
onUpdateIcon: (icon: string) => void;
showCover: boolean;
cover?: PageCover;
onUpdateCover?: (cover?: PageCover) => void;
}) {
return (
<>
<div
style={{
display: icon ? 'flex' : 'none',
}}
>
<ViewIcon onUpdateIcon={onUpdateIcon} icon={icon} />
<div className={'view-banner flex w-full flex-col'}>
{showCover && cover && <ViewCover cover={cover} onUpdateCover={onUpdateCover} />}
<div className={'relative min-h-[65px] px-16 pt-4'}>
<div
style={{
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
style={{
opacity: hover ? 1 : 0,
}}
>
<ViewIconGroup icon={icon} onUpdateIcon={onUpdateIcon} />
</div>
</>
</div>
);
}

View File

@ -29,7 +29,7 @@ function ViewIcon({ icon, onUpdateIcon }: { icon?: PageIcon; onUpdateIcon: (icon
if (!icon) return null;
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'}>
{icon.value}
</div>

View File

@ -1,31 +1,42 @@
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 { randomEmoji } from '$app/utils/emoji';
import { EmojiEmotionsOutlined } from '@mui/icons-material';
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 {
icon?: PageIcon;
// onUpdateCover: (coverType: CoverType, cover: 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 showAddIcon = !icon?.value;
const showAddCover = !cover && showCover;
const onAddIcon = useCallback(() => {
const emoji = randomEmoji();
onUpdateIcon(emoji);
}, [onUpdateIcon]);
// const onAddCover = useCallback(() => {
// const color = randomColor();
//
// onUpdateCover(CoverType.Color, color);
// }, []);
const onAddCover = useCallback(() => {
onUpdateCover?.(defaultCover);
}, [onUpdateCover]);
return (
<div className={'flex items-center py-2'}>
@ -34,11 +45,11 @@ function ViewIconGroup({ icon, onUpdateIcon }: Props) {
{t('document.plugins.cover.addIcon')}
</Button>
)}
{/*{showAddCover && (*/}
{/* <Button onClick={onAddCover} color={'inherit'} startIcon={<ImageOutlined />}>*/}
{/* {t('document.plugins.cover.addCover')}*/}
{/* </Button>*/}
{/*)}*/}
{showAddCover && (
<Button onClick={onAddCover} color={'inherit'} startIcon={<ImageIcon />}>
{t('document.plugins.cover.addCover')}
</Button>
)}
</div>
);
}

View File

@ -1,6 +1,6 @@
import React, { useCallback, useEffect, useState } from 'react';
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 ViewTitleInput from '$app/components/_shared/view_title/ViewTitleInput';
@ -9,9 +9,20 @@ interface Props {
showTitle?: boolean;
onTitleChange?: (title: string) => 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 [icon, setIcon] = useState<PageIcon | undefined>(view.icon);
@ -38,7 +49,14 @@ function ViewTitle({ view, onTitleChange, showTitle = true, onUpdateIcon: onUpda
onMouseEnter={() => setHover(true)}
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 && (
<div className='relative'>
<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 { DocumentHeader } from 'src/appflowy_app/components/document/document_header';
import { useAppDispatch, useAppSelector } from '$app/stores/store';
import { updatePageName } from '$app_reducers/pages/async_actions';
import { PageCover } from '$app_reducers/pages/slice';
export function Document({ id }: { id: string }) {
const page = useAppSelector((state) => state.pages.pageMap[id]);
const [cover, setCover] = useState<PageCover | undefined>(undefined);
const dispatch = useAppDispatch();
const onTitleChange = useCallback(
@ -21,12 +23,29 @@ export function Document({ id }: { id: string }) {
[dispatch, id]
);
const view = useMemo(() => {
return {
...page,
cover,
};
}, [page, cover]);
useEffect(() => {
return () => {
setCover(undefined);
};
}, [id]);
if (!page) return null;
return (
<div className={'relative'}>
<DocumentHeader page={page} />
<Editor id={id} onTitleChange={onTitleChange} title={page.name} />
<div className={'relative w-full'}>
<DocumentHeader onUpdateCover={setCover} page={view} />
<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>
);
}

View File

@ -1,15 +1,18 @@
import React, { memo, useCallback } from 'react';
import { Page, PageIcon } from '$app_reducers/pages/slice';
import React, { memo, useCallback, useEffect, useRef, useState } from 'react';
import { Page, PageCover, PageIcon } from '$app_reducers/pages/slice';
import ViewTitle from '$app/components/_shared/view_title/ViewTitle';
import { updatePageIcon } from '$app/application/folder/page.service';
interface DocumentHeaderProps {
page: Page;
onUpdateCover: (cover?: PageCover) => void;
}
export function DocumentHeader({ page }: DocumentHeaderProps) {
export function DocumentHeader({ page, onUpdateCover }: DocumentHeaderProps) {
const pageId = page.id;
const ref = useRef<HTMLDivElement>(null);
const [forceHover, setForceHover] = useState(false);
const onUpdateIcon = useCallback(
async (icon: PageIcon) => {
await updatePageIcon(pageId, icon.value ? icon : undefined);
@ -17,10 +20,39 @@ export function DocumentHeader({ page }: DocumentHeaderProps) {
[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;
return (
<div className={'document-header select-none px-16 pt-4'}>
<ViewTitle showTitle={false} onUpdateIcon={onUpdateIcon} view={page} />
<div ref={ref} className={'document-header select-none'}>
<ViewTitle
showCover
showTitle={false}
forceHover={forceHover}
onUpdateCover={onUpdateCover}
onUpdateIcon={onUpdateIcon}
view={page}
/>
</div>
);
}

View File

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

View File

@ -254,17 +254,23 @@ export const CustomEditor = {
},
insertMention(editor: ReactEditor, mention: Mention) {
const mentionElement = {
type: EditorInlineNodeType.Mention,
children: [{ text: '@' }],
data: {
...mention,
const mentionElement = [
{
type: EditorInlineNodeType.Mention,
children: [{ text: '$' }],
data: {
...mention,
},
},
};
];
Transforms.insertNodes(editor, mentionElement, {
select: true,
});
editor.collapse({
edge: 'end',
});
},
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]'}>
<CalloutIcon node={node} />
</div>
<div {...attributes} ref={ref} className={`${attributes.className ?? ''} w-full bg-bg-body py-2`}>
<div
className={`flex w-full flex-col rounded border border-solid border-line-divider bg-content-blue-50 py-2 pl-10`}
>
<div ref={ref} className={`${attributes.className ?? ''} w-full bg-bg-body py-2`}>
<div {...attributes} className={`flex w-full flex-col rounded bg-content-blue-50 py-2 pl-10`}>
{children}
</div>
</div>

View File

@ -24,9 +24,9 @@ export const ImageBlock = memo(
onClick={() => {
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}
</div>
<div

View File

@ -39,7 +39,7 @@ function ImageEmpty({
<>
<div
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 />

View File

@ -1,20 +1,13 @@
import React, { useCallback, useMemo, SyntheticEvent, useState } from 'react';
import Popover, { PopoverOrigin } from '@mui/material/Popover/Popover';
import React, { useMemo } from 'react';
import { PopoverOrigin } from '@mui/material/Popover/Popover';
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 { EmbedLink, Unsplash } from '$app/components/_shared/image_upload';
import SwipeableViews from 'react-swipeable-views';
import { EmbedLink, Unsplash, UploadTabs, TabOption, TAB_KEY } from '$app/components/_shared/image_upload';
import { CustomEditor } from '$app/components/editor/command';
import { useSlateStatic } from 'slate-react';
import { ImageNode, ImageType } from '$app/application/document/document.types';
enum TAB_KEY {
UPLOAD = 'upload',
EMBED_LINK = 'embed_link',
UNSPLASH = 'unsplash',
}
const initialOrigin: {
transformOrigin: PopoverOrigin;
anchorOrigin: PopoverOrigin;
@ -53,7 +46,7 @@ function UploadPopover({
open,
});
const tabOptions = useMemo(() => {
const tabOptions: TabOption[] = useMemo(() => {
return [
// {
// label: t('button.upload'),
@ -87,102 +80,25 @@ function UploadPopover({
];
}, [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 (
<Popover
{...PopoverCommonProps}
disableAutoFocus={false}
open={open && isEntered}
anchorEl={anchorEl}
transformOrigin={transformOrigin}
anchorOrigin={anchorOrigin}
onClose={onClose}
onMouseDown={(e) => {
e.stopPropagation();
}}
onKeyDown={onKeyDown}
PaperProps={{
style: {
padding: 0,
<UploadTabs
popoverProps={{
anchorEl,
open: open && isEntered,
onClose,
transformOrigin,
anchorOrigin,
onMouseDown: (e) => {
e.stopPropagation();
},
}}
>
<div
style={{
maxWidth: paperWidth,
maxHeight: paperHeight,
overflow: 'hidden',
}}
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>
containerStyle={{
maxWidth: paperWidth,
maxHeight: paperHeight,
overflow: 'hidden',
}}
tabOptions={tabOptions}
/>
);
}

View File

@ -4,7 +4,7 @@ import { EditorElementProps, PageNode } from '$app/application/document/document
export const Page = memo(
forwardRef<HTMLDivElement, EditorElementProps<PageNode>>(({ node: _, children, ...attributes }, ref) => {
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]);
return (

View File

@ -5,62 +5,87 @@ import { EditorProps } from '$app/application/document/document.types';
import { Provider } from '$app/components/editor/provider';
import { YXmlText } from 'yjs/dist/src/types/YXmlText';
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) => {
const [sharedType, setSharedType] = useState<YXmlText | null>(null);
const provider = useMemo(() => {
setSharedType(null);
return new Provider(id, showTitle);
}, [id, showTitle]);
export const CollaborativeEditor = memo(
({ id, title, cover, showTitle = true, onTitleChange, onCoverChange, ...props }: EditorProps) => {
const [sharedType, setSharedType] = useState<YXmlText | null>(null);
const provider = useMemo(() => {
setSharedType(null);
return new Provider(id, showTitle);
}, [id, showTitle]);
const root = useMemo(() => {
if (!showTitle || !sharedType || !sharedType.doc) return null;
const root = useMemo(() => {
if (!showTitle || !sharedType || !sharedType.doc) return null;
return getYTarget(sharedType?.doc, [0]);
}, [sharedType, showTitle]);
return getYTarget(sharedType?.doc, [0]);
}, [sharedType, showTitle]);
const rootText = useMemo(() => {
if (!root) return null;
return getInsertTarget(root, [0]);
}, [root]);
const rootText = useMemo(() => {
if (!root) return null;
return getInsertTarget(root, [0]);
}, [root]);
useEffect(() => {
if (!rootText || rootText.toString() === title) return;
useEffect(() => {
if (!rootText || rootText.toString() === title) return;
if (rootText.length > 0) {
rootText.delete(0, rootText.length);
if (rootText.length > 0) {
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 || '');
}, [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} {...props} />;
}
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 { CircularProgress } from '@mui/material';
import * as Y from 'yjs';
import { NodeEntry } from 'slate';
import {
DecorateStateProvider,
@ -22,8 +21,9 @@ import {
} from '$app/components/editor/stores';
import CommandPanel from '../tools/command_panel/CommandPanel';
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 decorateCodeHighlight = useDecorateCodeHighlight(editor);
const { onKeyDown: onShortcutsKeyDown } = useShortcuts(editor);
@ -74,7 +74,10 @@ function Editor({ sharedType, disableFocus }: { sharedType: Y.XmlText; id: strin
disableFocus={disableFocus}
onKeyDown={onKeyDown}
decorate={decorate}
className={'px-16 caret-text-title outline-none focus:outline-none'}
style={{
caretColor,
}}
className={`px-16 outline-none focus:outline-none`}
/>
<CommandPanel />
<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 { useElementState } from '$app/components/editor/components/editor/Element.hooks';
import UnSupportBlock from '$app/components/editor/components/blocks/_shared/unSupportBlock';
import { renderColor } from '$app/utils/color';
function Element({ element, attributes, children }: RenderElementProps) {
const node = element;
@ -98,8 +99,8 @@ function Element({ element, attributes, children }: RenderElementProps) {
const data = (node.data as BlockData) || {};
return {
backgroundColor: data.bg_color,
color: data.font_color,
backgroundColor: data.bg_color ? renderColor(data.bg_color) : undefined,
color: data.font_color ? renderColor(data.font_color) : undefined,
};
}, [node.data]);

View File

@ -1,6 +1,7 @@
import React, { CSSProperties } from 'react';
import { RenderLeafProps } from 'slate-react';
import { Link } from '$app/components/editor/components/inline_nodes/link';
import { renderColor } from '$app/utils/color';
export function Leaf({ attributes, children, leaf }: RenderLeafProps) {
let newChildren = children;
@ -39,11 +40,11 @@ export function Leaf({ attributes, children, leaf }: RenderLeafProps) {
const style: CSSProperties = {};
if (leaf.font_color) {
style['color'] = leaf.font_color.replace('0x', '#');
style['color'] = renderColor(leaf.font_color);
}
if (leaf.bg_color) {
style['backgroundColor'] = leaf.bg_color.replace('0x', '#');
style['backgroundColor'] = renderColor(leaf.bg_color);
}
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:
// https://bugs.chromium.org/p/chromium/issues/detail?id=1249405
export const InlineChromiumBugfix = () => (
export const InlineChromiumBugfix = ({ className }: { className?: string }) => (
<span
contentEditable={false}
className={'absolute caret-transparent'}
className={`absolute caret-transparent ${className ?? ''}`}
style={{
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 { Transforms } from 'slate';
import { Editor, Range, Transforms } from 'slate';
import { EditorElementProps, FormulaNode } from '$app/application/document/document.types';
import FormulaLeaf from '$app/components/editor/components/inline_nodes/inline_formula/FormulaLeaf';
import FormulaEditPopover from '$app/components/editor/components/inline_nodes/inline_formula/FormulaEditPopover';
@ -18,17 +18,27 @@ export const InlineFormula = memo(
const selected = useSelected();
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(
(e: MouseEvent<HTMLSpanElement>) => {
const target = e.currentTarget;
const path = getNodePath(editor, target);
ReactEditor.focus(editor);
Transforms.select(editor, path);
if (editor.selection) {
setRange(editor.selection);
openPopover();
}
setRange(path);
openPopover();
},
[editor, openPopover, setRange]
);
@ -103,9 +113,9 @@ export const InlineFormula = memo(
selected ? 'selected' : ''
}`}
>
<InlineChromiumBugfix />
<InlineChromiumBugfix className={'left-0'} />
<FormulaLeaf formula={formula}>{children}</FormulaLeaf>
<InlineChromiumBugfix />
<InlineChromiumBugfix className={'right-0'} />
</span>
{open && (
<FormulaEditPopover

View File

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

View File

@ -5,23 +5,44 @@ import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { pageTypeMap } from '$app_reducers/pages/slice';
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 { notify } from 'src/appflowy_app/components/_shared/notify';
import { subscribeNotifications } from '$app/application/notification';
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 [page, setPage] = useState<MentionPage | null>(null);
const [error, setError] = useState<boolean>(false);
const navigate = useNavigate();
const editor = useSlate();
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 () => {
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 {
const page = await getPage(mention.page);
const page = await getPage(pageId);
setPage(page);
setError(false);
@ -29,7 +50,7 @@ export function MentionLeaf({ children, mention }: { mention: Mention; children:
setPage(null);
setError(true);
}
}, [mention.page]);
}, [mention]);
useEffect(() => {
void loadPage();
@ -94,31 +115,27 @@ export function MentionLeaf({ children, mention }: { mention: Mention; children:
}, [page]);
return (
<span className={'relative'}>
<span
className={'relative mx-1 inline-flex cursor-pointer items-center hover:rounded hover:bg-content-blue-100'}
onClick={openPage}
style={{
backgroundColor: selected ? 'var(--content-blue-100)' : undefined,
}}
>
{page && (
<span
className={`mention-inline mx-1 inline-flex select-none items-center gap-1`}
onClick={openPage}
contentEditable={false}
style={{
backgroundColor: selected ? 'var(--content-blue-100)' : undefined,
}}
>
{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>
<span className={'ml-6 mr-0.5 underline'}>{page.name || t('document.title.placeholder')}</span>
{page.icon?.value || <DocumentSvg />}
<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>
);
}

View File

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

View File

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

View File

@ -52,7 +52,8 @@ export function useMentionPanel({
closePanel(true);
CustomEditor.insertMention(editor, {
page: id,
page_id: id,
type: MentionType.PageRef,
});
},
[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 { DataObjectOutlined, FunctionsOutlined, HorizontalRuleOutlined, MenuBookOutlined } from '@mui/icons-material';
import { CustomEditor } from '$app/components/editor/command';
import { randomEmoji } from '$app/utils/emoji';
import { KeyboardNavigationOption } from '$app/components/_shared/keyboard_navigation/KeyboardNavigation';
import { YjsEditor } from '@slate-yjs/core';
import { useEditorBlockDispatch } from '$app/components/editor/stores/block';
@ -133,7 +132,7 @@ export function useSlashCommandPanel({
if (nodeType === EditorNodeType.CalloutBlock) {
Object.assign(data, {
icon: randomEmoji(),
icon: '📌',
});
}

View File

@ -27,6 +27,8 @@ export function Formula() {
}
requestAnimationFrame(() => {
const selection = editor.selection;
if (!selection) return;
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 {
@apply min-w-[1px];
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 {
@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 {
@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);
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('data', data);
this.sharedType = sharedType;
this.sharedType?.observeDeep(this.onChange);

View File

@ -7,76 +7,90 @@ export function generateId() {
return nanoid(10);
}
export function transformToInlineElement(op: Op): Element | null {
export function transformToInlineElement(op: Op): Element[] {
const attributes = op.attributes;
if (!attributes) return null;
if (!attributes) return [];
const { formula, mention, ...attrs } = attributes;
if (formula) {
return {
type: EditorInlineNodeType.Formula,
data: formula,
children: [
{
text: op.insert as string,
...attrs,
},
],
};
const texts = (op.insert as string).split('');
return texts.map((text) => {
return {
type: EditorInlineNodeType.Formula,
data: formula,
children: [
{
text,
...attrs,
},
],
};
});
}
if (mention) {
return {
type: EditorInlineNodeType.Mention,
children: [
{
text: op.insert as string,
...attrs,
const texts = (op.insert as string).split('');
return texts.map((text) => {
return {
type: EditorInlineNodeType.Mention,
children: [
{
text,
...attrs,
},
],
data: {
...(mention as Mention),
},
],
data: {
...(mention as Mention),
},
};
};
});
}
return null;
return [];
}
export function getInlinesWithDelta(delta?: Op[]): (Text | Element)[] {
return delta && delta.length > 0
? delta.map((op) => {
const matchInline = transformToInlineElement(op);
const newDelta: (Text | Element)[] = [];
if (matchInline) {
return matchInline;
}
if (!delta || !delta.length)
return [
{
text: '',
},
];
if (op.attributes) {
if ('font_color' in op.attributes && op.attributes['font_color'] === '') {
delete op.attributes['font_color'];
}
delta.forEach((op) => {
const matchInlines = transformToInlineElement(op);
if ('bg_color' in op.attributes && op.attributes['bg_color'] === '') {
delete op.attributes['bg_color'];
}
if (matchInlines.length > 0) {
newDelta.push(...matchInlines);
return;
}
if ('code' in op.attributes && !op.attributes['code']) {
delete op.attributes['code'];
}
}
if (op.attributes) {
if ('font_color' in op.attributes && op.attributes['font_color'] === '') {
delete op.attributes['font_color'];
}
return {
text: op.insert as string,
...op.attributes,
};
})
: [
{
text: '',
},
];
if ('bg_color' in op.attributes && op.attributes['bg_color'] === '') {
delete op.attributes['bg_color'];
}
if ('code' in op.attributes && !op.attributes['code']) {
delete op.attributes['code'];
}
}
newDelta.push({
text: op.insert as string,
...op.attributes,
});
});
return newDelta;
}
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 {
::-webkit-scrollbar {
width: 0px;
}
}
.MuiPopover-root, .MuiPaper-root {
::-webkit-scrollbar {
width: 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 { createSlice, PayloadAction } from '@reduxjs/toolkit';
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 = {
[ViewLayoutPB.Document]: 'document',
@ -14,6 +16,7 @@ export interface Page {
name: string;
layout: ViewLayoutPB;
icon?: PageIcon;
cover?: PageCover;
}
export interface PageIcon {
@ -21,6 +24,17 @@ export interface PageIcon {
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 {
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: {
contained: {
color: 'var(--content-on-fill)',
boxShadow: 'var(--shadow)',
},
containedPrimary: {
'&:hover': {
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: {

View File

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

View File

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

View File

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

View File

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