refactor(core): workbench (#7355)

Merge the right sidebar logic into the workbench. this can simplify our logic.

Previously we had 3 modules

* workbench
* right-sidebar (Control sidebar open&close)
* multi-tab-sidebar (Control tabs)

Now everything is managed in Workbench.

# Behavioral changes

The sidebar button is always visible and can be opened at any time.
If there is no content to display,  will be `No Selection`

![CleanShot 2024-06-28 at 14.00.41.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/g3jz87HxbjOJpXV3FPT7/d74b3a60-2299-452e-877e-188186fe5ee5.png)

Elements in the sidebar can now be defined as`unmountOnInactive=false`. Inactive sidebars are marked with `display: none` but not unmount, so the `ChatPanel` can always remain in the DOM and user input will be retained even if the sidebar is closed.
This commit is contained in:
EYHN
2024-07-12 04:11:05 +00:00
parent 5f16cb400d
commit 5dd7382693
55 changed files with 831 additions and 704 deletions
@@ -12,7 +12,7 @@ import { nanoid } from 'nanoid';
import { useCallback, useMemo, useState } from 'react';
import { CollectionService } from '../../../modules/collection';
import { ViewBodyIsland, ViewHeaderIsland } from '../../../modules/workbench';
import { ViewBody, ViewHeader } from '../../../modules/workbench';
import { EmptyCollectionList } from '../page-list-empty';
import { AllCollectionHeader } from './header';
import * as styles from './index.css';
@@ -55,13 +55,13 @@ export const AllCollection = () => {
return (
<>
<ViewHeaderIsland>
<ViewHeader>
<AllCollectionHeader
showCreateNew={!hideHeaderCreateNew}
onCreateCollection={handleCreateCollection}
/>
</ViewHeaderIsland>
<ViewBodyIsland>
</ViewHeader>
<ViewBody>
<div className={styles.body}>
{collectionMetas.length > 0 ? (
<VirtualizedCollectionList
@@ -82,7 +82,7 @@ export const AllCollection = () => {
/>
)}
</div>
</ViewBodyIsland>
</ViewBody>
</>
);
};
@@ -9,7 +9,7 @@ import type { Filter } from '@affine/env/filter';
import { useService, WorkspaceService } from '@toeverything/infra';
import { useState } from 'react';
import { ViewBodyIsland, ViewHeaderIsland } from '../../../modules/workbench';
import { ViewBody, ViewHeader } from '../../../modules/workbench';
import { EmptyPageList } from '../page-list-empty';
import * as styles from './all-page.css';
import { FilterContainer } from './all-page-filter';
@@ -27,14 +27,14 @@ export const AllPage = () => {
return (
<>
<ViewHeaderIsland>
<ViewHeader>
<AllPageHeader
showCreateNew={!hideHeaderCreateNew}
filters={filters}
onChangeFilters={setFilters}
/>
</ViewHeaderIsland>
<ViewBodyIsland>
</ViewHeader>
<ViewBody>
<div className={styles.body}>
<FilterContainer filters={filters} onChangeFilters={setFilters} />
{filteredPageMetas.length > 0 ? (
@@ -50,7 +50,7 @@ export const AllPage = () => {
/>
)}
</div>
</ViewBodyIsland>
</ViewBody>
</>
);
};
@@ -8,7 +8,7 @@ import { DeleteTagConfirmModal, TagService } from '@affine/core/modules/tag';
import { useLiveData, useService } from '@toeverything/infra';
import { useCallback, useState } from 'react';
import { ViewBodyIsland, ViewHeaderIsland } from '../../../modules/workbench';
import { ViewBody, ViewHeader } from '../../../modules/workbench';
import { EmptyTagList } from '../page-list-empty';
import * as styles from './all-tag.css';
import { AllTagHeader } from './header';
@@ -56,10 +56,10 @@ export const AllTag = () => {
return (
<>
<ViewHeaderIsland>
<ViewHeader>
<AllTagHeader />
</ViewHeaderIsland>
<ViewBodyIsland>
</ViewHeader>
<ViewBody>
<div className={styles.body}>
{tags.length > 0 ? (
<VirtualizedTagList
@@ -71,7 +71,7 @@ export const AllTag = () => {
<EmptyTagList heading={<EmptyTagListHeader />} />
)}
</div>
</ViewBodyIsland>
</ViewBody>
<DeleteTagConfirmModal
open={open}
onOpenChange={handleCloseModal}
@@ -19,7 +19,7 @@ import { useCallback, useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { useNavigateHelper } from '../../../hooks/use-navigate-helper';
import { ViewBodyIsland, ViewHeaderIsland } from '../../../modules/workbench';
import { ViewBody, ViewHeader } from '../../../modules/workbench';
import { WorkspaceSubPath } from '../../../shared';
import * as styles from './collection.css';
import { CollectionDetailHeader } from './header';
@@ -40,18 +40,18 @@ export const CollectionDetail = ({
return (
<>
<ViewHeaderIsland>
<ViewHeader>
<CollectionDetailHeader
showCreateNew={!hideHeaderCreateNew}
onCreate={handleEditCollection}
/>
</ViewHeaderIsland>
<ViewBodyIsland>
</ViewHeader>
<ViewBody>
<VirtualizedPageList
collection={collection}
setHideHeaderCreateNewPage={setHideHeaderCreateNew}
/>
</ViewBodyIsland>
</ViewBody>
{node}
</>
);
@@ -127,7 +127,7 @@ const Placeholder = ({ collection }: { collection: Collection }) => {
return (
<>
<ViewHeaderIsland>
<ViewHeader>
<div
style={{
display: 'flex',
@@ -166,8 +166,8 @@ const Placeholder = ({ collection }: { collection: Collection }) => {
</div>
<div style={{ flex: 1 }} />
</div>
</ViewHeaderIsland>
<ViewBodyIsland>
</ViewHeader>
<ViewBody>
<div
style={{
display: 'flex',
@@ -302,7 +302,7 @@ const Placeholder = ({ collection }: { collection: Collection }) => {
</div>
) : null}
</div>
</ViewBodyIsland>
</ViewBody>
{node}
</>
);
@@ -1,4 +1,8 @@
import { Divider, type InlineEditHandle } from '@affine/component';
import {
Divider,
type InlineEditHandle,
observeResize,
} from '@affine/component';
import { openInfoModalAtom } from '@affine/core/atoms';
import { InfoModal } from '@affine/core/components/affine/page-properties';
import { FavoriteButton } from '@affine/core/components/blocksuite/block-suite-header/favorite';
@@ -13,7 +17,7 @@ import { useJournalInfoHelper } from '@affine/core/hooks/use-journal';
import type { Doc } from '@blocksuite/store';
import { type Workspace } from '@toeverything/infra';
import { useAtom, useAtomValue } from 'jotai';
import { useCallback, useRef } from 'react';
import { forwardRef, useCallback, useEffect, useRef, useState } from 'react';
import { SharePageButton } from '../../../components/affine/share-page-modal';
import { appSidebarFloatingAtom } from '../../../components/app-sidebar';
@@ -22,36 +26,50 @@ import { HeaderDivider } from '../../../components/pure/header';
import * as styles from './detail-page-header.css';
import { useDetailPageHeaderResponsive } from './use-header-responsive';
function Header({
children,
style,
className,
}: {
children: React.ReactNode;
className?: string;
style?: React.CSSProperties;
}) {
const Header = forwardRef<
HTMLDivElement,
{
children: React.ReactNode;
className?: string;
style?: React.CSSProperties;
}
>(({ children, style, className }, ref) => {
const appSidebarFloating = useAtomValue(appSidebarFloatingAtom);
return (
<div
data-testid="header"
style={style}
className={className}
ref={ref}
data-sidebar-floating={appSidebarFloating}
>
{children}
</div>
);
}
});
Header.displayName = 'forwardRef(Header)';
interface PageHeaderProps {
page: Doc;
workspace: Workspace;
}
export function JournalPageHeader({ page, workspace }: PageHeaderProps) {
const { hideShare, hideToday } = useDetailPageHeaderResponsive();
const containerRef = useRef<HTMLDivElement | null>(null);
const [containerWidth, setContainerWidth] = useState(0);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
return observeResize(container, entry => {
setContainerWidth(entry.contentRect.width);
});
}, []);
const { hideShare, hideToday } =
useDetailPageHeaderResponsive(containerWidth);
return (
<Header className={styles.header}>
<Header className={styles.header} ref={containerRef}>
<EditorModeSwitch
docCollection={workspace.docCollection}
pageId={page?.id}
@@ -66,7 +84,11 @@ export function JournalPageHeader({ page, workspace }: PageHeaderProps) {
<JournalTodayButton docCollection={workspace.docCollection} />
)}
<HeaderDivider />
<PageHeaderMenuButton isJournal page={page} />
<PageHeaderMenuButton
isJournal
page={page}
containerWidth={containerWidth}
/>
{page && !hideShare ? (
<SharePageButton workspace={workspace} page={page} />
) : null}
@@ -76,14 +98,25 @@ export function JournalPageHeader({ page, workspace }: PageHeaderProps) {
export function NormalPageHeader({ page, workspace }: PageHeaderProps) {
const titleInputHandleRef = useRef<InlineEditHandle>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
const [containerWidth, setContainerWidth] = useState(0);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
return observeResize(container, entry => {
setContainerWidth(entry.contentRect.width);
});
}, []);
const { hideCollect, hideShare, hidePresent, showDivider } =
useDetailPageHeaderResponsive();
useDetailPageHeaderResponsive(containerWidth);
const onRename = useCallback(() => {
setTimeout(() => titleInputHandleRef.current?.triggerEdit());
}, []);
return (
<Header className={styles.header}>
<Header className={styles.header} ref={containerRef}>
<EditorModeSwitch
docCollection={workspace.docCollection}
pageId={page?.id}
@@ -100,7 +133,11 @@ export function NormalPageHeader({ page, workspace }: PageHeaderProps) {
{runtimeConfig.enableInfoModal ? <InfoButton /> : null}
</>
)}
<PageHeaderMenuButton rename={onRename} page={page} />
<PageHeaderMenuButton
rename={onRename}
page={page}
containerWidth={containerWidth}
/>
</div>
<div className={styles.spacer} />
@@ -1,10 +1,11 @@
import { Scrollable } from '@affine/component';
import { PageDetailSkeleton } from '@affine/component/page-detail-skeleton';
import type { ChatPanel } from '@affine/core/blocksuite/presets/ai';
import { AIProvider } from '@affine/core/blocksuite/presets/ai';
import { PageAIOnboarding } from '@affine/core/components/affine/ai-onboarding';
import { AIIsland } from '@affine/core/components/pure/ai-island';
import { useAppSettingHelper } from '@affine/core/hooks/affine/use-app-setting-helper';
import { RecentDocsService } from '@affine/core/modules/quicksearch';
import { ViewService } from '@affine/core/modules/workbench/services/view';
import type { PageRootService } from '@blocksuite/blocks';
import {
BookmarkBlockService,
@@ -15,6 +16,7 @@ import {
ImageBlockService,
} from '@blocksuite/blocks';
import { DisposableGroup } from '@blocksuite/global/utils';
import { AiIcon, FrameIcon, TocIcon, TodayIcon } from '@blocksuite/icons/rc';
import { type AffineEditorContainer } from '@blocksuite/presets';
import type { Doc as BlockSuiteDoc } from '@blocksuite/store';
import type { Doc } from '@toeverything/infra';
@@ -30,7 +32,14 @@ import {
} from '@toeverything/infra';
import clsx from 'clsx';
import type { ReactElement } from 'react';
import { memo, useCallback, useEffect, useLayoutEffect, useState } from 'react';
import {
memo,
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from 'react';
import { useParams } from 'react-router-dom';
import type { Map as YMap } from 'yjs';
@@ -43,29 +52,26 @@ import { useRegisterBlocksuiteEditorCommands } from '../../../hooks/affine/use-r
import { useActiveBlocksuiteEditor } from '../../../hooks/use-block-suite-editor';
import { usePageDocumentTitle } from '../../../hooks/use-global-state';
import { useNavigateHelper } from '../../../hooks/use-navigate-helper';
import {
MultiTabSidebarBody,
MultiTabSidebarHeaderSwitcher,
sidebarTabs,
type TabOnLoadFn,
} from '../../../modules/multi-tab-sidebar';
import {
RightSidebarService,
RightSidebarViewIsland,
} from '../../../modules/right-sidebar';
import {
useIsActiveView,
ViewBodyIsland,
ViewHeaderIsland,
ViewBody,
ViewHeader,
ViewSidebarTab,
WorkbenchService,
} from '../../../modules/workbench';
import { performanceRenderLogger } from '../../../shared';
import { PageNotFound } from '../../404';
import * as styles from './detail-page.css';
import { DetailPageHeader } from './detail-page-header';
import { EditorChatPanel } from './tabs/chat';
import { EditorFramePanel } from './tabs/frame';
import { EditorJournalPanel } from './tabs/journal';
import { EditorOutline } from './tabs/outline';
const DetailPageImpl = memo(function DetailPageImpl() {
const rightSidebar = useService(RightSidebarService).rightSidebar;
const activeTabName = useLiveData(rightSidebar.activeTabName$);
const workbench = useService(WorkbenchService).workbench;
const view = useService(ViewService).view;
const activeSidebarTab = useLiveData(view.activeSidebarTab$);
const doc = useService(DocService).doc;
const { openPage, jumpToPageBlock, jumpToTag } = useNavigateHelper();
@@ -75,18 +81,12 @@ const DetailPageImpl = memo(function DetailPageImpl() {
const docCollection = workspace.docCollection;
const mode = useLiveData(doc.mode$);
const { appSettings } = useAppSettingHelper();
const [tabOnLoad, setTabOnLoad] = useState<TabOnLoadFn | null>(null);
const chatPanelRef = useRef<ChatPanel | null>(null);
const isActiveView = useIsActiveView();
// TODO(@eyhn): remove jotai here
const [_, setActiveBlockSuiteEditor] = useActiveBlocksuiteEditor();
const setActiveTabName = useCallback(
(...args: Parameters<typeof rightSidebar.setActiveTabName>) =>
rightSidebar.setActiveTabName(...args),
[rightSidebar]
);
useEffect(() => {
if (isActiveView) {
setActiveBlockSuiteEditor(editor);
@@ -95,28 +95,17 @@ const DetailPageImpl = memo(function DetailPageImpl() {
useEffect(() => {
const disposable = AIProvider.slots.requestOpenWithChat.on(params => {
const opened = rightSidebar.isOpen$.value;
const actived = activeTabName === 'chat';
console.log(params);
workbench.openSidebar();
view.activeSidebarTab('chat');
if (!opened) {
rightSidebar.open();
}
if (!actived) {
setActiveTabName('chat');
}
// Save chat parameters:
// * The right sidebar is not open
// * Chat panel is not activated
if (!opened || !actived) {
const callback = AIProvider.genRequestChatCardsFn(params);
setTabOnLoad(() => callback);
} else {
setTabOnLoad(null);
if (chatPanelRef.current) {
const chatCards = chatPanelRef.current.querySelector('chat-cards');
if (chatCards) chatCards.temporaryParams = params;
}
});
return () => disposable.dispose();
}, [activeTabName, rightSidebar, setActiveTabName]);
}, [activeSidebarTab, view, workbench]);
useEffect(() => {
if (isActiveView) {
@@ -224,16 +213,13 @@ const DetailPageImpl = memo(function DetailPageImpl() {
]
);
const isWindowsDesktop = environment.isDesktop && environment.isWindows;
return (
<>
<ViewHeaderIsland>
<ViewHeader>
<DetailPageHeader page={doc.blockSuiteDoc} workspace={workspace} />
</ViewHeaderIsland>
<ViewBodyIsland>
</ViewHeader>
<ViewBody>
<div className={styles.mainContainer}>
<AIIsland />
{/* Add a key to force rerender when page changed, to avoid error boundary persisting. */}
<AffineErrorBoundary key={doc.id}>
<TopTip pageId={doc.id} workspace={workspace} />
@@ -260,39 +246,24 @@ const DetailPageImpl = memo(function DetailPageImpl() {
</AffineErrorBoundary>
{isInTrash ? <TrashPageFooter /> : null}
</div>
</ViewBodyIsland>
</ViewBody>
<ViewSidebarTab tabId="chat" icon={<AiIcon />} unmountOnInactive={false}>
<EditorChatPanel editor={editor} ref={chatPanelRef} />
</ViewSidebarTab>
<ViewSidebarTab tabId="journal" icon={<TodayIcon />}>
<EditorJournalPanel />
</ViewSidebarTab>
<ViewSidebarTab tabId="outline" icon={<TocIcon />}>
<EditorOutline editor={editor} />
</ViewSidebarTab>
<ViewSidebarTab tabId="frame" icon={<FrameIcon />}>
<EditorFramePanel editor={editor} />
</ViewSidebarTab>
<RightSidebarViewIsland
active={isActiveView}
header={
!isWindowsDesktop ? (
<MultiTabSidebarHeaderSwitcher
activeTabName={activeTabName ?? sidebarTabs[0]?.name}
setActiveTabName={setActiveTabName}
tabs={sidebarTabs}
/>
) : null
}
body={
<MultiTabSidebarBody
editor={editor}
tab={
sidebarTabs.find(ext => ext.name === activeTabName) ??
sidebarTabs[0]
}
onLoad={tabOnLoad}
>
{/* Show switcher in body for windows desktop */}
{isWindowsDesktop && (
<MultiTabSidebarHeaderSwitcher
activeTabName={activeTabName ?? sidebarTabs[0]?.name}
setActiveTabName={setActiveTabName}
tabs={sidebarTabs}
/>
)}
</MultiTabSidebarBody>
}
/>
<GlobalPageHistoryModal />
<PageAIOnboarding />
</>
@@ -0,0 +1,6 @@
import { style } from '@vanilla-extract/css';
export const root = style({
display: 'flex',
height: '100%',
width: '100%',
});
@@ -0,0 +1,73 @@
import { ChatPanel } from '@affine/core/blocksuite/presets/ai';
import { assertExists } from '@blocksuite/global/utils';
import type { AffineEditorContainer } from '@blocksuite/presets';
import { forwardRef, useCallback, useEffect, useRef } from 'react';
import * as styles from './chat.css';
export interface SidebarTabProps {
editor: AffineEditorContainer | null;
onLoad?: ((component: HTMLElement) => void) | null;
}
// A wrapper for CopilotPanel
export const EditorChatPanel = forwardRef(function EditorChatPanel(
{ editor, onLoad }: SidebarTabProps,
ref: React.ForwardedRef<ChatPanel>
) {
const chatPanelRef = useRef<ChatPanel | null>(null);
const onRefChange = useCallback((container: HTMLDivElement | null) => {
if (container) {
assertExists(chatPanelRef.current, 'chat panel should be initialized');
container.append(chatPanelRef.current);
}
}, []);
useEffect(() => {
if (onLoad && chatPanelRef.current) {
(chatPanelRef.current as ChatPanel).updateComplete
.then(() => {
if (ref) {
if (typeof ref === 'function') {
ref(chatPanelRef.current);
} else {
ref.current = chatPanelRef.current;
}
}
})
.catch(console.error);
}
}, [onLoad, ref]);
useEffect(() => {
if (!editor) return;
const pageService = editor.host.spec.getService('affine:page');
const disposable = [
pageService.slots.docLinkClicked.on(() => {
(chatPanelRef.current as ChatPanel).doc = editor.doc;
}),
pageService.docModeService.onModeChange(() => {
if (!editor.host) return;
(chatPanelRef.current as ChatPanel).host = editor.host;
}),
];
return () => disposable.forEach(d => d.dispose());
}, [editor]);
if (!editor) {
return;
}
if (!chatPanelRef.current) {
chatPanelRef.current = new ChatPanel();
}
(chatPanelRef.current as ChatPanel).host = editor.host;
(chatPanelRef.current as ChatPanel).doc = editor.doc;
// (copilotPanelRef.current as CopilotPanel).fitPadding = [20, 20, 20, 20];
return <div className={styles.root} ref={onRefChange} />;
});
@@ -0,0 +1,6 @@
import { style } from '@vanilla-extract/css';
export const root = style({
display: 'flex',
height: '100%',
width: '100%',
});
@@ -0,0 +1,37 @@
import { assertExists } from '@blocksuite/global/utils';
import type { AffineEditorContainer } from '@blocksuite/presets';
import { FramePanel } from '@blocksuite/presets';
import { useCallback, useRef } from 'react';
import * as styles from './frame.css';
// A wrapper for FramePanel
export const EditorFramePanel = ({
editor,
}: {
editor: AffineEditorContainer | null;
}) => {
const framePanelRef = useRef<FramePanel | null>(null);
const onRefChange = useCallback((container: HTMLDivElement | null) => {
if (container) {
assertExists(framePanelRef.current, 'frame panel should be initialized');
container.append(framePanelRef.current);
}
}, []);
if (!editor) {
return;
}
if (!framePanelRef.current) {
framePanelRef.current = new FramePanel();
}
if (editor !== framePanelRef.current?.editor) {
(framePanelRef.current as FramePanel).editor = editor;
(framePanelRef.current as FramePanel).fitPadding = [20, 20, 20, 20];
}
return <div className={styles.root} ref={onRefChange} />;
};
@@ -0,0 +1,227 @@
import { cssVar } from '@toeverything/theme';
import { style } from '@vanilla-extract/css';
const interactive = style({
position: 'relative',
cursor: 'pointer',
selectors: {
'&:hover': {
backgroundColor: cssVar('hoverColor'),
},
'&::before': {
content: '""',
position: 'absolute',
inset: 0,
opacity: 0,
borderRadius: 'inherit',
boxShadow: `0 0 0 3px ${cssVar('primaryColor')}`,
pointerEvents: 'none',
},
'&::after': {
content: '""',
position: 'absolute',
inset: 0,
borderRadius: 'inherit',
boxShadow: `0 0 0 0px ${cssVar('primaryColor')}`,
pointerEvents: 'none',
},
'&:focus-visible::before': {
opacity: 0.2,
},
'&:focus-visible::after': {
boxShadow: `0 0 0 1px ${cssVar('primaryColor')}`,
},
},
});
export const calendar = style({
padding: '16px',
});
export const journalPanel = style({
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'stretch',
overflow: 'hidden',
});
export const dailyCount = style({
height: 0,
flexGrow: 1,
display: 'flex',
flexDirection: 'column',
gap: 8,
});
export const dailyCountHeader = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '0 16px',
gap: 8,
});
export const dailyCountNav = style([
interactive,
{
height: 28,
width: 0,
flex: 1,
fontWeight: 500,
fontSize: 14,
padding: '4px 8px',
whiteSpace: 'nowrap',
borderRadius: 8,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: cssVar('textSecondaryColor'),
transition: 'all .3s',
selectors: {
'&[aria-selected="true"]': {
backgroundColor: cssVar('backgroundTertiaryColor'),
color: cssVar('textPrimaryColor'),
},
},
},
]);
export const dailyCountContainer = style({
height: 0,
flexGrow: 1,
display: 'flex',
width: `calc(var(--item-count) * 100%)`,
transition: 'transform .15s ease',
transform:
'translateX(calc(var(--active-index) * 100% / var(--item-count) * -1))',
});
export const dailyCountItem = style({
width: 'calc(100% / var(--item-count))',
height: '100%',
});
export const dailyCountContent = style({
padding: '8px 16px',
display: 'flex',
flexDirection: 'column',
gap: 4,
});
export const dailyCountEmpty = style({
width: '100%',
height: '100%',
maxHeight: 220,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
lineHeight: '24px',
fontSize: 15,
color: cssVar('textSecondaryColor'),
textAlign: 'center',
padding: '0 70px',
fontWeight: 400,
});
// page item
export const pageItem = style([
interactive,
{
width: '100%',
display: 'flex',
alignItems: 'center',
borderRadius: 4,
padding: '0 4px',
gap: 8,
height: 30,
selectors: {
'&[aria-selected="true"]': {
backgroundColor: cssVar('hoverColor'),
},
},
},
]);
export const pageItemIcon = style({
width: 20,
height: 20,
color: cssVar('iconColor'),
});
export const pageItemLabel = style({
width: 0,
flex: 1,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
fontWeight: 500,
fontSize: cssVar('fontSm'),
color: cssVar('textPrimaryColor'),
textAlign: 'left',
selectors: {
'[aria-selected="true"] &': {
// TODO(@catsjuice): wait for design
color: cssVar('primaryColor'),
},
},
});
// conflict
export const journalConflictBlock = style({
padding: '0 16px 16px 16px',
});
export const journalConflictWrapper = style({
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(170px, 1fr))',
rowGap: 4,
columnGap: 8,
});
export const journalConflictMoreTrigger = style([
interactive,
{
color: cssVar('textSecondaryColor'),
height: 30,
borderRadius: 4,
padding: '0px 8px',
fontSize: cssVar('fontSm'),
display: 'flex',
alignItems: 'center',
},
]);
// customize date-picker cell
export const journalDateCell = style([
interactive,
{
width: '100%',
height: '100%',
borderRadius: 8,
fontSize: cssVar('fontSm'),
color: cssVar('textPrimaryColor'),
fontWeight: 400,
position: 'relative',
selectors: {
'&[data-is-today="true"]': {
fontWeight: 600,
color: cssVar('brandColor'),
},
'&[data-not-current-month="true"]': {
color: cssVar('black10'),
},
'&[data-selected="true"]': {
backgroundColor: cssVar('brandColor'),
fontWeight: 500,
color: cssVar('pureWhite'),
},
'&[data-is-journal="false"][data-selected="true"]': {
backgroundColor: 'transparent',
color: 'var(--affine-text-primary-color)',
fontWeight: 500,
border: `1px solid ${cssVar('primaryColor')}`,
},
},
},
]);
export const journalDateCellDot = style({
width: 4,
height: 4,
borderRadius: '50%',
backgroundColor: cssVar('primaryColor'),
position: 'absolute',
bottom: 0,
left: '50%',
transform: 'translateX(-50%)',
});
@@ -0,0 +1,382 @@
import type { DateCell } from '@affine/component';
import { DatePicker, IconButton, Menu, Scrollable } from '@affine/component';
import { MoveToTrash } from '@affine/core/components/page-list';
import { useTrashModalHelper } from '@affine/core/hooks/affine/use-trash-modal-helper';
import {
useJournalHelper,
useJournalInfoHelper,
useJournalRouteHelper,
} from '@affine/core/hooks/use-journal';
import { useNavigateHelper } from '@affine/core/hooks/use-navigate-helper';
import { useI18n } from '@affine/i18n';
import {
EdgelessIcon,
MoreHorizontalIcon,
PageIcon,
TodayIcon,
} from '@blocksuite/icons/rc';
import type { DocRecord } from '@toeverything/infra';
import {
DocService,
DocsService,
useLiveData,
useService,
WorkspaceService,
} from '@toeverything/infra';
import { assignInlineVars } from '@vanilla-extract/dynamic';
import clsx from 'clsx';
import dayjs from 'dayjs';
import type { HTMLAttributes, PropsWithChildren, ReactNode } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import * as styles from './journal.css';
/**
* @internal
*/
const CountDisplay = ({
count,
max = 99,
...attrs
}: { count: number; max?: number } & HTMLAttributes<HTMLSpanElement>) => {
return <span {...attrs}>{count > max ? `${max}+` : count}</span>;
};
interface PageItemProps extends HTMLAttributes<HTMLDivElement> {
docRecord: DocRecord;
right?: ReactNode;
}
const PageItem = ({ docRecord, right, className, ...attrs }: PageItemProps) => {
const title = useLiveData(docRecord.title$);
const mode = useLiveData(docRecord.mode$);
const workspace = useService(WorkspaceService).workspace;
const { isJournal } = useJournalInfoHelper(
workspace.docCollection,
docRecord.id
);
const Icon = isJournal
? TodayIcon
: mode === 'edgeless'
? EdgelessIcon
: PageIcon;
return (
<div
aria-label={title}
className={clsx(className, styles.pageItem)}
{...attrs}
>
<div className={styles.pageItemIcon}>
<Icon width={20} height={20} />
</div>
<span className={styles.pageItemLabel}>{title}</span>
{right}
</div>
);
};
type NavItemName = 'createdToday' | 'updatedToday';
interface NavItem {
name: NavItemName;
label: string;
count: number;
}
interface JournalBlockProps {
date: dayjs.Dayjs;
}
export const EditorJournalPanel = () => {
const t = useI18n();
const doc = useService(DocService).doc;
const workspace = useService(WorkspaceService).workspace;
const { journalDate, isJournal } = useJournalInfoHelper(
workspace.docCollection,
doc.id
);
const { openJournal } = useJournalRouteHelper(workspace.docCollection);
const [date, setDate] = useState(dayjs().format('YYYY-MM-DD'));
useEffect(() => {
journalDate && setDate(journalDate.format('YYYY-MM-DD'));
}, [journalDate]);
const onDateSelect = useCallback(
(date: string) => {
if (journalDate && dayjs(date).isSame(dayjs(journalDate))) return;
openJournal(date);
},
[journalDate, openJournal]
);
const customDayRenderer = useCallback(
(cell: DateCell) => {
// TODO(@catsjuice): add a dot to indicate journal
// has performance issue for now, better to calculate it in advance
// const hasJournal = !!getJournalsByDate(cell.date.format('YYYY-MM-DD'))?.length;
const hasJournal = false;
return (
<button
className={styles.journalDateCell}
data-is-date-cell
tabIndex={cell.focused ? 0 : -1}
data-is-today={cell.isToday}
data-not-current-month={cell.notCurrentMonth}
data-selected={cell.selected}
data-is-journal={isJournal}
data-has-journal={hasJournal}
>
{cell.label}
{hasJournal && !cell.selected ? (
<div className={styles.journalDateCellDot} />
) : null}
</button>
);
},
[isJournal]
);
return (
<div className={styles.journalPanel} data-is-journal={isJournal}>
<div className={styles.calendar}>
<DatePicker
weekDays={t['com.affine.calendar-date-picker.week-days']()}
monthNames={t['com.affine.calendar-date-picker.month-names']()}
todayLabel={t['com.affine.calendar-date-picker.today']()}
customDayRenderer={customDayRenderer}
value={date}
onChange={onDateSelect}
/>
</div>
<JournalConflictBlock date={dayjs(date)} />
<JournalDailyCountBlock date={dayjs(date)} />
</div>
);
};
const sortPagesByDate = (
docs: DocRecord[],
field: 'updatedDate' | 'createDate',
order: 'asc' | 'desc' = 'desc'
) => {
return [...docs].sort((a, b) => {
return (
(order === 'asc' ? 1 : -1) *
dayjs(b.meta$.value[field]).diff(dayjs(a.meta$.value[field]))
);
});
};
const DailyCountEmptyFallback = ({ name }: { name: NavItemName }) => {
const t = useI18n();
return (
<div className={styles.dailyCountEmpty}>
{name === 'createdToday'
? t['com.affine.journal.daily-count-created-empty-tips']()
: t['com.affine.journal.daily-count-updated-empty-tips']()}
</div>
);
};
const JournalDailyCountBlock = ({ date }: JournalBlockProps) => {
const workspace = useService(WorkspaceService).workspace;
const nodeRef = useRef<HTMLDivElement>(null);
const t = useI18n();
const [activeItem, setActiveItem] = useState<NavItemName>('createdToday');
const docRecords = useLiveData(useService(DocsService).list.docs$);
const navigateHelper = useNavigateHelper();
const getTodaysPages = useCallback(
(field: 'createDate' | 'updatedDate') => {
return sortPagesByDate(
docRecords.filter(docRecord => {
const meta = docRecord.meta$.value;
if (meta.trash) return false;
return meta[field] && dayjs(meta[field]).isSame(date, 'day');
}),
field
);
},
[date, docRecords]
);
const createdToday = useMemo(
() => getTodaysPages('createDate'),
[getTodaysPages]
);
const updatedToday = useMemo(
() => getTodaysPages('updatedDate'),
[getTodaysPages]
);
const headerItems = useMemo<NavItem[]>(
() => [
{
name: 'createdToday',
label: t['com.affine.journal.created-today'](),
count: createdToday.length,
},
{
name: 'updatedToday',
label: t['com.affine.journal.updated-today'](),
count: updatedToday.length,
},
],
[createdToday.length, t, updatedToday.length]
);
const activeIndex = headerItems.findIndex(({ name }) => name === activeItem);
const vars = assignInlineVars({
'--active-index': String(activeIndex),
'--item-count': String(headerItems.length),
});
return (
<div className={styles.dailyCount} style={vars}>
<header className={styles.dailyCountHeader}>
{headerItems.map(({ label, count, name }, index) => {
return (
<button
onClick={() => setActiveItem(name)}
aria-selected={activeItem === name}
className={styles.dailyCountNav}
key={index}
>
{label}
&nbsp;
<CountDisplay count={count} />
</button>
);
})}
</header>
<main className={styles.dailyCountContainer} data-active={activeItem}>
{headerItems.map(({ name }) => {
const renderList =
name === 'createdToday' ? createdToday : updatedToday;
if (renderList.length === 0)
return (
<div key={name} className={styles.dailyCountItem}>
<DailyCountEmptyFallback name={name} />
</div>
);
return (
<Scrollable.Root key={name} className={styles.dailyCountItem}>
<Scrollable.Scrollbar />
<Scrollable.Viewport>
<div className={styles.dailyCountContent} ref={nodeRef}>
{renderList.map((pageRecord, index) => (
<PageItem
onClick={() =>
navigateHelper.openPage(workspace.id, pageRecord.id)
}
tabIndex={name === activeItem ? 0 : -1}
key={index}
docRecord={pageRecord}
/>
))}
</div>
</Scrollable.Viewport>
</Scrollable.Root>
);
})}
</main>
</div>
);
};
const MAX_CONFLICT_COUNT = 5;
interface ConflictListProps
extends PropsWithChildren,
HTMLAttributes<HTMLDivElement> {
docRecords: DocRecord[];
}
const ConflictList = ({
docRecords,
children,
className,
...attrs
}: ConflictListProps) => {
const navigateHelper = useNavigateHelper();
const workspace = useService(WorkspaceService).workspace;
const currentDoc = useService(DocService).doc;
const { setTrashModal } = useTrashModalHelper(workspace.docCollection);
const handleOpenTrashModal = useCallback(
(docRecord: DocRecord) => {
setTrashModal({
open: true,
pageIds: [docRecord.id],
pageTitles: [docRecord.title$.value],
});
},
[setTrashModal]
);
return (
<div className={clsx(styles.journalConflictWrapper, className)} {...attrs}>
{docRecords.map(docRecord => {
const isCurrent = docRecord.id === currentDoc.id;
return (
<PageItem
aria-selected={isCurrent}
docRecord={docRecord}
key={docRecord.id}
right={
<Menu
items={
<MoveToTrash
onSelect={() => handleOpenTrashModal(docRecord)}
/>
}
>
<IconButton type="plain">
<MoreHorizontalIcon />
</IconButton>
</Menu>
}
onClick={() => navigateHelper.openPage(workspace.id, docRecord.id)}
/>
);
})}
{children}
</div>
);
};
const JournalConflictBlock = ({ date }: JournalBlockProps) => {
const t = useI18n();
const workspace = useService(WorkspaceService).workspace;
const docRecordList = useService(DocsService).list;
const journalHelper = useJournalHelper(workspace.docCollection);
const docs = journalHelper.getJournalsByDate(date.format('YYYY-MM-DD'));
const docRecords = useLiveData(
docRecordList.docs$.map(records =>
records.filter(v => {
return docs.some(doc => doc.id === v.id);
})
)
);
if (docs.length <= 1) return null;
return (
<ConflictList
className={styles.journalConflictBlock}
docRecords={docRecords.slice(0, MAX_CONFLICT_COUNT)}
>
{docs.length > MAX_CONFLICT_COUNT ? (
<Menu
items={
<ConflictList docRecords={docRecords.slice(MAX_CONFLICT_COUNT)} />
}
>
<div className={styles.journalConflictMoreTrigger}>
{t['com.affine.journal.conflict-show-more']({
count: (docRecords.length - MAX_CONFLICT_COUNT).toFixed(0),
})}
</div>
</Menu>
) : null}
</ConflictList>
);
};
@@ -0,0 +1,6 @@
import { style } from '@vanilla-extract/css';
export const root = style({
display: 'flex',
height: '100%',
width: '100%',
});
@@ -0,0 +1,37 @@
import { assertExists } from '@blocksuite/global/utils';
import type { AffineEditorContainer } from '@blocksuite/presets';
import { OutlinePanel } from '@blocksuite/presets';
import { useCallback, useRef } from 'react';
import * as styles from './outline.css';
// A wrapper for TOCNotesPanel
export const EditorOutline = ({
editor,
}: {
editor: AffineEditorContainer | null;
}) => {
const outlinePanelRef = useRef<OutlinePanel | null>(null);
const onRefChange = useCallback((container: HTMLDivElement | null) => {
if (container) {
assertExists(outlinePanelRef.current, 'toc panel should be initialized');
container.append(outlinePanelRef.current);
}
}, []);
if (!editor) {
return;
}
if (!outlinePanelRef.current) {
outlinePanelRef.current = new OutlinePanel();
}
if (editor !== outlinePanelRef.current?.editor) {
(outlinePanelRef.current as OutlinePanel).editor = editor;
(outlinePanelRef.current as OutlinePanel).fitPadding = [20, 20, 20, 20];
}
return <div className={styles.root} ref={onRefChange} />;
};
@@ -1,21 +1,16 @@
import { RightSidebarService } from '@affine/core/modules/right-sidebar';
import { WorkbenchService } from '@affine/core/modules/workbench';
import { ViewService } from '@affine/core/modules/workbench/services/view';
import { useViewPosition } from '@affine/core/modules/workbench/view/use-view-position';
import { DocService, useLiveData, useService } from '@toeverything/infra';
export const useDetailPageHeaderResponsive = () => {
export const useDetailPageHeaderResponsive = (availableWidth: number) => {
const mode = useLiveData(useService(DocService).doc.mode$);
const view = useService(ViewService).view;
const workbench = useService(WorkbenchService).workbench;
const availableWidth = useLiveData(view.headerContentWidth$);
const viewPosition = useViewPosition();
const workbenchViewsCount = useLiveData(
workbench.views$.map(views => views.length)
);
const rightSidebar = useService(RightSidebarService).rightSidebar;
const rightSidebarOpen = useLiveData(rightSidebar.isOpen$);
const rightSidebarOpen = useLiveData(workbench.sidebarOpen$);
// share button should be hidden once split-view is enabled
const hideShare = availableWidth < 500 || workbenchViewsCount > 1;
@@ -15,7 +15,6 @@ import { useParams } from 'react-router-dom';
import { AffineErrorBoundary } from '../../components/affine/affine-error-boundary';
import { WorkspaceLayout } from '../../layouts/workspace-layout';
import { RightSidebarContainer } from '../../modules/right-sidebar';
import { WorkbenchRoot } from '../../modules/workbench';
import { AllWorkspaceModals } from '../../providers/modal-provider';
import { performanceRenderLogger } from '../../shared';
@@ -163,7 +162,6 @@ export const Component = (): ReactElement => {
<AffineErrorBoundary height="100vh">
<WorkspaceLayout>
<WorkbenchRoot />
<RightSidebarContainer />
</WorkspaceLayout>
</AffineErrorBoundary>
</FrameworkScope>
@@ -4,10 +4,7 @@ import {
} from '@affine/core/components/page-list';
import { useBlockSuiteDocMeta } from '@affine/core/hooks/use-block-suite-page-meta';
import { TagService } from '@affine/core/modules/tag';
import {
ViewBodyIsland,
ViewHeaderIsland,
} from '@affine/core/modules/workbench';
import { ViewBody, ViewHeader } from '@affine/core/modules/workbench';
import { useLiveData, useService, WorkspaceService } from '@toeverything/infra';
import { useMemo } from 'react';
import { useParams } from 'react-router-dom';
@@ -37,10 +34,10 @@ export const TagDetail = ({ tagId }: { tagId?: string }) => {
return (
<>
<ViewHeaderIsland>
<ViewHeader>
<TagDetailHeader />
</ViewHeaderIsland>
<ViewBodyIsland>
</ViewHeader>
<ViewBody>
<div className={styles.body}>
{filteredPageMetas.length > 0 ? (
<VirtualizedPageList
@@ -60,7 +57,7 @@ export const TagDetail = ({ tagId }: { tagId?: string }) => {
/>
)}
</div>
</ViewBodyIsland>
</ViewBody>
</>
);
};
@@ -9,7 +9,7 @@ import { assertExists } from '@blocksuite/global/utils';
import { DeleteIcon } from '@blocksuite/icons/rc';
import { useService, WorkspaceService } from '@toeverything/infra';
import { ViewBodyIsland, ViewHeaderIsland } from '../../modules/workbench';
import { ViewBody, ViewHeader } from '../../modules/workbench';
import { EmptyPageList } from './page-list-empty';
import * as styles from './trash-page.css';
@@ -39,10 +39,10 @@ export const TrashPage = () => {
return (
<>
<ViewHeaderIsland>
<ViewHeader>
<TrashHeader />
</ViewHeaderIsland>
<ViewBodyIsland>
</ViewHeader>
<ViewBody>
<div className={styles.body}>
{filteredPageMetas.length > 0 ? (
<VirtualizedTrashList />
@@ -53,7 +53,7 @@ export const TrashPage = () => {
/>
)}
</div>
</ViewBodyIsland>
</ViewBody>
</>
);
};