feat(core): split right sidebar (#5971)

https://github.com/toeverything/AFFiNE/assets/13579374/c846c069-aa32-445d-b59b-b773a9b05ced

Now each view has a general container, the yellow area is the general container part, and the green part is the routing specific part.

![CleanShot 2024-03-01 at 11.47.35@2x.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/g3jz87HxbjOJpXV3FPT7/9a9f6ad6-2207-42e5-ae66-f7426bc9f3fc.png)
This commit is contained in:
EYHN
2024-03-04 06:42:12 +00:00
parent e2a31ea1fc
commit c599715963
88 changed files with 1393 additions and 1238 deletions

View File

@@ -1,5 +1,6 @@
import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { SidebarIcon } from '@blocksuite/icons'; import { SidebarIcon } from '@blocksuite/icons';
import clsx from 'clsx';
import { useAtom } from 'jotai'; import { useAtom } from 'jotai';
import { IconButton } from '../../../ui/button'; import { IconButton } from '../../../ui/button';
@@ -7,7 +8,13 @@ import { Tooltip } from '../../../ui/tooltip';
import { appSidebarOpenAtom } from '../index.jotai'; import { appSidebarOpenAtom } from '../index.jotai';
import * as styles from './sidebar-switch.css'; import * as styles from './sidebar-switch.css';
export const SidebarSwitch = ({ show }: { show: boolean }) => { export const SidebarSwitch = ({
show = true,
className,
}: {
show?: boolean;
className?: string;
}) => {
const [open, setOpen] = useAtom(appSidebarOpenAtom); const [open, setOpen] = useAtom(appSidebarOpenAtom);
const t = useAFFiNEI18N(); const t = useAFFiNEI18N();
const tooltipContent = open const tooltipContent = open
@@ -22,7 +29,7 @@ export const SidebarSwitch = ({ show }: { show: boolean }) => {
side={open ? 'bottom' : 'right'} side={open ? 'bottom' : 'right'}
> >
<IconButton <IconButton
className={styles.sidebarSwitch} className={clsx(styles.sidebarSwitch, className)}
data-show={show} data-show={show}
size="large" size="large"
data-testid={`app-sidebar-arrow-button-${open ? 'collapse' : 'expand'}`} data-testid={`app-sidebar-arrow-button-${open ? 'collapse' : 'expand'}`}

View File

@@ -3,11 +3,11 @@ import { style } from '@vanilla-extract/css';
export const notFoundPageContainer = style({ export const notFoundPageContainer = style({
fontSize: cssVar('fontBase'), fontSize: cssVar('fontBase'),
color: cssVar('textPrimaryColor'), color: cssVar('textPrimaryColor'),
height: '100vh', height: '100%',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
width: '100vw', width: '100%',
padding: '0 20px', padding: '0 20px',
}); });
export const wrapper = style({ export const wrapper = style({

View File

@@ -13,7 +13,7 @@ export const root = style({
}, },
position: 'relative', position: 'relative',
width: panelWidthVar, width: panelWidthVar,
minWidth: 0, minWidth: panelWidthVar,
height: '100%', height: '100%',
selectors: { selectors: {
'&[data-is-floating="true"]': { '&[data-is-floating="true"]': {

View File

@@ -48,13 +48,12 @@ export const mainContainerStyle = style({
zIndex: 0, zIndex: 0,
// it will create stacking context to limit layer of child elements and be lower than after auto zIndex // it will create stacking context to limit layer of child elements and be lower than after auto zIndex
width: 0, width: 0,
display: 'flex',
flex: 1, flex: 1,
maxWidth: '100%', maxWidth: '100%',
backgroundColor: cssVar('backgroundPrimaryColor'),
selectors: { selectors: {
'&[data-show-padding="true"]': { '&[data-show-padding="true"]': {
margin: '8px', margin: '8px',
borderRadius: '5px',
overflow: 'hidden', overflow: 'hidden',
// todo: is this performance intensive? // todo: is this performance intensive?
filter: 'drop-shadow(0px 0px 4px rgba(66,65,73,.14))', filter: 'drop-shadow(0px 0px 4px rgba(66,65,73,.14))',
@@ -66,9 +65,6 @@ export const mainContainerStyle = style({
}, },
}, },
}, },
'&[data-show-padding="true"][data-is-macos="true"]': {
borderRadius: '6px',
},
'&[data-show-padding="true"]:before': { '&[data-show-padding="true"]:before': {
content: '""', content: '""',
position: 'absolute', position: 'absolute',

View File

@@ -266,7 +266,6 @@ affine-block-hub {
} }
} }
button,
input, input,
select, select,
textarea textarea

View File

@@ -6,7 +6,7 @@ export const scrollableContainerRoot = style({
'--scrollbar-width': '8px', '--scrollbar-width': '8px',
}, },
height: '100%', height: '100%',
overflow: 'hidden', overflowY: 'hidden',
}); });
export const scrollTopBorder = style({ export const scrollTopBorder = style({
position: 'absolute', position: 'absolute',

View File

@@ -10,6 +10,7 @@ import clsx from 'clsx';
import type React from 'react'; import type React from 'react';
import { import {
forwardRef, forwardRef,
type RefObject,
useEffect, useEffect,
useLayoutEffect, useLayoutEffect,
useMemo, useMemo,
@@ -62,7 +63,7 @@ function findBlockElementById(container: HTMLElement, blockId: string) {
// a workaround for returning the webcomponent for the given block id // a workaround for returning the webcomponent for the given block id
// by iterating over the children of the rendered dom tree // by iterating over the children of the rendered dom tree
const useBlockElementById = ( const useBlockElementById = (
container: HTMLElement | null, containerRef: RefObject<HTMLElement | null>,
blockId: string | undefined, blockId: string | undefined,
timeout = 1000 timeout = 1000
) => { ) => {
@@ -74,10 +75,10 @@ const useBlockElementById = (
let canceled = false; let canceled = false;
const start = Date.now(); const start = Date.now();
function run() { function run() {
if (canceled || !container || !blockId) { if (canceled || !containerRef.current || !blockId) {
return; return;
} }
const element = findBlockElementById(container, blockId); const element = findBlockElementById(containerRef.current, blockId);
if (element) { if (element) {
setBlockElement(element); setBlockElement(element);
} else if (Date.now() - start < timeout) { } else if (Date.now() - start < timeout) {
@@ -88,7 +89,7 @@ const useBlockElementById = (
return () => { return () => {
canceled = true; canceled = true;
}; };
}, [container, blockId, timeout]); }, [blockId, containerRef, timeout]);
return blockElement; return blockElement;
}; };
@@ -200,10 +201,7 @@ export const BlocksuiteEditorContainer = forwardRef<
} }
}, [affineEditorContainerProxy, ref]); }, [affineEditorContainerProxy, ref]);
const blockElement = useBlockElementById( const blockElement = useBlockElementById(rootRef, defaultSelectedBlockId);
rootRef.current,
defaultSelectedBlockId
);
useEffect(() => { useEffect(() => {
if (blockElement) { if (blockElement) {

View File

@@ -3,4 +3,10 @@ import { style } from '@vanilla-extract/css';
export const title = style({ export const title = style({
fontWeight: 500, fontWeight: 500,
color: cssVar('textPrimaryColor'), color: cssVar('textPrimaryColor'),
selectors: {
'&[data-editing="true"]': {
['WebkitAppRegion' as string]: 'no-drag',
flexGrow: 1,
},
},
}); });

View File

@@ -1,6 +1,5 @@
import './page-detail-editor.css'; import './page-detail-editor.css';
import { useActiveBlocksuiteEditor } from '@affine/core/hooks/use-block-suite-editor';
import { useBlockSuiteWorkspacePage } from '@affine/core/hooks/use-block-suite-workspace-page'; import { useBlockSuiteWorkspacePage } from '@affine/core/hooks/use-block-suite-workspace-page';
import { assertExists, DisposableGroup } from '@blocksuite/global/utils'; import { assertExists, DisposableGroup } from '@blocksuite/global/utils';
import type { AffineEditorContainer } from '@blocksuite/presets'; import type { AffineEditorContainer } from '@blocksuite/presets';
@@ -70,14 +69,12 @@ const PageDetailEditorMain = memo(function PageDetailEditorMain({
return fontStyle.value; return fontStyle.value;
}, [appSettings.fontStyle]); }, [appSettings.fontStyle]);
const [, setActiveBlocksuiteEditor] = useActiveBlocksuiteEditor();
const blockId = useRouterHash(); const blockId = useRouterHash();
const onLoadEditor = useCallback( const onLoadEditor = useCallback(
(editor: AffineEditorContainer) => { (editor: AffineEditorContainer) => {
// debug current detail editor // debug current detail editor
globalThis.currentEditor = editor; globalThis.currentEditor = editor;
setActiveBlocksuiteEditor(editor);
const disposableGroup = new DisposableGroup(); const disposableGroup = new DisposableGroup();
disposableGroup.add( disposableGroup.add(
page.slots.blockUpdated.once(() => { page.slots.blockUpdated.once(() => {
@@ -99,10 +96,9 @@ const PageDetailEditorMain = memo(function PageDetailEditorMain({
return () => { return () => {
disposableGroup.dispose(); disposableGroup.dispose();
setActiveBlocksuiteEditor(null);
}; };
}, },
[onLoad, page, setActiveBlocksuiteEditor] [onLoad, page]
); );
return ( return (

View File

@@ -1,9 +1,8 @@
import { Checkbox } from '@affine/component'; import { Checkbox } from '@affine/component';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { useDraggable } from '@dnd-kit/core'; import { useDraggable } from '@dnd-kit/core';
import { type PropsWithChildren, useCallback, useMemo } from 'react'; import { type PropsWithChildren, useCallback, useMemo } from 'react';
import { WorkbenchLink } from '../../../modules/workbench/workbench-link'; import { WorkbenchLink } from '../../../modules/workbench/view/workbench-link';
import type { DraggableTitleCellData, PageListItemProps } from '../types'; import type { DraggableTitleCellData, PageListItemProps } from '../types';
import { ColWrapper, formatDate, stopPropagation } from '../utils'; import { ColWrapper, formatDate, stopPropagation } from '../utils';
import * as styles from './page-list-item.css'; import * as styles from './page-list-item.css';
@@ -13,14 +12,13 @@ const ListTitleCell = ({
title, title,
preview, preview,
}: Pick<PageListItemProps, 'title' | 'preview'>) => { }: Pick<PageListItemProps, 'title' | 'preview'>) => {
const t = useAFFiNEI18N();
return ( return (
<div data-testid="page-list-item-title" className={styles.titleCell}> <div data-testid="page-list-item-title" className={styles.titleCell}>
<div <div
data-testid="page-list-item-title-text" data-testid="page-list-item-title-text"
className={styles.titleCellMain} className={styles.titleCellMain}
> >
{title || t['Untitled']()} {title}
</div> </div>
{preview ? ( {preview ? (
<div <div

View File

@@ -1,4 +1,3 @@
import { useBlockSuiteWorkspacePageTitle } from '@affine/core/hooks/use-block-suite-workspace-page-title';
import { useJournalInfoHelper } from '@affine/core/hooks/use-journal'; import { useJournalInfoHelper } from '@affine/core/hooks/use-journal';
import type { Tag } from '@affine/env/filter'; import type { Tag } from '@affine/env/filter';
import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { useAFFiNEI18N } from '@affine/i18n/hooks';
@@ -12,6 +11,9 @@ import {
} from '@blocksuite/icons'; } from '@blocksuite/icons';
import type { DocMeta, Workspace } from '@blocksuite/store'; import type { DocMeta, Workspace } from '@blocksuite/store';
import * as Collapsible from '@radix-ui/react-collapsible'; import * as Collapsible from '@radix-ui/react-collapsible';
import { useService } from '@toeverything/infra';
import { useLiveData } from '@toeverything/infra';
import { PageRecordList } from '@toeverything/infra';
import clsx from 'clsx'; import clsx from 'clsx';
import { selectAtom } from 'jotai/utils'; import { selectAtom } from 'jotai/utils';
import { type MouseEventHandler, useCallback, useMemo, useState } from 'react'; import { type MouseEventHandler, useCallback, useMemo, useState } from 'react';
@@ -271,9 +273,15 @@ function tagIdToTagOption(
); );
} }
const PageTitle = ({ id, workspace }: { id: string; workspace: Workspace }) => { const PageTitle = ({ id }: { id: string }) => {
const title = useBlockSuiteWorkspacePageTitle(workspace, id); const page = useLiveData(
return title; useService(PageRecordList).records.map(record => {
return record.find(p => p.id === id);
})
);
const title = useLiveData(page?.title);
const t = useAFFiNEI18N();
return title || t['Untitled']();
}; };
const UnifiedPageIcon = ({ const UnifiedPageIcon = ({
@@ -315,7 +323,7 @@ function pageMetaToListItemProp(
: undefined; : undefined;
const itemProps: PageListItemProps = { const itemProps: PageListItemProps = {
pageId: item.id, pageId: item.id,
title: <PageTitle id={item.id} workspace={props.blockSuiteWorkspace} />, title: <PageTitle id={item.id} />,
preview: ( preview: (
<PagePreview workspace={props.blockSuiteWorkspace} pageId={item.id} /> <PagePreview workspace={props.blockSuiteWorkspace} pageId={item.id} />
), ),

View File

@@ -1,13 +1,10 @@
import { import {
appSidebarFloatingAtom, appSidebarFloatingAtom,
appSidebarOpenAtom, appSidebarOpenAtom,
SidebarSwitch,
} from '@affine/component/app-sidebar'; } from '@affine/component/app-sidebar';
import { useIsTinyScreen } from '@affine/core/hooks/use-is-tiny-screen';
import clsx from 'clsx'; import clsx from 'clsx';
import { useAtomValue } from 'jotai'; import { useAtomValue } from 'jotai';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { useCallback, useRef, useState } from 'react';
import * as style from './style.css'; import * as style from './style.css';
@@ -16,86 +13,35 @@ interface HeaderPros {
right?: ReactNode; right?: ReactNode;
center?: ReactNode; center?: ReactNode;
bottomBorder?: boolean; bottomBorder?: boolean;
isFloat?: boolean;
} }
// The Header component is used to solve the following problems // The Header component is used to solve the following problems
// 1. Manage layout issues independently of page or business logic // 1. Manage layout issues independently of page or business logic
// 2. Dynamic centered middle element (relative to the main-container), when the middle element is detected to collide with the two elements, the line wrapping process is performed // 2. Dynamic centered middle element (relative to the main-container), when the middle element is detected to collide with the two elements, the line wrapping process is performed
export const Header = ({ export const Header = ({ left, center, right }: HeaderPros) => {
left,
center,
right,
bottomBorder,
isFloat,
}: HeaderPros) => {
const sidebarSwitchRef = useRef<HTMLDivElement | null>(null);
const leftSlotRef = useRef<HTMLDivElement | null>(null);
const centerSlotRef = useRef<HTMLDivElement | null>(null);
const rightSlotRef = useRef<HTMLDivElement | null>(null);
const [headerRoot, setHeaderRoot] = useState<HTMLDivElement | null>(null);
const onSetHeaderRoot = useCallback((node: HTMLDivElement | null) => {
setHeaderRoot(node);
}, []);
const isTinyScreen = useIsTinyScreen({
container: headerRoot,
leftStatic: sidebarSwitchRef,
leftSlot: [leftSlotRef],
centerDom: centerSlotRef,
rightSlot: [rightSlotRef],
});
const open = useAtomValue(appSidebarOpenAtom); const open = useAtomValue(appSidebarOpenAtom);
const appSidebarFloating = useAtomValue(appSidebarFloatingAtom); const appSidebarFloating = useAtomValue(appSidebarFloatingAtom);
return ( return (
<div <div
className={clsx(style.header, bottomBorder && style.bottomBorder, { className={clsx(style.header)}
[style.headerFloat]: isFloat,
})}
data-open={open} data-open={open}
data-sidebar-floating={appSidebarFloating} data-sidebar-floating={appSidebarFloating}
data-testid="header" data-testid="header"
ref={onSetHeaderRoot}
> >
<div <div className={clsx(style.headerSideContainer)}>
className={clsx(style.headerSideContainer, {
block: isTinyScreen,
})}
>
<div
className={clsx(
style.headerItem,
'top-item',
!open ? 'top-item-visible' : ''
)}
>
<div ref={sidebarSwitchRef}>
<SidebarSwitch show={!open} />
</div>
</div>
<div className={clsx(style.headerItem, 'left')}> <div className={clsx(style.headerItem, 'left')}>
<div ref={leftSlotRef}>{left}</div> <div>{left}</div>
</div> </div>
</div> </div>
<div <div
className={clsx({ className={clsx({
[style.headerCenter]: center, [style.headerCenter]: center,
})} })}
ref={centerSlotRef}
> >
{center} {center}
</div> </div>
<div <div className={clsx(style.headerSideContainer, 'right')}>
className={clsx(style.headerSideContainer, 'right', { <div className={clsx(style.headerItem, 'right')}>{right}</div>
block: isTinyScreen,
})}
>
<div ref={rightSlotRef} className={clsx(style.headerItem, 'right')}>
{right}
</div>
</div> </div>
</div> </div>
); );

View File

@@ -3,48 +3,23 @@ import { style } from '@vanilla-extract/css';
export const header = style({ export const header = style({
display: 'flex', display: 'flex',
flex: 1,
justifyContent: 'space-between', justifyContent: 'space-between',
position: 'relative', position: 'relative',
padding: '0 16px',
minHeight: '52px',
background: 'var(--affine-background-primary-color)',
zIndex: 2, zIndex: 2,
selectors: {
'&[data-sidebar-floating="false"]': {
WebkitAppRegion: 'drag',
},
},
'@media': { '@media': {
print: { print: {
display: 'none', display: 'none',
}, },
}, },
':has([data-popper-placement])': {
WebkitAppRegion: 'no-drag',
},
} as ComplexStyleRule); } as ComplexStyleRule);
export const headerFloat = style({
position: 'absolute',
width: '100%',
});
export const bottomBorder = style({
borderBottom: '1px solid var(--affine-border-color)',
});
export const headerItem = style({ export const headerItem = style({
minHeight: '32px', minHeight: '32px',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
flexShrink: 0, flexShrink: 0,
selectors: { selectors: {
'&.top-item': {
height: '52px',
},
'&.top-item-visible': {
marginRight: '20px',
},
'&.left': { '&.left': {
justifyContent: 'left', justifyContent: 'left',
}, },
@@ -66,12 +41,6 @@ export const headerCenter = style({
transform: 'translateX(-50%)', transform: 'translateX(-50%)',
left: '50%', left: '50%',
zIndex: 1, zIndex: 1,
selectors: {
'&.shadow': {
position: 'static',
visibility: 'hidden',
},
},
}); });
export const headerSideContainer = style({ export const headerSideContainer = style({
@@ -82,10 +51,6 @@ export const headerSideContainer = style({
'&.right': { '&.right': {
flexDirection: 'row-reverse', flexDirection: 'row-reverse',
}, },
'&.block': {
display: 'block',
paddingBottom: '10px',
},
}, },
}); });

View File

@@ -22,7 +22,7 @@ import { useAllPageListConfig } from '../../../../hooks/affine/use-all-page-list
import { getDropItemId } from '../../../../hooks/affine/use-sidebar-drag'; import { getDropItemId } from '../../../../hooks/affine/use-sidebar-drag';
import { useBlockSuiteDocMeta } from '../../../../hooks/use-block-suite-page-meta'; import { useBlockSuiteDocMeta } from '../../../../hooks/use-block-suite-page-meta';
import { Workbench } from '../../../../modules/workbench'; import { Workbench } from '../../../../modules/workbench';
import { WorkbenchLink } from '../../../../modules/workbench/workbench-link'; import { WorkbenchLink } from '../../../../modules/workbench/view/workbench-link';
import type { CollectionsListProps } from '../index'; import type { CollectionsListProps } from '../index';
import { Page } from './page'; import { Page } from './page';
import * as styles from './styles.css'; import * as styles from './styles.css';

View File

@@ -1,73 +0,0 @@
import 'foxact/use-debounced-state';
import { debounce } from 'lodash-es';
import { type RefObject, useEffect, useState } from 'react';
export function useIsTinyScreen({
container,
leftStatic,
leftSlot,
centerDom,
rightSlot,
}: {
container: HTMLElement | null;
leftStatic: RefObject<HTMLElement>;
leftSlot: RefObject<HTMLElement>[];
centerDom: RefObject<HTMLElement>;
rightSlot: RefObject<HTMLElement>[];
}) {
const [isTinyScreen, setIsTinyScreen] = useState(false);
useEffect(() => {
if (!container) {
return;
}
const handleResize = debounce(() => {
if (!centerDom.current) {
return;
}
const leftStaticWidth = leftStatic.current?.clientWidth || 0;
const leftSlotWidth = leftSlot.reduce((accWidth, dom) => {
return accWidth + (dom.current?.clientWidth || 0);
}, 0);
const rightSlotWidth = rightSlot.reduce((accWidth, dom) => {
return accWidth + (dom.current?.clientWidth || 0);
}, 0);
if (!leftSlotWidth && !rightSlotWidth) {
if (isTinyScreen) {
setIsTinyScreen(false);
}
return;
}
const containerRect = container.getBoundingClientRect();
const centerRect = centerDom.current.getBoundingClientRect();
if (
leftStaticWidth + leftSlotWidth + containerRect.left >=
centerRect.left ||
containerRect.right - centerRect.right <= rightSlotWidth
) {
setIsTinyScreen(true);
} else {
setIsTinyScreen(false);
}
}, 100);
handleResize();
const resizeObserver = new ResizeObserver(() => {
handleResize();
});
resizeObserver.observe(container);
return () => {
resizeObserver.unobserve(container);
};
}, [centerDom, isTinyScreen, leftSlot, leftStatic, container, rightSlot]);
return isTinyScreen;
}

View File

@@ -0,0 +1,13 @@
import type { AffineEditorContainer } from '@blocksuite/presets';
export type SidebarTabName = 'outline' | 'frame' | 'copilot' | 'journal';
export interface SidebarTabProps {
editor: AffineEditorContainer | null;
}
export interface SidebarTab {
name: SidebarTabName;
icon: React.ReactNode;
Component: React.ComponentType<SidebarTabProps>;
}

View File

@@ -0,0 +1,14 @@
import type { SidebarTab } from './sidebar-tab';
import { copilotTab } from './tabs/copilot';
import { framePanelTab } from './tabs/frame';
import { journalTab } from './tabs/journal';
import { outlineTab } from './tabs/outline';
// the list of all possible tabs in affine.
// order matters (determines the order of the tabs)
export const sidebarTabs: SidebarTab[] = [
journalTab,
outlineTab,
framePanelTab,
copilotTab,
];

View File

@@ -1,16 +1,14 @@
import { useActiveBlocksuiteEditor } from '@affine/core/hooks/use-block-suite-editor';
import { assertExists } from '@blocksuite/global/utils'; import { assertExists } from '@blocksuite/global/utils';
import { AiIcon } from '@blocksuite/icons'; import { AiIcon } from '@blocksuite/icons';
import { CopilotPanel } from '@blocksuite/presets'; import { CopilotPanel } from '@blocksuite/presets';
import { useCallback, useRef } from 'react'; import { useCallback, useRef } from 'react';
import type { EditorExtension } from '../types'; import type { SidebarTab, SidebarTabProps } from '../sidebar-tab';
import * as styles from './outline.css'; import * as styles from './outline.css';
// A wrapper for CopilotPanel // A wrapper for CopilotPanel
const EditorCopilotPanel = () => { const EditorCopilotPanel = ({ editor }: SidebarTabProps) => {
const copilotPanelRef = useRef<CopilotPanel | null>(null); const copilotPanelRef = useRef<CopilotPanel | null>(null);
const [editor] = useActiveBlocksuiteEditor();
const onRefChange = useCallback((container: HTMLDivElement | null) => { const onRefChange = useCallback((container: HTMLDivElement | null) => {
if (container) { if (container) {
@@ -38,7 +36,7 @@ const EditorCopilotPanel = () => {
return <div className={styles.root} ref={onRefChange} />; return <div className={styles.root} ref={onRefChange} />;
}; };
export const copilotExtension: EditorExtension = { export const copilotTab: SidebarTab = {
name: 'copilot', name: 'copilot',
icon: <AiIcon />, icon: <AiIcon />,
Component: EditorCopilotPanel, Component: EditorCopilotPanel,

View File

@@ -1,18 +1,15 @@
import { useActiveBlocksuiteEditor } from '@affine/core/hooks/use-block-suite-editor';
import { assertExists } from '@blocksuite/global/utils'; import { assertExists } from '@blocksuite/global/utils';
import { FrameIcon } from '@blocksuite/icons'; import { FrameIcon } from '@blocksuite/icons';
import { FramePanel } from '@blocksuite/presets'; import { FramePanel } from '@blocksuite/presets';
import { useCallback, useRef } from 'react'; import { useCallback, useRef } from 'react';
import type { EditorExtension } from '../types'; import type { SidebarTab, SidebarTabProps } from '../sidebar-tab';
import * as styles from './frame.css'; import * as styles from './frame.css';
// A wrapper for FramePanel // A wrapper for FramePanel
const EditorFramePanel = () => { const EditorFramePanel = ({ editor }: SidebarTabProps) => {
const framePanelRef = useRef<FramePanel | null>(null); const framePanelRef = useRef<FramePanel | null>(null);
const [editor] = useActiveBlocksuiteEditor();
const onRefChange = useCallback((container: HTMLDivElement | null) => { const onRefChange = useCallback((container: HTMLDivElement | null) => {
if (container) { if (container) {
assertExists(framePanelRef.current, 'frame panel should be initialized'); assertExists(framePanelRef.current, 'frame panel should be initialized');
@@ -36,7 +33,7 @@ const EditorFramePanel = () => {
return <div className={styles.root} ref={onRefChange} />; return <div className={styles.root} ref={onRefChange} />;
}; };
export const framePanelExtension: EditorExtension = { export const framePanelTab: SidebarTab = {
name: 'frame', name: 'frame',
icon: <FrameIcon />, icon: <FrameIcon />,
Component: EditorFramePanel, Component: EditorFramePanel,

View File

@@ -7,15 +7,12 @@ import {
} from '@affine/component'; } from '@affine/component';
import { MoveToTrash } from '@affine/core/components/page-list'; import { MoveToTrash } from '@affine/core/components/page-list';
import { useTrashModalHelper } from '@affine/core/hooks/affine/use-trash-modal-helper'; import { useTrashModalHelper } from '@affine/core/hooks/affine/use-trash-modal-helper';
import { useBlockSuiteDocMeta } from '@affine/core/hooks/use-block-suite-page-meta';
import { useBlockSuiteWorkspacePageTitle } from '@affine/core/hooks/use-block-suite-workspace-page-title';
import { import {
useJournalHelper, useJournalHelper,
useJournalInfoHelper, useJournalInfoHelper,
useJournalRouteHelper, useJournalRouteHelper,
} from '@affine/core/hooks/use-journal'; } from '@affine/core/hooks/use-journal';
import { useNavigateHelper } from '@affine/core/hooks/use-navigate-helper'; import { useNavigateHelper } from '@affine/core/hooks/use-navigate-helper';
import type { BlockSuiteWorkspace } from '@affine/core/shared';
import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { import {
EdgelessIcon, EdgelessIcon,
@@ -23,14 +20,21 @@ import {
PageIcon, PageIcon,
TodayIcon, TodayIcon,
} from '@blocksuite/icons'; } from '@blocksuite/icons';
import type { Doc, DocMeta } from '@blocksuite/store'; import type { PageRecord } from '@toeverything/infra';
import {
Doc,
PageRecordList,
useLiveData,
Workspace,
} from '@toeverything/infra';
import { useService } from '@toeverything/infra/di';
import { assignInlineVars } from '@vanilla-extract/dynamic'; import { assignInlineVars } from '@vanilla-extract/dynamic';
import clsx from 'clsx'; import clsx from 'clsx';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import type { HTMLAttributes, PropsWithChildren, ReactNode } from 'react'; import type { HTMLAttributes, PropsWithChildren, ReactNode } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { EditorExtension, EditorExtensionProps } from '..'; import type { SidebarTab } from '../sidebar-tab';
import * as styles from './journal.css'; import * as styles from './journal.css';
/** /**
@@ -44,28 +48,31 @@ const CountDisplay = ({
return <span {...attrs}>{count > max ? `${max}+` : count}</span>; return <span {...attrs}>{count > max ? `${max}+` : count}</span>;
}; };
interface PageItemProps extends HTMLAttributes<HTMLDivElement> { interface PageItemProps extends HTMLAttributes<HTMLDivElement> {
pageMeta: DocMeta; pageRecord: PageRecord;
workspace: BlockSuiteWorkspace;
right?: ReactNode; right?: ReactNode;
} }
const PageItem = ({ const PageItem = ({
pageMeta, pageRecord,
workspace,
right, right,
className, className,
...attrs ...attrs
}: PageItemProps) => { }: PageItemProps) => {
const { isJournal } = useJournalInfoHelper(workspace, pageMeta.id); const title = useLiveData(pageRecord.title);
const title = useBlockSuiteWorkspacePageTitle(workspace, pageMeta.id); const mode = useLiveData(pageRecord.mode);
const workspace = useService(Workspace);
const { isJournal } = useJournalInfoHelper(
workspace.blockSuiteWorkspace,
pageRecord.id
);
const Icon = isJournal const Icon = isJournal
? TodayIcon ? TodayIcon
: pageMeta.mode === 'edgeless' : mode === 'edgeless'
? EdgelessIcon ? EdgelessIcon
: PageIcon; : PageIcon;
return ( return (
<div <div
aria-label={pageMeta.title} aria-label={title}
className={clsx(className, styles.pageItem)} className={clsx(className, styles.pageItem)}
{...attrs} {...attrs}
> >
@@ -84,18 +91,19 @@ interface NavItem {
label: string; label: string;
count: number; count: number;
} }
interface JournalBlockProps extends EditorExtensionProps { interface JournalBlockProps {
date: dayjs.Dayjs; date: dayjs.Dayjs;
} }
const EditorJournalPanel = (props: EditorExtensionProps) => { const EditorJournalPanel = () => {
const { workspace, page } = props;
const t = useAFFiNEI18N(); const t = useAFFiNEI18N();
const doc = useService(Doc);
const workspace = useService(Workspace);
const { journalDate, isJournal } = useJournalInfoHelper( const { journalDate, isJournal } = useJournalInfoHelper(
page.workspace, workspace.blockSuiteWorkspace,
page.id doc.id
); );
const { openJournal } = useJournalRouteHelper(workspace); const { openJournal } = useJournalRouteHelper(workspace.blockSuiteWorkspace);
const [date, setDate] = useState(dayjs().format('YYYY-MM-DD')); const [date, setDate] = useState(dayjs().format('YYYY-MM-DD'));
useEffect(() => { useEffect(() => {
@@ -149,19 +157,22 @@ const EditorJournalPanel = (props: EditorExtensionProps) => {
onChange={onDateSelect} onChange={onDateSelect}
/> />
</div> </div>
<JournalConflictBlock date={dayjs(date)} {...props} /> <JournalConflictBlock date={dayjs(date)} />
<JournalDailyCountBlock date={dayjs(date)} {...props} /> <JournalDailyCountBlock date={dayjs(date)} />
</div> </div>
); );
}; };
const sortPagesByDate = ( const sortPagesByDate = (
pages: DocMeta[], pages: PageRecord[],
field: 'updatedDate' | 'createDate', field: 'updatedDate' | 'createDate',
order: 'asc' | 'desc' = 'desc' order: 'asc' | 'desc' = 'desc'
) => { ) => {
return [...pages].sort((a, b) => { return [...pages].sort((a, b) => {
return (order === 'asc' ? 1 : -1) * dayjs(b[field]).diff(dayjs(a[field])); return (
(order === 'asc' ? 1 : -1) *
dayjs(b.meta.value[field]).diff(dayjs(a.meta.value[field]))
);
}); });
}; };
@@ -176,25 +187,30 @@ const DailyCountEmptyFallback = ({ name }: { name: NavItemName }) => {
</div> </div>
); );
}; };
const JournalDailyCountBlock = ({ workspace, date }: JournalBlockProps) => { const JournalDailyCountBlock = ({ date }: JournalBlockProps) => {
const workspace = useService(Workspace);
const nodeRef = useRef<HTMLDivElement>(null); const nodeRef = useRef<HTMLDivElement>(null);
const t = useAFFiNEI18N(); const t = useAFFiNEI18N();
const [activeItem, setActiveItem] = useState<NavItemName>('createdToday'); const [activeItem, setActiveItem] = useState<NavItemName>('createdToday');
const pageMetas = useBlockSuiteDocMeta(workspace); const pageRecordList = useService(PageRecordList);
const pageRecords = useLiveData(pageRecordList.records);
const navigateHelper = useNavigateHelper(); const navigateHelper = useNavigateHelper();
const getTodaysPages = useCallback( const getTodaysPages = useCallback(
(field: 'createDate' | 'updatedDate') => { (field: 'createDate' | 'updatedDate') => {
return sortPagesByDate( return sortPagesByDate(
pageMetas.filter(pageMeta => { pageRecords.filter(pageRecord => {
if (pageMeta.trash) return false; if (pageRecord.meta.value.trash) return false;
return pageMeta[field] && dayjs(pageMeta[field]).isSame(date, 'day'); return (
pageRecord.meta.value[field] &&
dayjs(pageRecord.meta.value[field]).isSame(date, 'day')
);
}), }),
field field
); );
}, },
[date, pageMetas] [date, pageRecords]
); );
const createdToday = useMemo( const createdToday = useMemo(
@@ -263,15 +279,14 @@ const JournalDailyCountBlock = ({ workspace, date }: JournalBlockProps) => {
<Scrollable.Scrollbar /> <Scrollable.Scrollbar />
<Scrollable.Viewport> <Scrollable.Viewport>
<div className={styles.dailyCountContent} ref={nodeRef}> <div className={styles.dailyCountContent} ref={nodeRef}>
{renderList.map((pageMeta, index) => ( {renderList.map((pageRecord, index) => (
<PageItem <PageItem
onClick={() => onClick={() =>
navigateHelper.openPage(workspace.id, pageMeta.id) navigateHelper.openPage(workspace.id, pageRecord.id)
} }
tabIndex={name === activeItem ? 0 : -1} tabIndex={name === activeItem ? 0 : -1}
key={index} key={index}
pageMeta={pageMeta} pageRecord={pageRecord}
workspace={workspace}
/> />
))} ))}
</div> </div>
@@ -286,29 +301,27 @@ const JournalDailyCountBlock = ({ workspace, date }: JournalBlockProps) => {
const MAX_CONFLICT_COUNT = 5; const MAX_CONFLICT_COUNT = 5;
interface ConflictListProps interface ConflictListProps
extends JournalBlockProps, extends PropsWithChildren,
PropsWithChildren,
HTMLAttributes<HTMLDivElement> { HTMLAttributes<HTMLDivElement> {
pages: Doc[]; pageRecords: PageRecord[];
} }
const ConflictList = ({ const ConflictList = ({
page: currentPage, pageRecords,
pages,
workspace,
children, children,
className, className,
...attrs ...attrs
}: ConflictListProps) => { }: ConflictListProps) => {
const navigateHelper = useNavigateHelper(); const navigateHelper = useNavigateHelper();
const { setTrashModal } = useTrashModalHelper(workspace); const workspace = useService(Workspace);
const currentDoc = useService(Doc);
const { setTrashModal } = useTrashModalHelper(workspace.blockSuiteWorkspace);
const handleOpenTrashModal = useCallback( const handleOpenTrashModal = useCallback(
(page: Doc) => { (pageRecord: PageRecord) => {
if (!page.meta) return;
setTrashModal({ setTrashModal({
open: true, open: true,
pageIds: [page.id], pageIds: [pageRecord.id],
pageTitles: [page.meta.title], pageTitles: [pageRecord.meta.value.title],
}); });
}, },
[setTrashModal] [setTrashModal]
@@ -316,19 +329,19 @@ const ConflictList = ({
return ( return (
<div className={clsx(styles.journalConflictWrapper, className)} {...attrs}> <div className={clsx(styles.journalConflictWrapper, className)} {...attrs}>
{pages.map(page => { {pageRecords.map(pageRecord => {
const isCurrent = page.id === currentPage.id; const isCurrent = pageRecord.id === currentDoc.id;
return ( return (
<PageItem <PageItem
aria-label={page.meta?.title}
aria-selected={isCurrent} aria-selected={isCurrent}
pageMeta={page.meta as DocMeta} pageRecord={pageRecord}
workspace={workspace} key={pageRecord.id}
key={page.id}
right={ right={
<Menu <Menu
items={ items={
<MoveToTrash onSelect={() => handleOpenTrashModal(page)} /> <MoveToTrash
onSelect={() => handleOpenTrashModal(pageRecord)}
/>
} }
> >
<IconButton type="plain"> <IconButton type="plain">
@@ -336,7 +349,7 @@ const ConflictList = ({
</IconButton> </IconButton>
</Menu> </Menu>
} }
onClick={() => navigateHelper.openPage(workspace.id, page.id)} onClick={() => navigateHelper.openPage(workspace.id, pageRecord.id)}
/> />
); );
})} })}
@@ -344,29 +357,32 @@ const ConflictList = ({
</div> </div>
); );
}; };
const JournalConflictBlock = (props: JournalBlockProps) => { const JournalConflictBlock = ({ date }: JournalBlockProps) => {
const { workspace, date } = props;
const t = useAFFiNEI18N(); const t = useAFFiNEI18N();
const journalHelper = useJournalHelper(workspace); const workspace = useService(Workspace);
const pages = journalHelper.getJournalsByDate(date.format('YYYY-MM-DD')); const pageRecordList = useService(PageRecordList);
const journalHelper = useJournalHelper(workspace.blockSuiteWorkspace);
const docs = journalHelper.getJournalsByDate(date.format('YYYY-MM-DD'));
const pageRecords = useLiveData(pageRecordList.records).filter(v => {
return docs.some(doc => doc.id === v.id);
});
if (pages.length <= 1) return null; if (docs.length <= 1) return null;
return ( return (
<ConflictList <ConflictList
className={styles.journalConflictBlock} className={styles.journalConflictBlock}
pages={pages.slice(0, MAX_CONFLICT_COUNT)} pageRecords={pageRecords.slice(0, MAX_CONFLICT_COUNT)}
{...props}
> >
{pages.length > MAX_CONFLICT_COUNT ? ( {docs.length > MAX_CONFLICT_COUNT ? (
<Menu <Menu
items={ items={
<ConflictList pages={pages.slice(MAX_CONFLICT_COUNT)} {...props} /> <ConflictList pageRecords={pageRecords.slice(MAX_CONFLICT_COUNT)} />
} }
> >
<div className={styles.journalConflictMoreTrigger}> <div className={styles.journalConflictMoreTrigger}>
{t['com.affine.journal.conflict-show-more']({ {t['com.affine.journal.conflict-show-more']({
count: (pages.length - MAX_CONFLICT_COUNT).toFixed(0), count: (pageRecords.length - MAX_CONFLICT_COUNT).toFixed(0),
})} })}
</div> </div>
</Menu> </Menu>
@@ -375,7 +391,7 @@ const JournalConflictBlock = (props: JournalBlockProps) => {
); );
}; };
export const journalExtension: EditorExtension = { export const journalTab: SidebarTab = {
name: 'journal', name: 'journal',
icon: <TodayIcon />, icon: <TodayIcon />,
Component: EditorJournalPanel, Component: EditorJournalPanel,

View File

@@ -1,16 +1,14 @@
import { useActiveBlocksuiteEditor } from '@affine/core/hooks/use-block-suite-editor';
import { assertExists } from '@blocksuite/global/utils'; import { assertExists } from '@blocksuite/global/utils';
import { TocIcon } from '@blocksuite/icons'; import { TocIcon } from '@blocksuite/icons';
import { OutlinePanel } from '@blocksuite/presets'; import { OutlinePanel } from '@blocksuite/presets';
import { useCallback, useRef } from 'react'; import { useCallback, useRef } from 'react';
import type { EditorExtension } from '../types'; import type { SidebarTab, SidebarTabProps } from '../sidebar-tab';
import * as styles from './outline.css'; import * as styles from './outline.css';
// A wrapper for TOCNotesPanel // A wrapper for TOCNotesPanel
const EditorOutline = () => { const EditorOutline = ({ editor }: SidebarTabProps) => {
const outlinePanelRef = useRef<OutlinePanel | null>(null); const outlinePanelRef = useRef<OutlinePanel | null>(null);
const [editor] = useActiveBlocksuiteEditor();
const onRefChange = useCallback((container: HTMLDivElement | null) => { const onRefChange = useCallback((container: HTMLDivElement | null) => {
if (container) { if (container) {
@@ -35,7 +33,7 @@ const EditorOutline = () => {
return <div className={styles.root} ref={onRefChange} />; return <div className={styles.root} ref={onRefChange} />;
}; };
export const outlineExtension: EditorExtension = { export const outlineTab: SidebarTab = {
name: 'outline', name: 'outline',
icon: <TocIcon />, icon: <TocIcon />,
Component: EditorOutline, Component: EditorOutline,

View File

@@ -0,0 +1,4 @@
export type { SidebarTabName } from './entities/sidebar-tab';
export { sidebarTabs } from './entities/sidebar-tabs';
export { MultiTabSidebarBody } from './view/body';
export { MultiTabSidebarHeaderSwitcher } from './view/header-switcher';

View File

@@ -0,0 +1,13 @@
import { cssVar } from '@toeverything/theme';
import { style } from '@vanilla-extract/css';
export const root = style({
display: 'flex',
flexDirection: 'column',
flex: 1,
width: '100%',
height: '100%',
minWidth: '320px',
overflow: 'hidden',
alignItems: 'center',
borderTop: `1px solid ${cssVar('borderColor')}`,
});

View File

@@ -0,0 +1,17 @@
import type { PropsWithChildren } from 'react';
import type { SidebarTab, SidebarTabProps } from '../entities/sidebar-tab';
import * as styles from './body.css';
export const MultiTabSidebarBody = (
props: PropsWithChildren<SidebarTabProps & { tab?: SidebarTab | null }>
) => {
const Component = props.tab?.Component;
return (
<div className={styles.root}>
{props.children}
{Component ? <Component {...props} /> : null}
</div>
);
};

View File

@@ -1,6 +1,11 @@
import { cssVar } from '@toeverything/theme'; import { cssVar } from '@toeverything/theme';
import { createVar, style } from '@vanilla-extract/css'; import { createVar, style } from '@vanilla-extract/css';
export const activeIdx = createVar(); export const activeIdx = createVar();
export const switchRootWrapper = style({
height: '52px',
display: 'flex',
alignItems: 'center',
});
export const switchRoot = style({ export const switchRoot = style({
vars: { vars: {
[activeIdx]: '0', [activeIdx]: '0',

View File

@@ -0,0 +1,77 @@
import { IconButton } from '@affine/component';
import { useJournalInfoHelper } from '@affine/core/hooks/use-journal';
import { useWorkspaceEnabledFeatures } from '@affine/core/hooks/use-workspace-features';
import { FeatureType } from '@affine/graphql';
import { Doc, useService, Workspace } from '@toeverything/infra';
import { assignInlineVars } from '@vanilla-extract/dynamic';
import { useEffect, useMemo } from 'react';
import type { SidebarTab, SidebarTabName } from '../entities/sidebar-tab';
import * as styles from './header-switcher.css';
export interface MultiTabSidebarHeaderSwitcherProps {
tabs: SidebarTab[];
activeTabName: SidebarTabName | null;
setActiveTabName: (ext: SidebarTabName) => void;
}
// provide a switcher for active extensions
// will be used in global top header (MacOS) or sidebar (Windows)
export const MultiTabSidebarHeaderSwitcher = ({
tabs,
activeTabName,
setActiveTabName,
}: MultiTabSidebarHeaderSwitcherProps) => {
const workspace = useService(Workspace);
const doc = useService(Doc);
const copilotEnabled = useWorkspaceEnabledFeatures(workspace.meta).includes(
FeatureType.Copilot
);
const { isJournal } = useJournalInfoHelper(
workspace.blockSuiteWorkspace,
doc.id
);
const exts = useMemo(
() =>
tabs.filter(ext => {
if (ext.name === 'copilot' && !copilotEnabled) return false;
return true;
}),
[copilotEnabled, tabs]
);
const activeExtension = exts.find(ext => ext.name === activeTabName);
// if journal is active, set selected to journal
useEffect(() => {
const journalExtension = tabs.find(ext => ext.name === 'journal');
isJournal && journalExtension && setActiveTabName('journal');
}, [tabs, isJournal, setActiveTabName]);
const vars = assignInlineVars({
[styles.activeIdx]: String(
exts.findIndex(ext => ext.name === activeExtension?.name) ?? 0
),
});
return (
<div className={styles.switchRootWrapper}>
<div className={styles.switchRoot} style={vars}>
{exts.map(extension => {
return (
<IconButton
onClick={() => setActiveTabName(extension.name)}
key={extension.name}
data-active={activeExtension === extension}
className={styles.button}
>
{extension.icon}
</IconButton>
);
})}
</div>
</div>
);
};

View File

@@ -1,3 +0,0 @@
export class PageListView {
constructor() {}
}

View File

@@ -0,0 +1,6 @@
import { createIsland } from '../../../utils/island';
export class RightSidebarView {
readonly body = createIsland();
readonly header = createIsland();
}

View File

@@ -0,0 +1,47 @@
import { LiveData } from '@toeverything/infra/livedata';
import type { RightSidebarView } from './right-sidebar-view';
export class RightSidebar {
readonly isOpen = new LiveData(false);
readonly views = new LiveData<RightSidebarView[]>([]);
readonly front = this.views.map(
stack => stack[0] as RightSidebarView | undefined
);
readonly hasViews = this.views.map(stack => stack.length > 0);
open() {
this.isOpen.next(true);
}
toggle() {
this.isOpen.next(!this.isOpen.value);
}
close() {
this.isOpen.next(false);
}
/**
* @private use `RightSidebarViewIsland` instead
*/
_append(view: RightSidebarView) {
this.views.next([...this.views.value, view]);
}
/**
* @private use `RightSidebarViewIsland` instead
*/
_moveToFront(view: RightSidebarView) {
if (this.views.value.includes(view)) {
this.views.next([view, ...this.views.value.filter(v => v !== view)]);
}
}
/**
* @private use `RightSidebarViewIsland` instead
*/
_remove(view: RightSidebarView) {
this.views.next(this.views.value.filter(v => v !== view));
}
}

View File

@@ -0,0 +1,3 @@
export { RightSidebar } from './entities/right-sidebar';
export { RightSidebarContainer } from './view/container';
export { RightSidebarViewIsland } from './view/view-island';

View File

@@ -0,0 +1,40 @@
import { cssVar } from '@toeverything/theme';
import { style } from '@vanilla-extract/css';
export const sidebarContainerInner = style({
display: 'flex',
background: cssVar('backgroundPrimaryColor'),
flexDirection: 'column',
overflow: 'hidden',
height: '100%',
width: '100%',
});
export const sidebarContainer = style({
display: 'flex',
flexShrink: 0,
height: '100%',
selectors: {
[`&[data-client-border=true]`]: {
paddingLeft: 9,
},
[`&[data-client-border=false]`]: {
borderLeft: `1px solid ${cssVar('borderColor')}`,
},
},
});
export const sidebarBodyTarget = style({
flex: 1,
width: '100%',
minWidth: '320px',
overflow: 'hidden',
selectors: {
[`&[data-client-border=true]`]: {
paddingLeft: 9,
},
[`&[data-client-border=false]`]: {
borderLeft: `1px solid ${cssVar('borderColor')}`,
},
},
});

View File

@@ -0,0 +1,68 @@
import { ResizePanel } from '@affine/component/resize-panel';
import { appSettingAtom } from '@toeverything/infra/atom';
import { useService } from '@toeverything/infra/di';
import { useLiveData } from '@toeverything/infra/livedata';
import { useAtomValue } from 'jotai';
import { useCallback, useState } from 'react';
import { RightSidebar } from '../entities/right-sidebar';
import * as styles from './container.css';
import { Header } from './header';
const MIN_SIDEBAR_WIDTH = 320;
const MAX_SIDEBAR_WIDTH = 800;
export const RightSidebarContainer = () => {
const { clientBorder } = useAtomValue(appSettingAtom);
const [width, setWidth] = useState(300);
const [resizing, setResizing] = useState(false);
const rightSidebar = useService(RightSidebar);
const frontView = useLiveData(rightSidebar.front);
const open = useLiveData(rightSidebar.isOpen) && frontView !== undefined;
const handleOpenChange = useCallback(
(open: boolean) => {
if (open) {
rightSidebar.open();
} else {
rightSidebar.close();
}
},
[rightSidebar]
);
const handleToggleOpen = useCallback(() => {
rightSidebar.toggle();
}, [rightSidebar]);
return (
<ResizePanel
resizeHandlePos="left"
resizeHandleOffset={clientBorder ? 4 : 0}
width={width}
resizing={resizing}
onResizing={setResizing}
className={styles.sidebarContainer}
data-client-border={clientBorder && open}
open={open}
onOpen={handleOpenChange}
onWidthChange={setWidth}
minWidth={MIN_SIDEBAR_WIDTH}
maxWidth={MAX_SIDEBAR_WIDTH}
>
{frontView && (
<div className={styles.sidebarContainerInner}>
<Header
floating={false}
onToggle={handleToggleOpen}
view={frontView}
/>
<frontView.body.Target
className={styles.sidebarBodyTarget}
></frontView.body.Target>
</div>
)}
</ResizePanel>
);
};

View File

@@ -0,0 +1,43 @@
import { cssVar } from '@toeverything/theme';
import { style } from '@vanilla-extract/css';
export const header = style({
display: 'flex',
height: '52px',
width: '100%',
alignItems: 'center',
flexShrink: 0,
padding: '0 16px',
gap: '12px',
background: cssVar('backgroundPrimaryColor'),
selectors: {
'&[data-sidebar-floating="false"]': {
['WebkitAppRegion' as string]: 'drag',
},
},
'@media': {
print: {
display: 'none',
},
},
});
export const spacer = style({
flexGrow: 1,
minWidth: 12,
});
export const standaloneExtensionSwitcherWrapper = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
height: '52px',
position: 'relative',
});
export const windowsAppControlsContainer = style({
display: 'flex',
height: '100%',
marginRight: '-16px',
});

View File

@@ -0,0 +1,69 @@
import { IconButton } from '@affine/component';
import { RightSidebarIcon } from '@blocksuite/icons';
import { WindowsAppControls } from '../../../components/pure/header/windows-app-controls';
import type { RightSidebarView } from '../entities/right-sidebar-view';
import * as styles from './header.css';
export type HeaderProps = {
floating: boolean;
onToggle?: () => void;
view: RightSidebarView;
};
function Container({
children,
style,
className,
floating,
}: {
children: React.ReactNode;
className?: string;
style?: React.CSSProperties;
floating?: boolean;
}) {
return (
<div
data-testid="header"
style={style}
className={className}
data-sidebar-floating={floating}
>
{children}
</div>
);
}
const ToggleButton = ({ onToggle }: { onToggle?: () => void }) => {
return (
<IconButton size="large" onClick={onToggle}>
<RightSidebarIcon />
</IconButton>
);
};
const Windows = ({ floating, onToggle, view }: HeaderProps) => {
return (
<Container className={styles.header} floating={floating}>
<view.header.Target></view.header.Target>
<div className={styles.spacer} />
<ToggleButton onToggle={onToggle} />
<div className={styles.windowsAppControlsContainer}>
<WindowsAppControls />
</div>
</Container>
);
};
const NonWindows = ({ floating, view, onToggle }: HeaderProps) => {
return (
<Container className={styles.header} floating={floating}>
<view.header.Target></view.header.Target>
<div className={styles.spacer} />
<ToggleButton onToggle={onToggle} />
</Container>
);
};
export const Header =
environment.isDesktop && environment.isWindows ? Windows : NonWindows;

View File

@@ -0,0 +1,42 @@
import { useService } from '@toeverything/infra';
import { useEffect, useMemo } from 'react';
import { RightSidebar } from '../entities/right-sidebar';
import { RightSidebarView } from '../entities/right-sidebar-view';
export interface RightSidebarViewProps {
body: JSX.Element;
header?: JSX.Element | null;
name?: string;
active?: boolean;
}
export const RightSidebarViewIsland = ({
body,
header,
active,
}: RightSidebarViewProps) => {
const rightSidebar = useService(RightSidebar);
const view = useMemo(() => new RightSidebarView(), []);
useEffect(() => {
rightSidebar._append(view);
return () => {
rightSidebar._remove(view);
};
}, [rightSidebar, view]);
useEffect(() => {
if (active) {
rightSidebar._moveToFront(view);
}
}, [active, rightSidebar, view]);
return (
<>
<view.header.Provider>{header}</view.header.Provider>
<view.body.Provider>{body}</view.body.Provider>
</>
);
};

View File

@@ -11,6 +11,7 @@ import {
LocalStorageGlobalCache, LocalStorageGlobalCache,
LocalStorageGlobalState, LocalStorageGlobalState,
} from './infra-web/storage'; } from './infra-web/storage';
import { RightSidebar } from './right-sidebar/entities/right-sidebar';
import { Workbench } from './workbench'; import { Workbench } from './workbench';
import { import {
CurrentWorkspaceService, CurrentWorkspaceService,
@@ -23,6 +24,7 @@ export function configureBusinessServices(services: ServiceCollection) {
services services
.scope(WorkspaceScope) .scope(WorkspaceScope)
.add(Workbench) .add(Workbench)
.add(RightSidebar)
.add(WorkspacePropertiesAdapter, [Workspace]) .add(WorkspacePropertiesAdapter, [Workspace])
.add(CollectionService, [Workspace]) .add(CollectionService, [Workspace])
.add(WorkspaceLegacyProperties, [Workspace]); .add(WorkspaceLegacyProperties, [Workspace]);

View File

@@ -4,6 +4,8 @@ import { createMemoryHistory } from 'history';
import { nanoid } from 'nanoid'; import { nanoid } from 'nanoid';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { createIsland } from '../../../utils/island';
export class View { export class View {
id = nanoid(); id = nanoid();
@@ -19,6 +21,9 @@ export class View {
this.history.location this.history.location
); );
header = createIsland();
body = createIsland();
push(path: To) { push(path: To) {
this.history.push(path); this.history.push(path);
} }

View File

@@ -1,2 +1,7 @@
export * from './view'; export { View } from './entities/view';
export * from './workbench'; export { Workbench } from './entities/workbench';
export { useIsActiveView } from './view/use-is-active-view';
export { ViewBodyIsland } from './view/view-body-island';
export { ViewHeaderIsland } from './view/view-header-island';
export { WorkbenchLink } from './view/workbench-link';
export { WorkbenchRoot } from './view/workbench-root';

View File

@@ -4,7 +4,7 @@ import { useEffect } from 'react';
// eslint-disable-next-line @typescript-eslint/no-restricted-imports // eslint-disable-next-line @typescript-eslint/no-restricted-imports
import { useLocation, useNavigate } from 'react-router-dom'; import { useLocation, useNavigate } from 'react-router-dom';
import type { Workbench } from './workbench'; import type { Workbench } from '../entities/workbench';
/** /**
* This hook binds the workbench to the browser router. * This hook binds the workbench to the browser router.

View File

@@ -3,7 +3,7 @@ import { useEffect } from 'react';
// eslint-disable-next-line @typescript-eslint/no-restricted-imports // eslint-disable-next-line @typescript-eslint/no-restricted-imports
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import type { Workbench } from './workbench'; import type { Workbench } from '../entities/workbench';
/** /**
* This hook binds the workbench to the browser router. * This hook binds the workbench to the browser router.

View File

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

View File

@@ -0,0 +1,57 @@
import { cssVar } from '@toeverything/theme';
import { style } from '@vanilla-extract/css';
export const root = style({
display: 'flex',
height: '100%',
overflow: 'hidden',
width: '100%',
position: 'relative',
flexDirection: 'column',
minWidth: 0,
background: cssVar('backgroundPrimaryColor'),
});
export const header = style({
display: 'flex',
height: '52px',
width: '100%',
alignItems: 'center',
flexShrink: 0,
background: cssVar('backgroundPrimaryColor'),
padding: '0 16px',
['WebkitAppRegion' as string]: 'drag',
'@media': {
print: {
display: 'none',
},
},
});
export const viewBodyContainer = style({
display: 'flex',
flex: 1,
overflow: 'hidden',
});
export const leftSidebarButton = style({
margin: '0 16px 0 0',
});
export const rightSidebarButton = style({
margin: '0 0 0 16px',
});
export const viewHeaderContainer = style({
display: 'flex',
height: '100%',
flexGrow: 1,
minWidth: 12,
});
export const windowsAppControlsContainer = style({
display: 'flex',
height: '100%',
marginRight: '-16px',
paddingLeft: '16px',
});

View File

@@ -0,0 +1,76 @@
import { IconButton } from '@affine/component';
import {
appSidebarOpenAtom,
SidebarSwitch,
} from '@affine/component/app-sidebar';
import { WindowsAppControls } from '@affine/core/components/pure/header/windows-app-controls';
import { RightSidebarIcon } from '@blocksuite/icons';
import { useLiveData } from '@toeverything/infra';
import { useService } from '@toeverything/infra/di';
import { useAtomValue } from 'jotai';
import { Suspense, useCallback } from 'react';
import { RightSidebar } from '../../right-sidebar';
import * as styles from './route-container.css';
import { useView } from './use-view';
import { useViewPosition } from './use-view-position';
export interface Props {
route: {
Component: React.ComponentType;
};
}
const ToggleButton = ({
onToggle,
className,
}: {
onToggle?: () => void;
className: string;
}) => {
return (
<IconButton size="large" onClick={onToggle} className={className}>
<RightSidebarIcon />
</IconButton>
);
};
export const RouteContainer = ({ route }: Props) => {
const view = useView();
const viewPosition = useViewPosition();
const leftSidebarOpen = useAtomValue(appSidebarOpenAtom);
const rightSidebar = useService(RightSidebar);
const rightSidebarOpen = useLiveData(rightSidebar.isOpen);
const rightSidebarHasViews = useLiveData(rightSidebar.hasViews);
const handleToggleRightSidebar = useCallback(() => {
rightSidebar.toggle();
}, [rightSidebar]);
const isWindowsDesktop = environment.isDesktop && environment.isWindows;
return (
<div className={styles.root}>
<div className={styles.header}>
{viewPosition.isFirst && !leftSidebarOpen && (
<SidebarSwitch className={styles.leftSidebarButton} />
)}
<view.header.Target className={styles.viewHeaderContainer} />
{viewPosition.isLast && !rightSidebarOpen && rightSidebarHasViews && (
<>
<ToggleButton
className={styles.rightSidebarButton}
onToggle={handleToggleRightSidebar}
/>
{isWindowsDesktop && (
<div className={styles.windowsAppControlsContainer}>
<WindowsAppControls />
</div>
)}
</>
)}
</div>
<view.body.Target className={styles.viewBodyContainer} />
<Suspense fallback={<>loading</>}>
<route.Component />
</Suspense>
</div>
);
};

View File

@@ -0,0 +1,12 @@
import { useService } from '@toeverything/infra';
import { useLiveData } from '@toeverything/infra';
import { Workbench } from '../entities/workbench';
import { useView } from './use-view';
export function useIsActiveView() {
const workbench = useService(Workbench);
const currentView = useView();
const activeView = useLiveData(workbench.activeView);
return currentView === activeView;
}

View File

@@ -0,0 +1,35 @@
import { useService } from '@toeverything/infra/di';
import { useEffect, useState } from 'react';
import type { View } from '../entities/view';
import { Workbench } from '../entities/workbench';
import { useView } from './use-view';
export const useViewPosition = () => {
const workbench = useService(Workbench);
const view = useView();
const [position, setPosition] = useState(() =>
calcPosition(view, workbench.views.value)
);
useEffect(() => {
const subscription = workbench.views.subscribe(views => {
setPosition(calcPosition(view, views));
});
return () => {
subscription.unsubscribe();
};
}, [view, workbench]);
return position;
};
function calcPosition(view: View, viewList: View[]) {
const index = viewList.indexOf(view);
return {
index: index,
isFirst: index === 0,
isLast: index === viewList.length - 1,
};
}

View File

@@ -0,0 +1,15 @@
import { createContext, useContext } from 'react';
import type { View } from '../entities/view';
export const ViewContext = createContext<View | null>(null);
export const useView = () => {
const view = useContext(ViewContext);
if (!view) {
throw new Error(
'No view found in context. Make sure you are rendering inside a ViewRoot.'
);
}
return view;
};

View File

@@ -0,0 +1,6 @@
import { useView } from './use-view';
export const ViewBodyIsland = ({ children }: React.PropsWithChildren) => {
const view = useView();
return <view.body.Provider>{children}</view.body.Provider>;
};

View File

@@ -0,0 +1,6 @@
import { useView } from './use-view';
export const ViewHeaderIsland = ({ children }: React.PropsWithChildren) => {
const view = useView();
return <view.header.Provider>{children}</view.header.Provider>;
};

View File

@@ -1,5 +1,5 @@
import { useLiveData } from '@toeverything/infra/livedata'; import { useLiveData } from '@toeverything/infra/livedata';
import { useEffect, useMemo } from 'react'; import { lazy as reactLazy, useEffect, useMemo } from 'react';
import { import {
createMemoryRouter, createMemoryRouter,
RouterProvider, RouterProvider,
@@ -8,10 +8,30 @@ import {
} from 'react-router-dom'; } from 'react-router-dom';
import { viewRoutes } from '../../../router'; import { viewRoutes } from '../../../router';
import type { View } from './view'; import type { View } from '../entities/view';
import { RouteContainer } from './route-container';
import { ViewContext } from './use-view';
const warpedRoutes = viewRoutes.map(({ path, lazy }) => {
const Component = reactLazy(() =>
lazy().then(m => ({
default: m.Component as React.ComponentType,
}))
);
const route = {
Component,
};
return {
path,
Component: () => {
return <RouteContainer route={route} />;
},
};
});
export const ViewRoot = ({ view }: { view: View }) => { export const ViewRoot = ({ view }: { view: View }) => {
const viewRouter = useMemo(() => createMemoryRouter(viewRoutes), []); const viewRouter = useMemo(() => createMemoryRouter(warpedRoutes), []);
const location = useLiveData(view.location); const location = useLiveData(view.location);
@@ -23,16 +43,18 @@ export const ViewRoot = ({ view }: { view: View }) => {
// https://github.com/remix-run/react-router/issues/7375#issuecomment-975431736 // https://github.com/remix-run/react-router/issues/7375#issuecomment-975431736
return ( return (
<UNSAFE_LocationContext.Provider value={null as any}> <ViewContext.Provider value={view}>
<UNSAFE_RouteContext.Provider <UNSAFE_LocationContext.Provider value={null as any}>
value={{ <UNSAFE_RouteContext.Provider
outlet: null, value={{
matches: [], outlet: null,
isDataRoute: false, matches: [],
}} isDataRoute: false,
> }}
<RouterProvider router={viewRouter} /> >
</UNSAFE_RouteContext.Provider> <RouterProvider router={viewRouter} />
</UNSAFE_LocationContext.Provider> </UNSAFE_RouteContext.Provider>
</UNSAFE_LocationContext.Provider>
</ViewContext.Provider>
); );
}; };

View File

@@ -2,7 +2,7 @@ import { useService } from '@toeverything/infra/di';
import type { To } from 'history'; import type { To } from 'history';
import { useCallback } from 'react'; import { useCallback } from 'react';
import { Workbench } from './workbench'; import { Workbench } from '../entities/workbench';
export const WorkbenchLink = ({ export const WorkbenchLink = ({
to, to,
@@ -17,11 +17,15 @@ export const WorkbenchLink = ({
(event: React.MouseEvent<HTMLAnchorElement>) => { (event: React.MouseEvent<HTMLAnchorElement>) => {
event.preventDefault(); event.preventDefault();
// TODO: open this when multi view control is implemented // TODO: open this when multi view control is implemented
// if (environment.isDesktop && (event.ctrlKey || event.metaKey)) { if (
// workbench.open(to, { at: 'beside' }); (window as any).enableMultiView &&
// } else { environment.isDesktop &&
workbench.open(to); (event.ctrlKey || event.metaKey)
// } ) {
workbench.open(to, { at: 'beside' });
} else {
workbench.open(to);
}
onClick?.(event); onClick?.(event);
}, },

View File

@@ -3,8 +3,17 @@ import { style } from '@vanilla-extract/css';
export const workbenchRootContainer = style({ export const workbenchRootContainer = style({
display: 'flex', display: 'flex',
height: '100%', height: '100%',
flex: 1,
overflow: 'hidden',
selectors: {
[`&[data-client-border="true"]`]: {
gap: '8px',
},
},
}); });
export const workbenchViewContainer = style({ export const workbenchViewContainer = style({
flex: 1, flex: 1,
overflow: 'hidden',
height: '100%',
}); });

View File

@@ -1,17 +1,16 @@
import { appSettingAtom } from '@toeverything/infra/atom';
import { useService } from '@toeverything/infra/di'; import { useService } from '@toeverything/infra/di';
import { useLiveData } from '@toeverything/infra/livedata'; import { useLiveData } from '@toeverything/infra/livedata';
import { useCallback } from 'react'; import { useAtomValue } from 'jotai';
import { useCallback, useEffect, useRef } from 'react';
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import type { View } from '../entities/view';
import { Workbench } from '../entities/workbench';
import { useBindWorkbenchToBrowserRouter } from './browser-adapter'; import { useBindWorkbenchToBrowserRouter } from './browser-adapter';
import { useBindWorkbenchToDesktopRouter } from './desktop-adapter'; import { useBindWorkbenchToDesktopRouter } from './desktop-adapter';
import type { View } from './view'; import { ViewRoot } from './view-root';
import { ViewRoot } from './view/view-root'; import * as styles from './workbench-root.css';
import { Workbench } from './workbench';
import {
workbenchRootContainer,
workbenchViewContainer,
} from './workbench-root.css';
const useAdapter = environment.isDesktop const useAdapter = environment.isDesktop
? useBindWorkbenchToDesktopRouter ? useBindWorkbenchToDesktopRouter
@@ -30,8 +29,13 @@ export const WorkbenchRoot = () => {
useAdapter(workbench, basename); useAdapter(workbench, basename);
const { clientBorder } = useAtomValue(appSettingAtom);
return ( return (
<div className={workbenchRootContainer}> <div
className={styles.workbenchRootContainer}
data-client-border={!!clientBorder}
>
{views.map((view, index) => ( {views.map((view, index) => (
<WorkbenchView key={view.id} view={view} index={index} /> <WorkbenchView key={view.id} view={view} index={index} />
))} ))}
@@ -46,8 +50,25 @@ const WorkbenchView = ({ view, index }: { view: View; index: number }) => {
workbench.active(index); workbench.active(index);
}, [workbench, index]); }, [workbench, index]);
const containerRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (containerRef.current) {
const element = containerRef.current;
element.addEventListener('mousedown', handleOnFocus, {
capture: true,
});
return () => {
element.removeEventListener('mousedown', handleOnFocus, {
capture: true,
});
};
}
return;
}, [handleOnFocus]);
return ( return (
<div className={workbenchViewContainer} onMouseDownCapture={handleOnFocus}> <div className={styles.workbenchViewContainer} ref={containerRef}>
<ViewRoot key={view.id} view={view} /> <ViewRoot key={view.id} view={view} />
</div> </div>
); );

View File

@@ -0,0 +1,14 @@
import { style } from '@vanilla-extract/css';
export const headerCreateNewCollectionIconButton = style({
padding: '4px 8px',
fontSize: '16px',
width: '32px',
height: '28px',
borderRadius: '8px',
transition: 'opacity 0.1s ease-in-out',
});
export const headerCreateNewButtonHidden = style({
opacity: 0,
pointerEvents: 'none',
});

View File

@@ -1,12 +1,10 @@
import { IconButton } from '@affine/component'; import { IconButton } from '@affine/component';
import { Header } from '@affine/core/components/pure/header'; import { Header } from '@affine/core/components/pure/header';
import { WindowsAppControls } from '@affine/core/components/pure/header/windows-app-controls';
import { WorkspaceModeFilterTab } from '@affine/core/components/pure/workspace-mode-filter-tab'; import { WorkspaceModeFilterTab } from '@affine/core/components/pure/workspace-mode-filter-tab';
import { PlusIcon } from '@blocksuite/icons'; import { PlusIcon } from '@blocksuite/icons';
import clsx from 'clsx'; import clsx from 'clsx';
import { useMemo } from 'react';
import * as styles from '../all-page/all-page.css'; import * as styles from './header.css';
export const AllCollectionHeader = ({ export const AllCollectionHeader = ({
showCreateNew, showCreateNew,
@@ -15,33 +13,18 @@ export const AllCollectionHeader = ({
showCreateNew: boolean; showCreateNew: boolean;
onCreateCollection?: () => void; onCreateCollection?: () => void;
}) => { }) => {
const isWindowsDesktop = environment.isDesktop && environment.isWindows;
const renderRightItem = useMemo(() => {
return (
<IconButton
type="default"
icon={<PlusIcon fontSize={16} />}
onClick={onCreateCollection}
className={clsx(
styles.headerCreateNewButton,
styles.headerCreateNewCollectionIconButton,
!showCreateNew && styles.headerCreateNewButtonHidden
)}
/>
);
}, [onCreateCollection, showCreateNew]);
return ( return (
<Header <Header
right={ right={
<div <IconButton
className={styles.headerRightWindows} type="default"
data-is-windows-desktop={isWindowsDesktop} icon={<PlusIcon fontSize={16} />}
> onClick={onCreateCollection}
{renderRightItem} className={clsx(
{isWindowsDesktop ? <WindowsAppControls /> : null} styles.headerCreateNewCollectionIconButton,
</div> !showCreateNew && styles.headerCreateNewButtonHidden
)}
/>
} }
center={<WorkspaceModeFilterTab activeFilter={'collections'} />} center={<WorkspaceModeFilterTab activeFilter={'collections'} />}
/> />

View File

@@ -1,4 +1,3 @@
import { HubIsland } from '@affine/core/components/affine/hub-island';
import { import {
CollectionListHeader, CollectionListHeader,
type CollectionMeta, type CollectionMeta,
@@ -16,7 +15,7 @@ import { nanoid } from 'nanoid';
import { useCallback, useMemo, useState } from 'react'; import { useCallback, useMemo, useState } from 'react';
import { CollectionService } from '../../../modules/collection'; import { CollectionService } from '../../../modules/collection';
import * as styles from '../all-page/all-page.css'; import { ViewBodyIsland, ViewHeaderIsland } from '../../../modules/workbench';
import { EmptyCollectionList } from '../page-list-empty'; import { EmptyCollectionList } from '../page-list-empty';
import { AllCollectionHeader } from './header'; import { AllCollectionHeader } from './header';
@@ -58,33 +57,35 @@ export const AllCollection = () => {
}, [collectionService, currentWorkspace, navigateHelper, open]); }, [collectionService, currentWorkspace, navigateHelper, open]);
return ( return (
<div className={styles.root}> <>
<AllCollectionHeader <ViewHeaderIsland>
showCreateNew={!hideHeaderCreateNew} <AllCollectionHeader
onCreateCollection={handleCreateCollection} showCreateNew={!hideHeaderCreateNew}
/> onCreateCollection={handleCreateCollection}
{collectionMetas.length > 0 ? (
<VirtualizedCollectionList
collections={collections}
collectionMetas={collectionMetas}
setHideHeaderCreateNewCollection={setHideHeaderCreateNew}
node={node}
config={config}
handleCreateCollection={handleCreateCollection}
/> />
) : ( </ViewHeaderIsland>
<EmptyCollectionList <ViewBodyIsland>
heading={ {collectionMetas.length > 0 ? (
<CollectionListHeader <VirtualizedCollectionList
node={node} collections={collections}
onCreate={handleCreateCollection} collectionMetas={collectionMetas}
/> setHideHeaderCreateNewCollection={setHideHeaderCreateNew}
} node={node}
/> config={config}
)} handleCreateCollection={handleCreateCollection}
/>
<HubIsland /> ) : (
</div> <EmptyCollectionList
heading={
<CollectionListHeader
node={node}
onCreate={handleCreateCollection}
/>
}
/>
)}
</ViewBodyIsland>
</>
); );
}; };

View File

@@ -3,17 +3,14 @@ import {
PageListNewPageButton, PageListNewPageButton,
} from '@affine/core/components/page-list'; } from '@affine/core/components/page-list';
import { Header } from '@affine/core/components/pure/header'; import { Header } from '@affine/core/components/pure/header';
import { WindowsAppControls } from '@affine/core/components/pure/header/windows-app-controls';
import { WorkspaceModeFilterTab } from '@affine/core/components/pure/workspace-mode-filter-tab'; import { WorkspaceModeFilterTab } from '@affine/core/components/pure/workspace-mode-filter-tab';
import type { Filter } from '@affine/env/filter'; import type { Filter } from '@affine/env/filter';
import { PlusIcon } from '@blocksuite/icons'; import { PlusIcon } from '@blocksuite/icons';
import { useService } from '@toeverything/infra'; import { useService } from '@toeverything/infra';
import { Workspace } from '@toeverything/infra'; import { Workspace } from '@toeverything/infra';
import clsx from 'clsx'; import clsx from 'clsx';
import { useMemo } from 'react';
import * as styles from './all-page.css'; import * as styles from './all-page.css';
import { FilterContainer } from './all-page-filter';
export const AllPageHeader = ({ export const AllPageHeader = ({
showCreateNew, showCreateNew,
@@ -25,44 +22,28 @@ export const AllPageHeader = ({
onChangeFilters: (filters: Filter[]) => void; onChangeFilters: (filters: Filter[]) => void;
}) => { }) => {
const workspace = useService(Workspace); const workspace = useService(Workspace);
const isWindowsDesktop = environment.isDesktop && environment.isWindows;
const renderRightItem = useMemo(() => {
return (
<PageListNewPageButton
size="small"
className={clsx(
styles.headerCreateNewButton,
!showCreateNew && styles.headerCreateNewButtonHidden
)}
>
<PlusIcon />
</PageListNewPageButton>
);
}, [showCreateNew]);
return ( return (
<> <Header
<Header left={
left={ <AllPageListOperationsMenu
<AllPageListOperationsMenu filterList={filters}
filterList={filters} onChangeFilterList={onChangeFilters}
onChangeFilterList={onChangeFilters} propertiesMeta={workspace.blockSuiteWorkspace.meta.properties}
propertiesMeta={workspace.blockSuiteWorkspace.meta.properties} />
/> }
} right={
right={ <PageListNewPageButton
<div size="small"
className={styles.headerRightWindows} className={clsx(
data-is-windows-desktop={isWindowsDesktop} styles.headerCreateNewButton,
> !showCreateNew && styles.headerCreateNewButtonHidden
{renderRightItem} )}
{isWindowsDesktop ? <WindowsAppControls /> : null} >
</div> <PlusIcon />
} </PageListNewPageButton>
center={<WorkspaceModeFilterTab activeFilter={'docs'} />} }
/> center={<WorkspaceModeFilterTab activeFilter={'docs'} />}
<FilterContainer filters={filters} onChangeFilters={onChangeFilters} /> />
</>
); );
}; };

View File

@@ -1,12 +1,4 @@
import { cssVar } from '@toeverything/theme';
import { style } from '@vanilla-extract/css'; import { style } from '@vanilla-extract/css';
export const root = style({
height: '100%',
width: '100%',
display: 'flex',
flexFlow: 'column',
background: cssVar('backgroundPrimaryColor'),
});
export const scrollContainer = style({ export const scrollContainer = style({
flex: 1, flex: 1,
width: '100%', width: '100%',
@@ -26,13 +18,11 @@ export const headerCreateNewButtonHidden = style({
opacity: 0, opacity: 0,
pointerEvents: 'none', pointerEvents: 'none',
}); });
export const headerRightWindows = style({
export const body = style({
display: 'flex', display: 'flex',
alignItems: 'center', flexDirection: 'column',
gap: 8, flex: 1,
selectors: { height: '100%',
'&[data-is-windows-desktop="true"]': { width: '100%',
transform: 'translateX(16px)',
},
},
}); });

View File

@@ -1,4 +1,3 @@
import { HubIsland } from '@affine/core/components/affine/hub-island';
import { import {
PageListHeader, PageListHeader,
useFilteredPageMetas, useFilteredPageMetas,
@@ -12,8 +11,10 @@ import { useService } from '@toeverything/infra';
import { Workspace } from '@toeverything/infra'; import { Workspace } from '@toeverything/infra';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { ViewBodyIsland, ViewHeaderIsland } from '../../../modules/workbench';
import { EmptyPageList } from '../page-list-empty'; import { EmptyPageList } from '../page-list-empty';
import * as styles from './all-page.css'; import * as styles from './all-page.css';
import { FilterContainer } from './all-page-filter';
import { AllPageHeader } from './all-page-header'; import { AllPageHeader } from './all-page-header';
export const AllPage = () => { export const AllPage = () => {
@@ -27,26 +28,32 @@ export const AllPage = () => {
}); });
return ( return (
<div className={styles.root}> <>
<AllPageHeader <ViewHeaderIsland>
showCreateNew={!hideHeaderCreateNew} <AllPageHeader
filters={filters} showCreateNew={!hideHeaderCreateNew}
onChangeFilters={setFilters}
/>
{filteredPageMetas.length > 0 ? (
<VirtualizedPageList
setHideHeaderCreateNewPage={setHideHeaderCreateNew}
filters={filters} filters={filters}
onChangeFilters={setFilters}
/> />
) : ( </ViewHeaderIsland>
<EmptyPageList <ViewBodyIsland>
type="all" <div className={styles.body}>
heading={<PageListHeader />} <FilterContainer filters={filters} onChangeFilters={setFilters} />
blockSuiteWorkspace={currentWorkspace.blockSuiteWorkspace} {filteredPageMetas.length > 0 ? (
/> <VirtualizedPageList
)} setHideHeaderCreateNewPage={setHideHeaderCreateNew}
<HubIsland /> filters={filters}
</div> />
) : (
<EmptyPageList
type="all"
heading={<PageListHeader />}
blockSuiteWorkspace={currentWorkspace.blockSuiteWorkspace}
/>
)}
</div>
</ViewBodyIsland>
</>
); );
}; };

View File

@@ -1,9 +1,9 @@
import { style } from '@vanilla-extract/css'; import { style } from '@vanilla-extract/css';
export const root = style({
export const body = style({
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
flex: 1, flex: 1,
overflow: 'auto', height: '100%',
width: '100%', width: '100%',
minWidth: '320px',
}); });

View File

@@ -1,23 +1,6 @@
import { Header } from '@affine/core/components/pure/header'; import { Header } from '@affine/core/components/pure/header';
import { WindowsAppControls } from '@affine/core/components/pure/header/windows-app-controls';
import { WorkspaceModeFilterTab } from '@affine/core/components/pure/workspace-mode-filter-tab'; import { WorkspaceModeFilterTab } from '@affine/core/components/pure/workspace-mode-filter-tab';
import * as styles from '../all-page/all-page.css';
export const AllTagHeader = () => { export const AllTagHeader = () => {
const isWindowsDesktop = environment.isDesktop && environment.isWindows; return <Header center={<WorkspaceModeFilterTab activeFilter={'tags'} />} />;
return (
<Header
right={
<div
className={styles.headerRightWindows}
data-is-windows-desktop={isWindowsDesktop}
>
{isWindowsDesktop ? <WindowsAppControls /> : null}
</div>
}
center={<WorkspaceModeFilterTab activeFilter={'tags'} />}
/>
);
}; };

View File

@@ -1,4 +1,3 @@
import { HubIsland } from '@affine/core/components/affine/hub-island';
import { useTagMetas } from '@affine/core/components/page-list'; import { useTagMetas } from '@affine/core/components/page-list';
import { import {
TagListHeader, TagListHeader,
@@ -8,8 +7,9 @@ import { useBlockSuiteDocMeta } from '@affine/core/hooks/use-block-suite-page-me
import { useService } from '@toeverything/infra'; import { useService } from '@toeverything/infra';
import { Workspace } from '@toeverything/infra'; import { Workspace } from '@toeverything/infra';
import * as styles from '../all-page/all-page.css'; import { ViewBodyIsland, ViewHeaderIsland } from '../../../modules/workbench';
import { EmptyTagList } from '../page-list-empty'; import { EmptyTagList } from '../page-list-empty';
import * as styles from './all-tag.css';
import { AllTagHeader } from './header'; import { AllTagHeader } from './header';
export const AllTag = () => { export const AllTag = () => {
@@ -22,19 +22,24 @@ export const AllTag = () => {
); );
return ( return (
<div className={styles.root}> <>
<AllTagHeader /> <ViewHeaderIsland>
{tags.length > 0 ? ( <AllTagHeader />
<VirtualizedTagList </ViewHeaderIsland>
tags={tags} <ViewBodyIsland>
tagMetas={tagMetas} <div className={styles.body}>
onTagDelete={deleteTags} {tags.length > 0 ? (
/> <VirtualizedTagList
) : ( tags={tags}
<EmptyTagList heading={<TagListHeader />} /> tagMetas={tagMetas}
)} onTagDelete={deleteTags}
<HubIsland /> />
</div> ) : (
<EmptyTagList heading={<TagListHeader />} />
)}
</div>
</ViewBodyIsland>
</>
); );
}; };

View File

@@ -23,3 +23,17 @@ export const button = style({
backgroundColor: cssVar('hoverColor'), backgroundColor: cssVar('hoverColor'),
}, },
}); });
export const headerCreateNewButton = style({
transition: 'opacity 0.1s ease-in-out',
});
export const headerCreateNewCollectionIconButton = style({
padding: '4px 8px',
fontSize: '16px',
width: '32px',
height: '28px',
borderRadius: '8px',
});
export const headerCreateNewButtonHidden = style({
opacity: 0,
pointerEvents: 'none',
});

View File

@@ -1,12 +1,10 @@
import { IconButton } from '@affine/component'; import { IconButton } from '@affine/component';
import { Header } from '@affine/core/components/pure/header'; import { Header } from '@affine/core/components/pure/header';
import { WindowsAppControls } from '@affine/core/components/pure/header/windows-app-controls';
import { WorkspaceModeFilterTab } from '@affine/core/components/pure/workspace-mode-filter-tab'; import { WorkspaceModeFilterTab } from '@affine/core/components/pure/workspace-mode-filter-tab';
import { PlusIcon } from '@blocksuite/icons'; import { PlusIcon } from '@blocksuite/icons';
import clsx from 'clsx'; import clsx from 'clsx';
import { useMemo } from 'react';
import * as styles from '../all-page/all-page.css'; import * as styles from './collection.css';
export const CollectionDetailHeader = ({ export const CollectionDetailHeader = ({
showCreateNew, showCreateNew,
@@ -15,33 +13,19 @@ export const CollectionDetailHeader = ({
showCreateNew: boolean; showCreateNew: boolean;
onCreate: () => void; onCreate: () => void;
}) => { }) => {
const isWindowsDesktop = environment.isDesktop && environment.isWindows;
const renderRightItem = useMemo(() => {
return (
<IconButton
type="default"
icon={<PlusIcon fontSize={16} />}
onClick={onCreate}
className={clsx(
styles.headerCreateNewButton,
styles.headerCreateNewCollectionIconButton,
!showCreateNew && styles.headerCreateNewButtonHidden
)}
/>
);
}, [onCreate, showCreateNew]);
return ( return (
<Header <Header
right={ right={
<div <IconButton
className={styles.headerRightWindows} type="default"
data-is-windows-desktop={isWindowsDesktop} icon={<PlusIcon fontSize={16} />}
> onClick={onCreate}
{renderRightItem} className={clsx(
{isWindowsDesktop ? <WindowsAppControls /> : null} styles.headerCreateNewButton,
</div> styles.headerCreateNewCollectionIconButton,
!showCreateNew && styles.headerCreateNewButtonHidden
)}
/>
} }
center={<WorkspaceModeFilterTab activeFilter={'collections'} />} center={<WorkspaceModeFilterTab activeFilter={'collections'} />}
/> />

View File

@@ -1,15 +1,9 @@
import {
appSidebarOpenAtom,
SidebarSwitch,
} from '@affine/component/app-sidebar';
import { pushNotificationAtom } from '@affine/component/notification-center'; import { pushNotificationAtom } from '@affine/component/notification-center';
import { HubIsland } from '@affine/core/components/affine/hub-island';
import { import {
AffineShapeIcon, AffineShapeIcon,
useEditCollection, useEditCollection,
VirtualizedPageList, VirtualizedPageList,
} from '@affine/core/components/page-list'; } from '@affine/core/components/page-list';
import { WindowsAppControls } from '@affine/core/components/pure/header/windows-app-controls';
import { useAllPageListConfig } from '@affine/core/hooks/affine/use-all-page-list-config'; import { useAllPageListConfig } from '@affine/core/hooks/affine/use-all-page-list-config';
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks'; import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
import { CollectionService } from '@affine/core/modules/collection'; import { CollectionService } from '@affine/core/modules/collection';
@@ -25,14 +19,13 @@ import {
import { Workspace } from '@toeverything/infra'; import { Workspace } from '@toeverything/infra';
import { useService } from '@toeverything/infra/di'; import { useService } from '@toeverything/infra/di';
import { useLiveData } from '@toeverything/infra/livedata'; import { useLiveData } from '@toeverything/infra/livedata';
import { useAtomValue } from 'jotai';
import { useSetAtom } from 'jotai'; import { useSetAtom } from 'jotai';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { useNavigateHelper } from '../../../hooks/use-navigate-helper'; import { useNavigateHelper } from '../../../hooks/use-navigate-helper';
import { ViewBodyIsland, ViewHeaderIsland } from '../../../modules/workbench';
import { WorkspaceSubPath } from '../../../shared'; import { WorkspaceSubPath } from '../../../shared';
import * as allPageStyles from '../all-page/all-page.css';
import * as styles from './collection.css'; import * as styles from './collection.css';
import { CollectionDetailHeader } from './header'; import { CollectionDetailHeader } from './header';
@@ -52,19 +45,22 @@ export const CollectionDetail = ({
}, [collection, collectionService, open]); }, [collection, collectionService, open]);
return ( return (
<div className={allPageStyles.root}> <>
<CollectionDetailHeader <ViewHeaderIsland>
showCreateNew={!hideHeaderCreateNew} <CollectionDetailHeader
onCreate={handleEditCollection} showCreateNew={!hideHeaderCreateNew}
/> onCreate={handleEditCollection}
<VirtualizedPageList />
collection={collection} </ViewHeaderIsland>
config={config} <ViewBodyIsland>
setHideHeaderCreateNewPage={setHideHeaderCreateNew} <VirtualizedPageList
/> collection={collection}
<HubIsland /> config={config}
setHideHeaderCreateNewPage={setHideHeaderCreateNew}
/>
</ViewBodyIsland>
{node} {node}
</div> </>
); );
}; };
@@ -115,8 +111,6 @@ export const Component = function CollectionPage() {
); );
}; };
const isWindowsDesktop = environment.isDesktop && environment.isWindows;
const Placeholder = ({ collection }: { collection: Collection }) => { const Placeholder = ({ collection }: { collection: Collection }) => {
const workspace = useService(Workspace); const workspace = useService(Workspace);
const collectionService = useService(CollectionService); const collectionService = useService(CollectionService);
@@ -139,199 +133,191 @@ const Placeholder = ({ collection }: { collection: Collection }) => {
localStorage.setItem('hide-empty-collection-help-info', 'true'); localStorage.setItem('hide-empty-collection-help-info', 'true');
}, []); }, []);
const t = useAFFiNEI18N(); const t = useAFFiNEI18N();
const leftSidebarOpen = useAtomValue(appSidebarOpenAtom);
const handleJumpToCollections = useCallback(() => { const handleJumpToCollections = useCallback(() => {
jumpToCollections(workspace.id); jumpToCollections(workspace.id);
}, [jumpToCollections, workspace]); }, [jumpToCollections, workspace]);
return ( return (
<div <>
style={{ <ViewHeaderIsland>
display: 'flex',
flexDirection: 'column',
height: '100%',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
height: 52,
paddingLeft: '16px',
fontSize: 'var(--affine-font-xs)',
['WebkitAppRegion' as string]: 'drag',
}}
>
<SidebarSwitch show={!leftSidebarOpen} />
<div <div
style={{ style={{
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: 4, gap: 8,
cursor: 'pointer', fontSize: 'var(--affine-font-xs)',
color: 'var(--affine-text-secondary-color)',
['WebkitAppRegion' as string]: 'no-drag',
}}
onClick={handleJumpToCollections}
>
<ViewLayersIcon
style={{ color: 'var(--affine-icon-color)' }}
fontSize={14}
/>
{t['com.affine.collection.allCollections']()}
<div>/</div>
</div>
<div
data-testid="collection-name"
style={{
fontWeight: 600,
color: 'var(--affine-text-primary-color)',
['WebkitAppRegion' as string]: 'no-drag',
}} }}
> >
{collection.name}
</div>
<div style={{ flex: 1 }} />
{isWindowsDesktop && <WindowsAppControls />}
</div>
<div
style={{
display: 'flex',
flex: 1,
flexDirection: 'column',
alignItems: 'center',
gap: 64,
}}
>
<div
style={{
maxWidth: 432,
marginTop: 118,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 18,
margin: '118px 12px 0',
}}
>
<AffineShapeIcon />
<div
style={{
fontSize: 20,
lineHeight: '28px',
fontWeight: 600,
color: 'var(--affine-text-primary-color)',
}}
>
{t['com.affine.collection.emptyCollection']()}
</div>
<div
style={{
fontSize: 12,
lineHeight: '20px',
color: 'var(--affine-text-secondary-color)',
textAlign: 'center',
}}
>
{t['com.affine.collection.emptyCollectionDescription']()}
</div>
<div <div
style={{ style={{
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: '12px 32px', gap: 4,
flexWrap: 'wrap', cursor: 'pointer',
justifyContent: 'center', color: 'var(--affine-text-secondary-color)',
['WebkitAppRegion' as string]: 'no-drag',
}}
onClick={handleJumpToCollections}
>
<ViewLayersIcon
style={{ color: 'var(--affine-icon-color)' }}
fontSize={14}
/>
{t['com.affine.collection.allCollections']()}
<div>/</div>
</div>
<div
data-testid="collection-name"
style={{
fontWeight: 600,
color: 'var(--affine-text-primary-color)',
['WebkitAppRegion' as string]: 'no-drag',
}} }}
> >
<div onClick={openPageEdit} className={styles.placeholderButton}> {collection.name}
<PageIcon
style={{
width: 20,
height: 20,
color: 'var(--affine-icon-color)',
}}
/>
<span style={{ padding: '0 4px' }}>
{t['com.affine.collection.addPages']()}
</span>
</div>
<div onClick={openRuleEdit} className={styles.placeholderButton}>
<FilterIcon
style={{
width: 20,
height: 20,
color: 'var(--affine-icon-color)',
}}
/>
<span style={{ padding: '0 4px' }}>
{t['com.affine.collection.addRules']()}
</span>
</div>
</div> </div>
<div style={{ flex: 1 }} />
</div> </div>
{showTips ? ( </ViewHeaderIsland>
<ViewBodyIsland>
<div
style={{
display: 'flex',
flex: 1,
flexDirection: 'column',
alignItems: 'center',
gap: 64,
}}
>
<div <div
style={{ style={{
maxWidth: 452, maxWidth: 432,
borderRadius: 8, marginTop: 118,
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
backgroundColor: 'var(--affine-background-overlay-panel-color)', alignItems: 'center',
padding: 10, gap: 18,
gap: 14, margin: '118px 12px 0',
margin: '0 12px',
}} }}
> >
<AffineShapeIcon />
<div <div
style={{ style={{
fontSize: 20,
lineHeight: '28px',
fontWeight: 600, fontWeight: 600,
color: 'var(--affine-text-primary-color)',
}}
>
{t['com.affine.collection.emptyCollection']()}
</div>
<div
style={{
fontSize: 12, fontSize: 12,
lineHeight: '20px', lineHeight: '20px',
color: 'var(--affine-text-secondary-color)', color: 'var(--affine-text-secondary-color)',
display: 'flex', textAlign: 'center',
alignItems: 'center',
justifyContent: 'space-between',
}} }}
> >
<div>{t['com.affine.collection.helpInfo']()}</div> {t['com.affine.collection.emptyCollectionDescription']()}
<CloseIcon
className={styles.button}
style={{ width: 16, height: 16 }}
onClick={hideTips}
/>
</div> </div>
<div <div
style={{ style={{
display: 'flex', display: 'flex',
flexDirection: 'column', alignItems: 'center',
gap: 10, gap: '12px 32px',
fontSize: 12, flexWrap: 'wrap',
lineHeight: '20px', justifyContent: 'center',
}} }}
> >
<div> <div onClick={openPageEdit} className={styles.placeholderButton}>
<Trans i18nKey="com.affine.collection.addPages.tips"> <PageIcon
<span style={{ fontWeight: 600 }}>Add pages:</span> You can style={{
freely select pages and add them to the collection. width: 20,
</Trans> height: 20,
color: 'var(--affine-icon-color)',
}}
/>
<span style={{ padding: '0 4px' }}>
{t['com.affine.collection.addPages']()}
</span>
</div> </div>
<div> <div onClick={openRuleEdit} className={styles.placeholderButton}>
<Trans i18nKey="com.affine.collection.addRules.tips"> <FilterIcon
<span style={{ fontWeight: 600 }}>Add rules:</span> Rules are style={{
based on filtering. After adding rules, pages that meet the width: 20,
requirements will be automatically added to the current height: 20,
collection. color: 'var(--affine-icon-color)',
</Trans> }}
/>
<span style={{ padding: '0 4px' }}>
{t['com.affine.collection.addRules']()}
</span>
</div> </div>
</div> </div>
</div> </div>
) : null} {showTips ? (
</div> <div
style={{
maxWidth: 452,
borderRadius: 8,
display: 'flex',
flexDirection: 'column',
backgroundColor: 'var(--affine-background-overlay-panel-color)',
padding: 10,
gap: 14,
margin: '0 12px',
}}
>
<div
style={{
fontWeight: 600,
fontSize: 12,
lineHeight: '20px',
color: 'var(--affine-text-secondary-color)',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<div>{t['com.affine.collection.helpInfo']()}</div>
<CloseIcon
className={styles.button}
style={{ width: 16, height: 16 }}
onClick={hideTips}
/>
</div>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: 10,
fontSize: 12,
lineHeight: '20px',
}}
>
<div>
<Trans i18nKey="com.affine.collection.addPages.tips">
<span style={{ fontWeight: 600 }}>Add pages:</span> You can
freely select pages and add them to the collection.
</Trans>
</div>
<div>
<Trans i18nKey="com.affine.collection.addRules.tips">
<span style={{ fontWeight: 600 }}>Add rules:</span> Rules
are based on filtering. After adding rules, pages that meet
the requirements will be automatically added to the current
collection.
</Trans>
</div>
</div>
</div>
) : null}
</div>
</ViewBodyIsland>
{node} {node}
</div> </>
); );
}; };

View File

@@ -1,59 +1,16 @@
import { cssVar } from '@toeverything/theme';
import { style } from '@vanilla-extract/css'; import { style } from '@vanilla-extract/css';
export const header = style({ export const header = style({
display: 'flex', display: 'flex',
height: '52px', height: '100%',
width: '100%', width: '100%',
alignItems: 'center', alignItems: 'center',
flexShrink: 0, gap: 12,
background: cssVar('backgroundPrimaryColor'),
borderBottom: `1px solid ${cssVar('borderColor')}`,
selectors: {
'&[data-sidebar-floating="false"]': {
['WebkitAppRegion' as string]: 'drag',
},
'&:has([data-popper-placement])': {
['WebkitAppRegion' as string]: 'no-drag',
},
},
'@media': {
print: {
display: 'none',
},
},
});
export const mainHeader = style([
header,
{
padding: '0 16px',
gap: 12,
},
]);
export const sidebarHeader = style([
header,
{
padding: '0 16px',
gap: '12px',
},
]);
export const mainHeaderRight = style({
display: 'flex',
alignItems: 'center',
gap: '8px',
}); });
export const spacer = style({ export const spacer = style({
flexGrow: 1, flexGrow: 1,
minWidth: 12, minWidth: 12,
}); });
export const standaloneExtensionSwitcherWrapper = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
height: '52px',
position: 'relative',
});
export const journalWeekPicker = style({ export const journalWeekPicker = style({
minWidth: 100, minWidth: 100,
flexGrow: 1, flexGrow: 1,

View File

@@ -1,85 +1,20 @@
import type { InlineEditHandle } from '@affine/component'; import type { InlineEditHandle } from '@affine/component';
import { IconButton } from '@affine/component'; import { appSidebarFloatingAtom } from '@affine/component/app-sidebar';
import {
appSidebarFloatingAtom,
appSidebarOpenAtom,
SidebarSwitch,
} from '@affine/component/app-sidebar';
import { FavoriteButton } from '@affine/core/components/blocksuite/block-suite-header/favorite'; import { FavoriteButton } from '@affine/core/components/blocksuite/block-suite-header/favorite';
import { JournalWeekDatePicker } from '@affine/core/components/blocksuite/block-suite-header/journal/date-picker'; import { JournalWeekDatePicker } from '@affine/core/components/blocksuite/block-suite-header/journal/date-picker';
import { JournalTodayButton } from '@affine/core/components/blocksuite/block-suite-header/journal/today-button'; import { JournalTodayButton } from '@affine/core/components/blocksuite/block-suite-header/journal/today-button';
import { PageHeaderMenuButton } from '@affine/core/components/blocksuite/block-suite-header/menu'; import { PageHeaderMenuButton } from '@affine/core/components/blocksuite/block-suite-header/menu';
import { EditorModeSwitch } from '@affine/core/components/blocksuite/block-suite-mode-switch'; import { EditorModeSwitch } from '@affine/core/components/blocksuite/block-suite-mode-switch';
import { useJournalInfoHelper } from '@affine/core/hooks/use-journal'; import { useJournalInfoHelper } from '@affine/core/hooks/use-journal';
import { RightSidebarIcon } from '@blocksuite/icons';
import type { Doc } from '@blocksuite/store'; import type { Doc } from '@blocksuite/store';
import type { Workspace } from '@toeverything/infra'; import type { Workspace } from '@toeverything/infra';
import { useAtomValue, useSetAtom } from 'jotai'; import { useAtomValue } from 'jotai';
import { useCallback, useRef } from 'react'; import { useCallback, useRef } from 'react';
import { SharePageButton } from '../../../components/affine/share-page-modal'; import { SharePageButton } from '../../../components/affine/share-page-modal';
import { BlocksuiteHeaderTitle } from '../../../components/blocksuite/block-suite-header/title/index'; import { BlocksuiteHeaderTitle } from '../../../components/blocksuite/block-suite-header/title/index';
import { HeaderDivider } from '../../../components/pure/header'; import { HeaderDivider } from '../../../components/pure/header';
import { WindowsAppControls } from '../../../components/pure/header/windows-app-controls';
import * as styles from './detail-page-header.css'; import * as styles from './detail-page-header.css';
import { ExtensionTabs } from './editor-sidebar';
import {
editorSidebarOpenAtom,
editorSidebarToggleAtom,
} from './editor-sidebar/atoms';
interface PageHeaderRightProps {
showSidebarSwitch?: boolean;
}
const ToggleSidebarButton = () => {
const toggle = useSetAtom(editorSidebarToggleAtom);
return (
<IconButton size="large" onClick={toggle}>
<RightSidebarIcon />
</IconButton>
);
};
const isWindowsDesktop = environment.isDesktop && environment.isWindows;
const WindowsMainPageHeaderRight = ({
showSidebarSwitch,
}: PageHeaderRightProps) => {
const editorSidebarOpen = useAtomValue(editorSidebarOpenAtom);
if (editorSidebarOpen) {
return null;
}
return (
<>
<HeaderDivider />
<div className={styles.mainHeaderRight} style={{ marginRight: -16 }}>
{showSidebarSwitch ? <ToggleSidebarButton /> : null}
<WindowsAppControls />
</div>
</>
);
};
const NonWindowsMainPageHeaderRight = ({
showSidebarSwitch,
}: PageHeaderRightProps) => {
const editorSidebarOpen = useAtomValue(editorSidebarOpenAtom);
if (editorSidebarOpen || !showSidebarSwitch) {
return null;
}
return (
<>
<HeaderDivider />
<div className={styles.mainHeaderRight}>
<ToggleSidebarButton />
</div>
</>
);
};
function Header({ function Header({
children, children,
@@ -106,21 +41,10 @@ function Header({
interface PageHeaderProps { interface PageHeaderProps {
page: Doc; page: Doc;
workspace: Workspace; workspace: Workspace;
showSidebarSwitch?: boolean;
} }
const RightHeader = isWindowsDesktop export function JournalPageHeader({ page, workspace }: PageHeaderProps) {
? WindowsMainPageHeaderRight
: NonWindowsMainPageHeaderRight;
export function JournalPageHeader({
page,
workspace,
showSidebarSwitch = true,
}: PageHeaderProps) {
const leftSidebarOpen = useAtomValue(appSidebarOpenAtom);
return ( return (
<Header className={styles.mainHeader}> <Header className={styles.header}>
<SidebarSwitch show={!leftSidebarOpen} />
{!leftSidebarOpen ? <HeaderDivider /> : null}
<EditorModeSwitch <EditorModeSwitch
blockSuiteWorkspace={workspace.blockSuiteWorkspace} blockSuiteWorkspace={workspace.blockSuiteWorkspace}
pageId={page?.id} pageId={page?.id}
@@ -137,26 +61,18 @@ export function JournalPageHeader({
{page ? ( {page ? (
<SharePageButton isJournal workspace={workspace} page={page} /> <SharePageButton isJournal workspace={workspace} page={page} />
) : null} ) : null}
<RightHeader showSidebarSwitch={showSidebarSwitch} />
</Header> </Header>
); );
} }
export function NormalPageHeader({ export function NormalPageHeader({ page, workspace }: PageHeaderProps) {
page,
workspace,
showSidebarSwitch = true,
}: PageHeaderProps) {
const titleInputHandleRef = useRef<InlineEditHandle>(null); const titleInputHandleRef = useRef<InlineEditHandle>(null);
const leftSidebarOpen = useAtomValue(appSidebarOpenAtom);
const onRename = useCallback(() => { const onRename = useCallback(() => {
setTimeout(() => titleInputHandleRef.current?.triggerEdit()); setTimeout(() => titleInputHandleRef.current?.triggerEdit());
}, []); }, []);
return ( return (
<Header className={styles.mainHeader}> <Header className={styles.header}>
<SidebarSwitch show={!leftSidebarOpen} />
{!leftSidebarOpen ? <HeaderDivider /> : null}
<EditorModeSwitch <EditorModeSwitch
blockSuiteWorkspace={workspace.blockSuiteWorkspace} blockSuiteWorkspace={workspace.blockSuiteWorkspace}
pageId={page?.id} pageId={page?.id}
@@ -170,7 +86,6 @@ export function NormalPageHeader({
<FavoriteButton pageId={page?.id} /> <FavoriteButton pageId={page?.id} />
<div className={styles.spacer} /> <div className={styles.spacer} />
{page ? <SharePageButton workspace={workspace} page={page} /> : null} {page ? <SharePageButton workspace={workspace} page={page} /> : null}
<RightHeader showSidebarSwitch={showSidebarSwitch} />
</Header> </Header>
); );
} }
@@ -186,36 +101,3 @@ export function DetailPageHeader(props: PageHeaderProps) {
<NormalPageHeader {...props} /> <NormalPageHeader {...props} />
); );
} }
interface SidebarHeaderProps {
workspace: Workspace;
page: Doc;
}
function WindowsSidebarHeader(props: SidebarHeaderProps) {
return (
<>
<Header className={styles.sidebarHeader} style={{ paddingRight: 0 }}>
<div className={styles.spacer} />
<ToggleSidebarButton />
<WindowsAppControls />
</Header>
<div className={styles.standaloneExtensionSwitcherWrapper}>
<ExtensionTabs {...props} />
</div>
</>
);
}
function NonWindowsSidebarHeader(props: SidebarHeaderProps) {
return (
<Header className={styles.sidebarHeader}>
<ExtensionTabs {...props} />
<div className={styles.spacer} />
<ToggleSidebarButton />
</Header>
);
}
export const RightSidebarHeader = isWindowsDesktop
? WindowsSidebarHeader
: NonWindowsSidebarHeader;

View File

@@ -1,26 +1,14 @@
import { cssVar } from '@toeverything/theme'; import { cssVar } from '@toeverything/theme';
import { style } from '@vanilla-extract/css'; import { style } from '@vanilla-extract/css';
export const root = style({
display: 'flex',
height: '100%',
overflow: 'hidden',
width: '100%',
});
export const mainContainer = style({ export const mainContainer = style({
display: 'flex', display: 'flex',
flex: 1,
height: '100%',
position: 'relative',
flexDirection: 'column', flexDirection: 'column',
minWidth: 0, flex: 1,
overflow: 'hidden', overflow: 'hidden',
background: cssVar('backgroundPrimaryColor'), borderTop: `1px solid ${cssVar('borderColor')}`,
selectors: {
[`${root}[data-client-border=true] &`]: {
borderRadius: '4px',
},
},
}); });
export const editorContainer = style({ export const editorContainer = style({
position: 'relative', position: 'relative',
display: 'flex', display: 'flex',
@@ -28,29 +16,3 @@ export const editorContainer = style({
flex: 1, flex: 1,
zIndex: 0, zIndex: 0,
}); });
export const sidebarContainer = style({
display: 'flex',
flexShrink: 0,
height: '100%',
selectors: {
[`${root}[data-client-border=true] &`]: {
paddingLeft: 9,
},
[`${root}[data-client-border=false] &`]: {
borderLeft: `1px solid ${cssVar('borderColor')}`,
},
},
});
export const sidebarContainerInner = style({
display: 'flex',
background: cssVar('backgroundPrimaryColor'),
flexDirection: 'column',
overflow: 'hidden',
height: '100%',
width: '100%',
selectors: {
[`${root}[data-client-border=true] &`]: {
borderRadius: '4px',
},
},
});

View File

@@ -1,6 +1,5 @@
import { Scrollable } from '@affine/component'; import { Scrollable } from '@affine/component';
import { PageDetailSkeleton } from '@affine/component/page-detail-skeleton'; import { PageDetailSkeleton } from '@affine/component/page-detail-skeleton';
import { ResizePanel } from '@affine/component/resize-panel';
import { useBlockSuiteDocMeta } from '@affine/core/hooks/use-block-suite-page-meta'; import { useBlockSuiteDocMeta } from '@affine/core/hooks/use-block-suite-page-meta';
import { import {
BookmarkService, BookmarkService,
@@ -20,13 +19,12 @@ import {
ServiceProviderContext, ServiceProviderContext,
useLiveData, useLiveData,
} from '@toeverything/infra'; } from '@toeverything/infra';
import { appSettingAtom, Workspace } from '@toeverything/infra'; import { Workspace } from '@toeverything/infra';
import { useService } from '@toeverything/infra'; import { useService } from '@toeverything/infra';
import { useAtom, useAtomValue, useSetAtom } from 'jotai'; import { useSetAtom } from 'jotai';
import { import {
memo, memo,
type ReactElement, type ReactElement,
type ReactNode,
useCallback, useCallback,
useEffect, useEffect,
useMemo, useMemo,
@@ -37,84 +35,55 @@ import type { Map as YMap } from 'yjs';
import { recentPageIdsBaseAtom } from '../../../atoms'; import { recentPageIdsBaseAtom } from '../../../atoms';
import { AffineErrorBoundary } from '../../../components/affine/affine-error-boundary'; import { AffineErrorBoundary } from '../../../components/affine/affine-error-boundary';
import { HubIsland } from '../../../components/affine/hub-island';
import { GlobalPageHistoryModal } from '../../../components/affine/page-history-modal'; import { GlobalPageHistoryModal } from '../../../components/affine/page-history-modal';
import { ImagePreviewModal } from '../../../components/image-preview'; import { ImagePreviewModal } from '../../../components/image-preview';
import { PageDetailEditor } from '../../../components/page-detail-editor'; import { PageDetailEditor } from '../../../components/page-detail-editor';
import { TrashPageFooter } from '../../../components/pure/trash-page-footer'; import { TrashPageFooter } from '../../../components/pure/trash-page-footer';
import { TopTip } from '../../../components/top-tip'; import { TopTip } from '../../../components/top-tip';
import { useRegisterBlocksuiteEditorCommands } from '../../../hooks/affine/use-register-blocksuite-editor-commands'; import { useRegisterBlocksuiteEditorCommands } from '../../../hooks/affine/use-register-blocksuite-editor-commands';
import { useActiveBlocksuiteEditor } from '../../../hooks/use-block-suite-editor';
import { usePageDocumentTitle } from '../../../hooks/use-global-state'; import { usePageDocumentTitle } from '../../../hooks/use-global-state';
import { useNavigateHelper } from '../../../hooks/use-navigate-helper'; import { useNavigateHelper } from '../../../hooks/use-navigate-helper';
import {
MultiTabSidebarBody,
MultiTabSidebarHeaderSwitcher,
type SidebarTabName,
} from '../../../modules/multi-tab-sidebar';
import { sidebarTabs } from '../../../modules/multi-tab-sidebar';
import { RightSidebarViewIsland } from '../../../modules/right-sidebar';
import {
useIsActiveView,
ViewBodyIsland,
ViewHeaderIsland,
} from '../../../modules/workbench';
import { performanceRenderLogger } from '../../../shared'; import { performanceRenderLogger } from '../../../shared';
import { PageNotFound } from '../../404'; import { PageNotFound } from '../../404';
import * as styles from './detail-page.css'; import * as styles from './detail-page.css';
import { DetailPageHeader, RightSidebarHeader } from './detail-page-header'; import { DetailPageHeader } from './detail-page-header';
import {
EditorSidebar,
editorSidebarOpenAtom,
editorSidebarResizingAtom,
editorSidebarWidthAtom,
} from './editor-sidebar';
interface DetailPageLayoutProps {
main: ReactNode;
header: ReactNode;
footer: ReactNode;
sidebar: ReactNode;
}
const MIN_SIDEBAR_WIDTH = 320;
const MAX_SIDEBAR_WIDTH = 800;
// todo: consider move to a shared place if we also want to reuse the layout for other routes
const DetailPageLayout = ({
main,
header,
footer,
sidebar,
}: DetailPageLayoutProps): ReactElement => {
const [width, setWidth] = useAtom(editorSidebarWidthAtom);
const { clientBorder } = useAtomValue(appSettingAtom);
const [resizing, setResizing] = useAtom(editorSidebarResizingAtom);
const [open, setOpen] = useAtom(editorSidebarOpenAtom);
return (
<div className={styles.root} data-client-border={clientBorder && open}>
<div className={styles.mainContainer}>
{header}
{main}
{footer}
</div>
{sidebar ? (
<ResizePanel
resizeHandlePos="left"
resizeHandleOffset={clientBorder ? 4 : 0}
width={width}
className={styles.sidebarContainer}
onResizing={setResizing}
resizing={resizing}
open={open}
onOpen={setOpen}
onWidthChange={setWidth}
minWidth={MIN_SIDEBAR_WIDTH}
maxWidth={MAX_SIDEBAR_WIDTH}
>
{sidebar}
</ResizePanel>
) : null}
</div>
);
};
const DetailPageImpl = memo(function DetailPageImpl() { const DetailPageImpl = memo(function DetailPageImpl() {
const page = useService(Doc); const page = useService(Doc);
const pageRecordList = useService(PageRecordList); const pageRecordList = useService(PageRecordList);
const currentPageId = page.id; const currentPageId = page.id;
const { openPage, jumpToTag } = useNavigateHelper(); const { openPage, jumpToTag } = useNavigateHelper();
const [editor, setEditor] = useState<AffineEditorContainer | null>(null);
const currentWorkspace = useService(Workspace); const currentWorkspace = useService(Workspace);
const blockSuiteWorkspace = currentWorkspace.blockSuiteWorkspace; const blockSuiteWorkspace = currentWorkspace.blockSuiteWorkspace;
const isActiveView = useIsActiveView();
// TODO: remove jotai here
const [_, setActiveBlockSuiteEditor] = useActiveBlocksuiteEditor();
useEffect(() => {
if (isActiveView) {
setActiveBlockSuiteEditor(editor);
}
}, [editor, isActiveView, setActiveBlockSuiteEditor]);
const [activeTabName, setActiveTabName] = useState<SidebarTabName | null>(
null
);
const pageMeta = useBlockSuiteDocMeta(blockSuiteWorkspace).find( const pageMeta = useBlockSuiteDocMeta(blockSuiteWorkspace).find(
meta => meta.id === page.id meta => meta.id === page.id
); );
@@ -184,6 +153,9 @@ const DetailPageImpl = memo(function DetailPageImpl() {
const disposeTagClick = editor.slots.tagClicked.on(({ tagId }) => { const disposeTagClick = editor.slots.tagClicked.on(({ tagId }) => {
jumpToTag(currentWorkspace.id, tagId); jumpToTag(currentWorkspace.id, tagId);
}); });
setEditor(editor);
return () => { return () => {
dispose.dispose(); dispose.dispose();
disposeTagClick.dispose(); disposeTagClick.dispose();
@@ -200,24 +172,23 @@ const DetailPageImpl = memo(function DetailPageImpl() {
] ]
); );
const isWindowsDesktop = environment.isDesktop && environment.isWindows;
return ( return (
<> <>
<DetailPageLayout <ViewHeaderIsland>
header={ <DetailPageHeader
<> page={page.blockSuiteDoc}
<DetailPageHeader workspace={currentWorkspace}
page={page.blockSuiteDoc} />
workspace={currentWorkspace} </ViewHeaderIsland>
showSidebarSwitch={!isInTrash} <ViewBodyIsland>
/> <div className={styles.mainContainer}>
<TopTip pageId={currentPageId} workspace={currentWorkspace} /> {/* Add a key to force rerender when page changed, to avoid error boundary persisting. */}
</>
}
main={
// Add a key to force rerender when page changed, to avoid error boundary persisting.
<AffineErrorBoundary key={currentPageId}> <AffineErrorBoundary key={currentPageId}>
<Scrollable.Root> <Scrollable.Root>
<Scrollable.Viewport className={styles.editorContainer}> <Scrollable.Viewport className={styles.editorContainer}>
<TopTip pageId={currentPageId} workspace={currentWorkspace} />
<PageDetailEditor <PageDetailEditor
pageId={currentPageId} pageId={currentPageId}
onLoad={onLoad} onLoad={onLoad}
@@ -226,25 +197,42 @@ const DetailPageImpl = memo(function DetailPageImpl() {
</Scrollable.Viewport> </Scrollable.Viewport>
<Scrollable.Scrollbar /> <Scrollable.Scrollbar />
</Scrollable.Root> </Scrollable.Root>
<HubIsland />
</AffineErrorBoundary> </AffineErrorBoundary>
} {isInTrash ? <TrashPageFooter pageId={page.id} /> : null}
footer={isInTrash ? <TrashPageFooter pageId={page.id} /> : null} </div>
sidebar={ </ViewBodyIsland>
!isInTrash ? (
<div className={styles.sidebarContainerInner}> <RightSidebarViewIsland
<RightSidebarHeader active={isActiveView}
workspace={currentWorkspace} header={
page={page.blockSuiteDoc} !isWindowsDesktop ? (
/> <MultiTabSidebarHeaderSwitcher
<EditorSidebar activeTabName={activeTabName ?? sidebarTabs[0]?.name}
workspace={blockSuiteWorkspace} setActiveTabName={setActiveTabName}
page={page.blockSuiteDoc} tabs={sidebarTabs}
/> />
</div>
) : null ) : null
} }
body={
<MultiTabSidebarBody
editor={editor}
tab={
sidebarTabs.find(ext => ext.name === activeTabName) ??
sidebarTabs[0]
}
>
{/* Show switcher in body for windows desktop */}
{isWindowsDesktop && (
<MultiTabSidebarHeaderSwitcher
activeTabName={activeTabName ?? sidebarTabs[0]?.name}
setActiveTabName={setActiveTabName}
tabs={sidebarTabs}
/>
)}
</MultiTabSidebarBody>
}
/> />
<ImagePreviewModal <ImagePreviewModal
pageId={currentPageId} pageId={currentPageId}
workspace={blockSuiteWorkspace} workspace={blockSuiteWorkspace}

View File

@@ -1,99 +0,0 @@
// main editor sidebar states
import { assertExists, isEqual } from '@blocksuite/global/utils';
import { atom } from 'jotai';
import { selectAtom } from 'jotai/utils';
import { copilotExtension } from './extensions/copilot';
import { framePanelExtension } from './extensions/frame';
import { journalExtension } from './extensions/journal';
import { outlineExtension } from './extensions/outline';
import type { EditorExtension, EditorExtensionName } from './types';
// the list of all possible extensions in affine.
// order matters (determines the order of the tabs)
export const extensions: EditorExtension[] = [
journalExtension,
outlineExtension,
framePanelExtension,
copilotExtension,
];
export interface EditorSidebarState {
isOpen: boolean;
width: number;
resizing: boolean;
activeExtension?: EditorExtension;
extensions: EditorExtension[];
}
const baseStateAtom = atom<EditorSidebarState>({
isOpen: false,
resizing: false,
width: 300, // todo: should be resizable
activeExtension: extensions[0],
extensions: extensions, // todo: maybe should be dynamic (by feature flag?)
});
const isOpenAtom = selectAtom(baseStateAtom, state => state.isOpen);
const resizingAtom = selectAtom(baseStateAtom, state => state.resizing);
const activeExtensionAtom = selectAtom(
baseStateAtom,
state => state.activeExtension
);
const widthAtom = selectAtom(baseStateAtom, state => state.width);
export const editorExtensionsAtom = selectAtom(
baseStateAtom,
state => state.extensions,
isEqual
);
// get/set sidebar open state
export const editorSidebarOpenAtom = atom(
get => get(isOpenAtom),
(_, set, isOpen: boolean) => {
set(baseStateAtom, prev => {
return { ...prev, isOpen };
});
}
);
// get/set sidebar resizing state
export const editorSidebarResizingAtom = atom(
get => get(resizingAtom),
(_, set, resizing: boolean) => {
set(baseStateAtom, prev => {
return { ...prev, resizing };
});
}
);
// get/set active extension
export const editorSidebarActiveExtensionAtom = atom(
get => get(activeExtensionAtom),
(_, set, extension: EditorExtensionName) => {
set(baseStateAtom, prev => {
const extensions = prev.extensions;
const newExtension = extensions.find(e => e.name === extension);
assertExists(newExtension, `extension ${extension} not found`);
return { ...prev, activeExtension: newExtension };
});
}
);
// toggle sidebar (write only)
export const editorSidebarToggleAtom = atom(null, (_, set) => {
set(baseStateAtom, prev => {
return { ...prev, isOpen: !prev.isOpen };
});
});
// get/set sidebar width
export const editorSidebarWidthAtom = atom(
get => get(widthAtom),
(_, set, width: number) => {
set(baseStateAtom, prev => {
return { ...prev, width };
});
}
);

View File

@@ -1,16 +0,0 @@
import { useAtomValue } from 'jotai';
import type { EditorExtensionProps } from '.';
import { editorSidebarActiveExtensionAtom } from './atoms';
import * as styles from './editor-sidebar.css';
export const EditorSidebar = (props: EditorExtensionProps) => {
const activeExtension = useAtomValue(editorSidebarActiveExtensionAtom);
const Component = activeExtension?.Component;
return (
<div className={styles.root}>
{Component ? <Component {...props} /> : null}
</div>
);
};

View File

@@ -1,69 +0,0 @@
import { IconButton } from '@affine/component';
import { useJournalInfoHelper } from '@affine/core/hooks/use-journal';
import { useWorkspaceEnabledFeatures } from '@affine/core/hooks/use-workspace-features';
import { FeatureType } from '@affine/graphql';
import type { Doc } from '@blocksuite/store';
import type { Workspace } from '@toeverything/infra';
import { assignInlineVars } from '@vanilla-extract/dynamic';
import { useAtom, useAtomValue } from 'jotai';
import { useEffect } from 'react';
import {
editorExtensionsAtom,
editorSidebarActiveExtensionAtom,
} from '../atoms';
import * as styles from './extensions.css';
export interface ExtensionTabsProps {
workspace: Workspace;
page: Doc;
}
// provide a switcher for active extensions
// will be used in global top header (MacOS) or sidebar (Windows)
export const ExtensionTabs = ({ page, workspace }: ExtensionTabsProps) => {
// todo: filter in editorExtensionsAtom instead?
const copilotEnabled = useWorkspaceEnabledFeatures(workspace.meta).includes(
FeatureType.Copilot
);
const { isJournal } = useJournalInfoHelper(page.workspace, page.id);
const exts = useAtomValue(editorExtensionsAtom).filter(ext => {
if (ext.name === 'copilot' && !copilotEnabled) return false;
return true;
});
const [selected, setSelected] = useAtom(editorSidebarActiveExtensionAtom);
// if journal is active, set selected to journal
useEffect(() => {
isJournal && setSelected('journal');
}, [isJournal, setSelected]);
const vars = assignInlineVars({
[styles.activeIdx]: String(
exts.findIndex(ext => ext.name === selected?.name) ?? 0
),
});
useEffect(() => {
if (!selected || !exts.some(e => selected.name === e.name)) {
setSelected(exts[0].name);
}
}, [exts, selected, setSelected]);
return (
<div className={styles.switchRoot} style={vars}>
{exts.map(extension => {
return (
<IconButton
onClick={() => setSelected(extension.name)}
key={extension.name}
data-active={selected?.name === extension.name}
className={styles.button}
>
{extension.icon}
</IconButton>
);
})}
</div>
);
};

View File

@@ -1,4 +0,0 @@
export * from './atoms';
export * from './editor-sidebar';
export * from './extensions';
export * from './types';

View File

@@ -1,15 +0,0 @@
import type { BlockSuiteWorkspace } from '@affine/core/shared';
import type { Doc } from '@blocksuite/store';
export type EditorExtensionName = 'outline' | 'frame' | 'copilot' | 'journal';
export interface EditorExtensionProps {
workspace: BlockSuiteWorkspace;
page: Doc;
}
export interface EditorExtension {
name: EditorExtensionName;
icon: React.ReactNode;
Component: React.ComponentType<EditorExtensionProps>;
}

View File

@@ -21,8 +21,10 @@ import {
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { AffineErrorBoundary } from '../../components/affine/affine-error-boundary'; import { AffineErrorBoundary } from '../../components/affine/affine-error-boundary';
import { HubIsland } from '../../components/affine/hub-island';
import { WorkspaceLayout } from '../../layouts/workspace-layout'; import { WorkspaceLayout } from '../../layouts/workspace-layout';
import { WorkbenchRoot } from '../../modules/workbench/workbench-root'; import { RightSidebarContainer } from '../../modules/right-sidebar';
import { WorkbenchRoot } from '../../modules/workbench';
import { CurrentWorkspaceService } from '../../modules/workspace/current-workspace'; import { CurrentWorkspaceService } from '../../modules/workspace/current-workspace';
import { performanceRenderLogger } from '../../shared'; import { performanceRenderLogger } from '../../shared';
import { PageNotFound } from '../404'; import { PageNotFound } from '../404';
@@ -115,6 +117,8 @@ export const Component = (): ReactElement => {
<AffineErrorBoundary height="100vh"> <AffineErrorBoundary height="100vh">
<WorkspaceLayout> <WorkspaceLayout>
<WorkbenchRoot /> <WorkbenchRoot />
<RightSidebarContainer />
<HubIsland />
</WorkspaceLayout> </WorkspaceLayout>
</AffineErrorBoundary> </AffineErrorBoundary>
</Suspense> </Suspense>

View File

@@ -76,6 +76,7 @@ export const EmptyCollectionList = ({ heading }: { heading: ReactNode }) => {
</div> </div>
); );
}; };
export const EmptyTagList = ({ heading }: { heading: ReactNode }) => { export const EmptyTagList = ({ heading }: { heading: ReactNode }) => {
const t = useAFFiNEI18N(); const t = useAFFiNEI18N();
return ( return (

View File

@@ -1,23 +1,6 @@
import { Header } from '@affine/core/components/pure/header'; import { Header } from '@affine/core/components/pure/header';
import { WindowsAppControls } from '@affine/core/components/pure/header/windows-app-controls';
import { WorkspaceModeFilterTab } from '@affine/core/components/pure/workspace-mode-filter-tab'; import { WorkspaceModeFilterTab } from '@affine/core/components/pure/workspace-mode-filter-tab';
import * as styles from '../all-page/all-page.css';
export const TagDetailHeader = () => { export const TagDetailHeader = () => {
const isWindowsDesktop = environment.isDesktop && environment.isWindows; return <Header center={<WorkspaceModeFilterTab activeFilter={'tags'} />} />;
return (
<Header
right={
<div
className={styles.headerRightWindows}
data-is-windows-desktop={isWindowsDesktop}
>
{isWindowsDesktop ? <WindowsAppControls /> : null}
</div>
}
center={<WorkspaceModeFilterTab activeFilter={'tags'} />}
/>
);
}; };

View File

@@ -1,4 +1,3 @@
import { HubIsland } from '@affine/core/components/affine/hub-island';
import { import {
PageListHeader, PageListHeader,
useTagMetas, useTagMetas,
@@ -11,7 +10,6 @@ import { useMemo } from 'react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { PageNotFound } from '../../404'; import { PageNotFound } from '../../404';
import * as styles from '../all-page/all-page.css';
import { EmptyPageList } from '../page-list-empty'; import { EmptyPageList } from '../page-list-empty';
import { TagDetailHeader } from './header'; import { TagDetailHeader } from './header';
@@ -40,7 +38,7 @@ export const TagDetail = ({ tagId }: { tagId?: string }) => {
} }
return ( return (
<div className={styles.root}> <>
<TagDetailHeader /> <TagDetailHeader />
{tagPageMetas.length > 0 ? ( {tagPageMetas.length > 0 ? (
<VirtualizedPageList tag={currentTag} listItem={tagPageMetas} /> <VirtualizedPageList tag={currentTag} listItem={tagPageMetas} />
@@ -51,8 +49,7 @@ export const TagDetail = ({ tagId }: { tagId?: string }) => {
blockSuiteWorkspace={currentWorkspace.blockSuiteWorkspace} blockSuiteWorkspace={currentWorkspace.blockSuiteWorkspace}
/> />
)} )}
<HubIsland /> </>
</div>
); );
}; };

View File

@@ -1,6 +1,5 @@
import { cssVar } from '@toeverything/theme'; import { cssVar } from '@toeverything/theme';
import { style } from '@vanilla-extract/css'; import { style } from '@vanilla-extract/css';
export { root } from './all-page/all-page.css';
export const trashTitle = style({ export const trashTitle = style({
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
@@ -8,6 +7,13 @@ export const trashTitle = style({
padding: '0 8px', padding: '0 8px',
fontWeight: 600, fontWeight: 600,
}); });
export const body = style({
display: 'flex',
flexDirection: 'column',
flex: 1,
height: '100%',
width: '100%',
});
export const trashIcon = style({ export const trashIcon = style({
color: cssVar('iconColor'), color: cssVar('iconColor'),
fontSize: cssVar('fontH5'), fontSize: cssVar('fontH5'),

View File

@@ -21,6 +21,7 @@ import { Workspace } from '@toeverything/infra';
import { useService } from '@toeverything/infra/di'; import { useService } from '@toeverything/infra/di';
import { useCallback } from 'react'; import { useCallback } from 'react';
import { ViewBodyIsland, ViewHeaderIsland } from '../../modules/workbench';
import { EmptyPageList } from './page-list-empty'; import { EmptyPageList } from './page-list-empty';
import * as styles from './trash-page.css'; import * as styles from './trash-page.css';
@@ -94,26 +95,32 @@ export const TrashPage = () => {
return <ListTableHeader headerCols={pageHeaderColsDef} />; return <ListTableHeader headerCols={pageHeaderColsDef} />;
}, []); }, []);
return ( return (
<div className={styles.root}> <>
<TrashHeader /> <ViewHeaderIsland>
{filteredPageMetas.length > 0 ? ( <TrashHeader />
<VirtualizedList </ViewHeaderIsland>
items={filteredPageMetas} <ViewBodyIsland>
rowAsLink <div className={styles.body}>
groupBy={false} {filteredPageMetas.length > 0 ? (
isPreferredEdgeless={isPreferredEdgeless} <VirtualizedList
blockSuiteWorkspace={currentWorkspace.blockSuiteWorkspace} items={filteredPageMetas}
operationsRenderer={pageOperationsRenderer} rowAsLink
itemRenderer={pageItemRenderer} groupBy={false}
headerRenderer={pageHeaderRenderer} isPreferredEdgeless={isPreferredEdgeless}
/> blockSuiteWorkspace={currentWorkspace.blockSuiteWorkspace}
) : ( operationsRenderer={pageOperationsRenderer}
<EmptyPageList itemRenderer={pageItemRenderer}
type="trash" headerRenderer={pageHeaderRenderer}
blockSuiteWorkspace={currentWorkspace.blockSuiteWorkspace} />
/> ) : (
)} <EmptyPageList
</div> type="trash"
blockSuiteWorkspace={currentWorkspace.blockSuiteWorkspace}
/>
)}
</div>
</ViewBodyIsland>
</>
); );
}; };

View File

@@ -0,0 +1,41 @@
import { LiveData, useLiveData } from '@toeverything/infra/livedata';
import { useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
export const createIsland = () => {
const targetLiveData = new LiveData<HTMLDivElement | null>(null);
let mounted = false;
let provided = false;
return {
Target: ({ ...other }: React.HTMLProps<HTMLDivElement>) => {
const target = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (mounted === true) {
throw new Error('Island should not be mounted more than once');
}
mounted = true;
targetLiveData.next(target.current);
return () => {
mounted = false;
targetLiveData.next(null);
};
}, []);
return <div {...other} ref={target}></div>;
},
Provider: ({ children }: React.PropsWithChildren) => {
const target = useLiveData(targetLiveData);
useEffect(() => {
if (provided === true && process.env.NODE_ENV !== 'production') {
throw new Error('Island should not be provided more than once');
}
provided = true;
return () => {
provided = false;
};
}, []);
return target ? createPortal(children, target) : null;
},
};
};
export type Island = ReturnType<typeof createIsland>;

View File

@@ -1,4 +0,0 @@
{
"id": "709a97a0-aa01-4d0b-b29a-f3ed2f63033c",
"mainDBPath": "/Users/eyhn/AFFiNE/packages/frontend/electron/test/db/tmp/app-data/workspaces/709a97a0-aa01-4d0b-b29a-f3ed2f63033c/storage.db"
}