feat: support adding cover to document ()

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

@ -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;
}

@ -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

(image error) Size: 1.1 KiB

After

(image error) Size: 342 B

Binary file not shown.

After

(image error) Size: 275 KiB

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

@ -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]
);

@ -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>
);
}

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

@ -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>
);
}

@ -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>

@ -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>
);
}

@ -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} />

@ -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;

@ -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;

@ -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>
);
}

@ -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);

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

@ -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>
);
}

@ -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>
);
}

@ -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) {

@ -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) {

@ -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>

@ -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

@ -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 />

@ -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}
/>
);
}

@ -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 (

@ -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} />;
});
);

@ -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'} />

@ -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]);

@ -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) {

@ -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,
}}

@ -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

@ -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>
);
})
);

@ -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>
);
}

@ -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),
},
],
},

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

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

@ -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: '📌',
});
}

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

@ -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;
}
}

@ -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);

@ -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[] {

@ -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);
}
}

@ -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;

@ -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', '#');
}

@ -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: {

@ -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;

@ -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;

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

@ -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",