mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-03 02:20:19 +08:00
merge branch develop into branch feat/doublelink220820
This commit is contained in:
@@ -22,16 +22,23 @@ import { type BlockEditor } from '@toeverything/components/editor-core';
|
||||
import { useFlag } from '@toeverything/datasource/feature-flags';
|
||||
import { CollapsiblePageTree } from './collapsible-page-tree';
|
||||
import { Tabs } from './components/tabs';
|
||||
import { TabMap, TAB_TITLE } from './components/tabs/Tabs';
|
||||
import { TOC } from './components/toc';
|
||||
import { WorkspaceName } from './workspace-name';
|
||||
|
||||
type PageProps = {
|
||||
workspace: string;
|
||||
};
|
||||
|
||||
export function Page(props: PageProps) {
|
||||
const [activeTab, setActiveTab] = useState(
|
||||
TabMap.get(TAB_TITLE.PAGES).value
|
||||
);
|
||||
const { page_id } = useParams();
|
||||
const { showSpaceSidebar, fixedDisplay, setSpaceSidebarVisible } =
|
||||
useShowSpaceSidebar();
|
||||
const dailyNotesFlag = useFlag('BooleanDailyNotes', false);
|
||||
const onTabChange = v => setActiveTab(v);
|
||||
|
||||
return (
|
||||
<LigoApp>
|
||||
@@ -50,31 +57,37 @@ export function Page(props: PageProps) {
|
||||
>
|
||||
<WorkspaceName />
|
||||
|
||||
<Tabs />
|
||||
<Tabs activeTab={activeTab} onTabChange={onTabChange} />
|
||||
|
||||
<WorkspaceSidebarContent>
|
||||
<div>
|
||||
{dailyNotesFlag && (
|
||||
{activeTab === TabMap.get(TAB_TITLE.PAGES).value && (
|
||||
<div>
|
||||
{dailyNotesFlag && (
|
||||
<div>
|
||||
<CollapsibleTitle title="Daily Notes">
|
||||
<CalendarHeatmap />
|
||||
</CollapsibleTitle>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<CollapsibleTitle title="Daily Notes">
|
||||
<CalendarHeatmap />
|
||||
<CollapsibleTitle
|
||||
title="ACTIVITIES"
|
||||
initialOpen={false}
|
||||
>
|
||||
<Activities />
|
||||
</CollapsibleTitle>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<CollapsibleTitle
|
||||
title="ACTIVITIES"
|
||||
initialOpen={false}
|
||||
>
|
||||
<Activities />
|
||||
</CollapsibleTitle>
|
||||
<div>
|
||||
<CollapsiblePageTree title="PAGES">
|
||||
{page_id ? <PageTree /> : null}
|
||||
</CollapsiblePageTree>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<CollapsiblePageTree title="PAGES">
|
||||
{page_id ? <PageTree /> : null}
|
||||
</CollapsiblePageTree>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === TabMap.get(TAB_TITLE.TOC).value && (
|
||||
<TOC />
|
||||
)}
|
||||
</WorkspaceSidebarContent>
|
||||
</WorkspaceSidebar>
|
||||
</LigoLeftContainer>
|
||||
@@ -105,6 +118,7 @@ const EditorContainer = ({
|
||||
const obv = new ResizeObserver(e => {
|
||||
setPageClientWidth(e[0].contentRect.width);
|
||||
});
|
||||
|
||||
obv.observe(scrollContainer);
|
||||
return () => obv.disconnect();
|
||||
}
|
||||
@@ -175,7 +189,7 @@ const WorkspaceSidebar = styled('div')(({ theme }) => ({
|
||||
width: 300,
|
||||
minWidth: 300,
|
||||
borderRadius: '0px 10px 10px 0px',
|
||||
boxShadow: theme.affine.shadows.shadow1,
|
||||
boxShadow: theme.affine.shadows.shadow2,
|
||||
backgroundColor: '#FFFFFF',
|
||||
transitionProperty: 'left',
|
||||
transitionDuration: '0.35s',
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { styled } from '@toeverything/components/ui';
|
||||
import type { ValueOf } from '@toeverything/utils';
|
||||
import { useState } from 'react';
|
||||
|
||||
const StyledTabs = styled('div')(({ theme }) => {
|
||||
return {
|
||||
@@ -56,32 +55,35 @@ const StyledTabTitle = styled('div')<{
|
||||
}
|
||||
`;
|
||||
|
||||
const TAB_TITLE = {
|
||||
PAGES: 'pages',
|
||||
GALLERY: 'gallery',
|
||||
TOC: 'toc',
|
||||
export const TAB_TITLE = {
|
||||
PAGES: 'PAGES',
|
||||
GALLERY: 'GALLERY',
|
||||
TOC: 'TOC',
|
||||
} as const;
|
||||
|
||||
const TabMap = new Map<TabKey, { value: TabValue; disabled?: boolean }>([
|
||||
['PAGES', { value: 'pages' }],
|
||||
['GALLERY', { value: 'gallery', disabled: true }],
|
||||
['TOC', { value: 'toc' }],
|
||||
export const TabMap = new Map<
|
||||
TabValue,
|
||||
{ value: TabValue; disabled?: boolean }
|
||||
>([
|
||||
[TAB_TITLE.PAGES, { value: TAB_TITLE.PAGES }],
|
||||
[TAB_TITLE.GALLERY, { value: TAB_TITLE.GALLERY, disabled: true }],
|
||||
[TAB_TITLE.TOC, { value: TAB_TITLE.TOC }],
|
||||
]);
|
||||
|
||||
type TabKey = keyof typeof TAB_TITLE;
|
||||
type TabValue = ValueOf<typeof TAB_TITLE>;
|
||||
|
||||
const Tabs = () => {
|
||||
const [activeValue, setActiveTab] = useState<TabValue>(TAB_TITLE.PAGES);
|
||||
interface Props {
|
||||
activeTab: TabValue;
|
||||
onTabChange: (v: TabValue) => void;
|
||||
}
|
||||
|
||||
const onClick = (v: TabValue) => {
|
||||
setActiveTab(v);
|
||||
};
|
||||
const Tabs = (props: Props) => {
|
||||
const { activeTab, onTabChange } = props;
|
||||
|
||||
return (
|
||||
<StyledTabs>
|
||||
{[...TabMap.entries()].map(([k, { value, disabled = false }]) => {
|
||||
const isActive = activeValue === value;
|
||||
const isActive = activeTab === value;
|
||||
|
||||
return (
|
||||
<StyledTabTitle
|
||||
@@ -89,7 +91,7 @@ const Tabs = () => {
|
||||
className={isActive ? 'active' : ''}
|
||||
isActive={isActive}
|
||||
isDisabled={disabled}
|
||||
onClick={() => onClick(value)}
|
||||
onClick={() => onTabChange(value)}
|
||||
>
|
||||
{k}
|
||||
</StyledTabTitle>
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import type { Virgo } from '@toeverything/components/editor-core';
|
||||
import { styled } from '@toeverything/components/ui';
|
||||
import { Protocol } from '@toeverything/datasource/db-service';
|
||||
import { useCurrentEditors } from '@toeverything/datasource/state';
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useParams } from 'react-router';
|
||||
import { BLOCK_TYPES } from './toc-enum';
|
||||
import {
|
||||
destroyEventList,
|
||||
getContentByAsyncBlocks,
|
||||
getPageTOC,
|
||||
} from './toc-util';
|
||||
import type { ListenerMap, TOCType } from './types';
|
||||
|
||||
const StyledTOCItem = styled('a')<{ type?: string; isActive?: boolean }>(
|
||||
({ type, isActive }) => {
|
||||
const common = {
|
||||
height: '32px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
cursor: 'pointer',
|
||||
color: isActive ? '#3E6FDB' : '#4C6275',
|
||||
};
|
||||
|
||||
if (type === BLOCK_TYPES.HEADING1) {
|
||||
return {
|
||||
...common,
|
||||
padding: '0 12px',
|
||||
fontWeight: '600',
|
||||
fontSize: '16px',
|
||||
};
|
||||
}
|
||||
|
||||
if (type === BLOCK_TYPES.HEADING2) {
|
||||
return {
|
||||
...common,
|
||||
padding: '0 32px',
|
||||
fontSize: '14px',
|
||||
};
|
||||
}
|
||||
|
||||
if (type === BLOCK_TYPES.HEADING3) {
|
||||
return {
|
||||
...common,
|
||||
padding: '0 52px',
|
||||
fontSize: '12px',
|
||||
};
|
||||
}
|
||||
|
||||
if (type === BLOCK_TYPES.GROUP) {
|
||||
return {
|
||||
...common,
|
||||
margin: '6px 0px',
|
||||
height: '46px',
|
||||
padding: '6px 12px',
|
||||
fontWeight: '600',
|
||||
fontSize: '16px',
|
||||
borderTop: '0.5px solid #E0E6EB',
|
||||
borderBottom: '0.5px solid #E0E6EB',
|
||||
color: isActive ? '#3E6FDB' : '#98ACBD',
|
||||
};
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
);
|
||||
|
||||
const StyledItem = styled('div')(props => {
|
||||
return {
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
};
|
||||
});
|
||||
|
||||
const TOCContext = createContext(null);
|
||||
|
||||
const TOCItem = props => {
|
||||
const { activeBlockId, onClick } = useContext(TOCContext);
|
||||
const { id, type, text } = props;
|
||||
const isActive = id === activeBlockId;
|
||||
|
||||
return (
|
||||
<StyledTOCItem
|
||||
key={id}
|
||||
isActive={isActive}
|
||||
type={type}
|
||||
onClick={() => onClick(id)}
|
||||
>
|
||||
<StyledItem>{text}</StyledItem>
|
||||
</StyledTOCItem>
|
||||
);
|
||||
};
|
||||
|
||||
const renderTOCContent = tocDataSource => {
|
||||
return (
|
||||
<>
|
||||
{tocDataSource.map(tocItem => {
|
||||
if (tocItem?.length) {
|
||||
return renderTOCContent(tocItem);
|
||||
}
|
||||
|
||||
const { id, type, text } = tocItem;
|
||||
|
||||
return <TOCItem key={id} id={id} type={type} text={text} />;
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const TOC = () => {
|
||||
const { page_id } = useParams();
|
||||
const [tocDataSource, setTocDataSource] = useState<TOCType[]>([]);
|
||||
const [activeBlockId, setActiveBlockId] = useState('');
|
||||
|
||||
/* store page/block unmount-listener */
|
||||
const listenerMapRef = useRef<ListenerMap>(new Map());
|
||||
|
||||
const { currentEditors } = useCurrentEditors();
|
||||
const editor = currentEditors[page_id] as Virgo;
|
||||
|
||||
const updateTocDataSource = useCallback(async () => {
|
||||
if (!editor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const listenerMap = listenerMapRef.current;
|
||||
|
||||
/* page listener: trigger update-notice when add new group */
|
||||
const pageAsyncBlock = (await editor.getBlockByIds([page_id]))?.[0];
|
||||
if (!listenerMap.has(pageAsyncBlock.id)) {
|
||||
listenerMap.set(
|
||||
pageAsyncBlock.id,
|
||||
pageAsyncBlock.onUpdate(updateTocDataSource)
|
||||
);
|
||||
}
|
||||
|
||||
/* block listener: trigger update-notice when change block content */
|
||||
const { children = [] } =
|
||||
(await editor.queryByPageId(page_id))?.[0] || {};
|
||||
const asyncBlocks = (await editor.getBlockByIds(children)) || [];
|
||||
const { tocContents } = await getContentByAsyncBlocks(
|
||||
asyncBlocks,
|
||||
updateTocDataSource,
|
||||
listenerMap
|
||||
);
|
||||
|
||||
/* toc: flat content */
|
||||
const tocDataSource = getPageTOC(asyncBlocks, tocContents);
|
||||
setTocDataSource(tocDataSource);
|
||||
}, [editor, page_id]);
|
||||
|
||||
/* init toc and add page/block update-listener & unmount-listener */
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
await updateTocDataSource();
|
||||
})();
|
||||
|
||||
/* remove listener when unmount component */
|
||||
return () => destroyEventList(listenerMapRef.current);
|
||||
}, [updateTocDataSource]);
|
||||
|
||||
const onClick = async (blockId?: string) => {
|
||||
setActiveBlockId(blockId);
|
||||
const block = await editor.getBlockById(blockId);
|
||||
await editor.scrollManager.scrollIntoViewByBlockId(blockId);
|
||||
|
||||
if (!block || block.type === Protocol.Block.Type.group) {
|
||||
// the group block has its own background
|
||||
return;
|
||||
}
|
||||
// See https://developer.mozilla.org/en-US/docs/Web/API/Element/animate
|
||||
block.dom?.animate(
|
||||
[
|
||||
{
|
||||
backgroundColor: 'rgba(152, 172, 189, 0.1)',
|
||||
},
|
||||
{
|
||||
backgroundColor: 'rgba(152, 172, 189, 0)',
|
||||
},
|
||||
],
|
||||
{
|
||||
delay: 500,
|
||||
duration: 700,
|
||||
easing: 'linear',
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<TOCContext.Provider value={{ activeBlockId, onClick }}>
|
||||
<div>{renderTOCContent(tocDataSource)}</div>
|
||||
</TOCContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { TOC } from './TOC';
|
||||
@@ -0,0 +1,6 @@
|
||||
export enum BLOCK_TYPES {
|
||||
GROUP = 'group',
|
||||
HEADING1 = 'heading1',
|
||||
HEADING2 = 'heading2',
|
||||
HEADING3 = 'heading3',
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { AsyncBlock } from '@toeverything/components/editor-core';
|
||||
import { BLOCK_TYPES } from './toc-enum';
|
||||
import type { ListenerMap, TOCType } from './types';
|
||||
|
||||
/* 😞😞sorry, I don't know how to define unlimited dimensions array */
|
||||
const getContentByAsyncBlocks = async (
|
||||
asyncBlocks: AsyncBlock[] = [],
|
||||
callback: () => void,
|
||||
listenerMap: ListenerMap
|
||||
): Promise<{
|
||||
tocContents: any[];
|
||||
}> => {
|
||||
const collect = async (asyncBlocks): Promise<any[]> => {
|
||||
/* maybe should recast it to tail recursion */
|
||||
return await Promise.all(
|
||||
asyncBlocks.map(async (asyncBlock: AsyncBlock) => {
|
||||
const asyncBlocks = await asyncBlock?.children();
|
||||
|
||||
if (asyncBlocks?.length) {
|
||||
return collect(asyncBlocks);
|
||||
}
|
||||
|
||||
/* add only once event listener for every block */
|
||||
if (!listenerMap.has(asyncBlock?.id)) {
|
||||
/* get update notice */
|
||||
const destroyHandler = asyncBlock?.onUpdate(callback);
|
||||
|
||||
/* collect destroy handlers */
|
||||
listenerMap.set(asyncBlock?.id, destroyHandler);
|
||||
}
|
||||
|
||||
const { id, type } = asyncBlock;
|
||||
|
||||
switch (type) {
|
||||
case BLOCK_TYPES.GROUP:
|
||||
case BLOCK_TYPES.HEADING1:
|
||||
case BLOCK_TYPES.HEADING2:
|
||||
case BLOCK_TYPES.HEADING3: {
|
||||
const properties = await asyncBlock?.getProperties();
|
||||
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
text: properties?.text?.value?.[0]?.text || '',
|
||||
};
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
tocContents: await collect(asyncBlocks),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* get flat toc
|
||||
* @param asyncBlocks
|
||||
* @param tocContents
|
||||
*/
|
||||
const getPageTOC = (asyncBlocks: AsyncBlock[], tocContents): TOCType[] => {
|
||||
return tocContents
|
||||
.reduce((tocGroupContent, tocContent, index) => {
|
||||
const { id, type } = asyncBlocks[index];
|
||||
const groupContent = {
|
||||
id,
|
||||
type,
|
||||
text: 'Untitled Group',
|
||||
};
|
||||
|
||||
tocGroupContent.push(
|
||||
!tocContent.flat(Infinity).filter(Boolean).length
|
||||
? groupContent
|
||||
: tocContent
|
||||
);
|
||||
|
||||
return tocGroupContent;
|
||||
}, [])
|
||||
.flat(Infinity)
|
||||
.filter(Boolean);
|
||||
};
|
||||
|
||||
/* destroy page/block update-listener */
|
||||
const destroyEventList = (listenerMap: ListenerMap) => {
|
||||
const eventListeners = listenerMap.values();
|
||||
listenerMap.clear();
|
||||
|
||||
for (const eventListener of eventListeners) {
|
||||
eventListener?.();
|
||||
}
|
||||
};
|
||||
|
||||
export { getPageTOC, getContentByAsyncBlocks, destroyEventList };
|
||||
@@ -0,0 +1,7 @@
|
||||
export type TOCType = {
|
||||
id: string;
|
||||
type: string;
|
||||
text: string;
|
||||
};
|
||||
|
||||
export type ListenerMap = Map<string, () => void>;
|
||||
Reference in New Issue
Block a user