feat(component): new right sidebar (#5169)

Refactor AFFiNE layout to support new right sidebar.

The new layout:
![image](https://github.com/toeverything/AFFiNE/assets/584378/678a05f5-bd48-4dbe-ad78-7a0bcc979918)

**Highlights:**
- new sidebar UI/UX
- favoring top-down UI components that are composed by basic building blocks in each route, instead of creating universal component like `WorkspaceHeader` that renders every possible cases (which I think is really hard to maintain)
- remove plugin based solution

**Pros/cons for current plugin-based solution:**

The current solution is somewhat a Dependency Injection (DI) approach, where the layout is defined at the top and UI items can be injected using Jotai atom slots.
This approach works well if we want a fully configurable system with everything being handled by plugins. It provides flexibility for custom extensions.
However, this solution is more suitable for single-page applications where the UI is completely controlled by configuration. It becomes challenging to achieve an optimized and visually appealing UI that remains under our control. An example of such a scenario would be a customizable dashboard like Grafana.
Another drawback of the existing solution is that we need to use Jotai and hooks to access context values, resulting in an unclear data flow within the component hierarchy.

**Alternatively, our approach in this PR** provides layout building blocks such as headers and sidebars, which can then be composed in individual route components. The good is that we have cleaner biz component instead of vague all-in-one layout component (like `<WorkspaceHeader />`).

**Issues of the implementation in this PR:**
Some UI layouts that that seems to be defined at the root layout are now defined in individual route component instead.
New 3-col layout component like the right sidebar still needs some abstraction and they are right now just for the detail editor only.
This commit is contained in:
Peng Xiao 2023-12-08 01:03:47 +00:00
parent 980831f9f1
commit 5352736eba
No known key found for this signature in database
GPG Key ID: 23F23D9E8B3971ED
60 changed files with 1424 additions and 1052 deletions

View File

@ -1,5 +1,3 @@
import type { EditorContainer } from '@blocksuite/presets';
import type { Page } from '@blocksuite/store';
import type {
ActiveDocProvider,
PassiveDocProvider,
@ -134,18 +132,6 @@ type UIBaseProps<_Flavour extends keyof WorkspaceRegistry> = {
currentWorkspaceId: string;
};
export type WorkspaceHeaderProps<Flavour extends keyof WorkspaceRegistry> =
UIBaseProps<Flavour> & {
rightSlot?: ReactNode;
currentEntry:
| {
subPath: WorkspaceSubPath;
}
| {
pageId: string;
};
};
type NewSettingProps<Flavour extends keyof WorkspaceRegistry> =
UIBaseProps<Flavour> & {
onDeleteLocalWorkspace: () => void;
@ -161,18 +147,11 @@ type NewSettingProps<Flavour extends keyof WorkspaceRegistry> =
) => void;
};
type PageDetailProps<Flavour extends keyof WorkspaceRegistry> =
UIBaseProps<Flavour> & {
currentPageId: string;
onLoadEditor: (page: Page, editor: EditorContainer) => () => void;
};
interface FC<P> {
(props: P): ReactNode;
}
export interface WorkspaceUISchema<Flavour extends keyof WorkspaceRegistry> {
PageDetail: FC<PageDetailProps<Flavour>>;
NewSettingsDetail: FC<NewSettingProps<Flavour>>;
Provider: FC<PropsWithChildren>;
LoginCard?: FC<object>;

View File

@ -54,13 +54,11 @@ export const mainContainerStyle = style({
width: 0,
flex: 1,
maxWidth: '100%',
backgroundColor: 'var(--affine-background-primary-color)',
selectors: {
'&[data-show-padding="true"]': {
margin: '8px',
borderRadius: '5px',
overflow: 'hidden',
boxShadow: 'var(--affine-shadow-1)',
'@media': {
print: {
overflow: 'visible',
@ -72,12 +70,6 @@ export const mainContainerStyle = style({
'&[data-show-padding="true"][data-is-macos="true"]': {
borderRadius: '6px',
},
'&[data-in-trash-page="true"]': {
marginBottom: '66px',
},
'&[data-in-trash-page="true"][data-show-padding="true"]': {
marginBottom: '66px',
},
'&[data-show-padding="true"]:before': {
content: '""',
position: 'absolute',
@ -124,7 +116,7 @@ globalStyle(
);
export const toolStyle = style({
position: 'fixed',
position: 'absolute',
right: '30px',
bottom: '30px',
zIndex: 'var(--affine-z-index-popover)',
@ -143,20 +135,4 @@ export const toolStyle = style({
display: 'none',
},
},
selectors: {
'&[data-in-trash-page="true"]': {
bottom: '70px',
'@media': {
'screen and (max-width: 960px)': {
bottom: '80px',
},
'screen and (max-width: 640px)': {
bottom: '85px',
},
print: {
display: 'none',
},
},
},
},
});

View File

@ -35,14 +35,13 @@ export const AppContainer = ({
export interface MainContainerProps extends HTMLAttributes<HTMLDivElement> {
className?: string;
padding?: boolean;
inTrashPage?: boolean;
}
export const MainContainer = forwardRef<
HTMLDivElement,
PropsWithChildren<MainContainerProps>
>(function MainContainer(
{ className, padding, inTrashPage, children, ...props },
{ className, padding, children, ...props },
ref
): ReactElement {
return (
@ -51,7 +50,6 @@ export const MainContainer = forwardRef<
className={clsx(mainContainerStyle, className)}
data-is-macos={environment.isDesktop && environment.isMacOs}
data-show-padding={!!padding}
data-in-trash-page={!!inTrashPage}
ref={ref}
>
{children}
@ -61,14 +59,8 @@ export const MainContainer = forwardRef<
MainContainer.displayName = 'MainContainer';
export const ToolContainer = (
props: PropsWithChildren & { inTrashPage: boolean }
): ReactElement => {
return (
<div className={toolStyle} data-in-trash-page={!!props.inTrashPage}>
{props.children}
</div>
);
export const ToolContainer = (props: PropsWithChildren): ReactElement => {
return <div className={toolStyle}>{props.children}</div>;
};
export const WorkspaceFallback = (): ReactElement => {

View File

@ -6,6 +6,7 @@ export const button = style({
alignItems: 'center',
userSelect: 'none',
touchAction: 'manipulation',
flexShrink: 0,
outline: '0',
border: '1px solid',
padding: '0 18px',

View File

@ -1,19 +1,11 @@
import { PageNotFoundError } from '@affine/env/constant';
import type {
WorkspaceFlavour,
WorkspaceUISchema,
} from '@affine/env/workspace';
import { lazy, useCallback } from 'react';
import { lazy } from 'react';
import type { OnLoadEditor } from '../../components/page-detail-editor';
import { useCurrentUser } from '../../hooks/affine/use-current-user';
import { useIsWorkspaceOwner } from '../../hooks/affine/use-is-workspace-owner';
import { useWorkspace } from '../../hooks/use-workspace';
import {
NewWorkspaceSettingDetail,
PageDetailEditor,
Provider,
} from '../shared';
import { NewWorkspaceSettingDetail, Provider } from '../shared';
const LoginCard = lazy(() =>
import('../../components/cloud/login-card').then(({ LoginCard }) => ({
@ -24,36 +16,6 @@ const LoginCard = lazy(() =>
export const UI = {
Provider,
LoginCard,
PageDetail: ({ currentWorkspaceId, currentPageId, onLoadEditor }) => {
const workspace = useWorkspace(currentWorkspaceId);
const page = workspace.blockSuiteWorkspace.getPage(currentPageId);
if (!page) {
throw new PageNotFoundError(workspace.blockSuiteWorkspace, currentPageId);
}
// this should be safe because we are under cloud workspace adapter
const currentUser = useCurrentUser();
const onLoad = useCallback<OnLoadEditor>(
(...args) => {
const dispose = onLoadEditor(...args);
workspace.blockSuiteWorkspace.awarenessStore.awareness.setLocalStateField(
'user',
{
name: currentUser.name,
}
);
return dispose;
},
[currentUser, workspace, onLoadEditor]
);
return (
<PageDetailEditor
pageId={currentPageId}
onLoad={onLoad}
workspace={workspace.blockSuiteWorkspace}
/>
);
},
NewSettingsDetail: ({
currentWorkspaceId,
onTransformWorkspace,

View File

@ -1,8 +1,5 @@
import { DebugLogger } from '@affine/debug';
import {
DEFAULT_WORKSPACE_NAME,
PageNotFoundError,
} from '@affine/env/constant';
import { DEFAULT_WORKSPACE_NAME } from '@affine/env/constant';
import type { WorkspaceAdapter } from '@affine/env/workspace';
import {
LoadPriority,
@ -14,19 +11,13 @@ import {
saveWorkspaceToLocalStorage,
} from '@affine/workspace/local/crud';
import { getOrCreateWorkspace } from '@affine/workspace/manager';
import { getBlockSuiteWorkspaceAtom } from '@toeverything/infra/__internal__/workspace';
import { getCurrentStore } from '@toeverything/infra/atom';
import { initEmptyPage } from '@toeverything/infra/blocksuite';
import { buildShowcaseWorkspace } from '@toeverything/infra/blocksuite';
import { useAtomValue } from 'jotai';
import { nanoid } from 'nanoid';
import { setPageModeAtom } from '../../atoms';
import {
NewWorkspaceSettingDetail,
PageDetailEditor,
Provider,
} from '../shared';
import { NewWorkspaceSettingDetail, Provider } from '../shared';
const logger = new DebugLogger('use-create-first-workspace');
@ -68,21 +59,6 @@ export const LocalAdapter: WorkspaceAdapter<WorkspaceFlavour.LOCAL> = {
CRUD,
UI: {
Provider,
PageDetail: ({ currentWorkspaceId, currentPageId, onLoadEditor }) => {
const [workspaceAtom] = getBlockSuiteWorkspaceAtom(currentWorkspaceId);
const workspace = useAtomValue(workspaceAtom);
const page = workspace.getPage(currentPageId);
if (!page) {
throw new PageNotFoundError(workspace, currentPageId);
}
return (
<PageDetailEditor
pageId={currentPageId}
onLoad={onLoadEditor}
workspace={workspace}
/>
);
},
NewSettingsDetail: ({
currentWorkspaceId,
onTransformWorkspace,

View File

@ -1,26 +1,10 @@
import { PageNotFoundError } from '@affine/env/constant';
import type { WorkspaceFlavour } from '@affine/env/workspace';
import { type WorkspaceUISchema } from '@affine/env/workspace';
import { useWorkspace } from '../../hooks/use-workspace';
import { PageDetailEditor, Provider } from '../shared';
import { Provider } from '../shared';
export const UI = {
Provider,
PageDetail: ({ currentWorkspaceId, currentPageId, onLoadEditor }) => {
const workspace = useWorkspace(currentWorkspaceId);
const page = workspace.blockSuiteWorkspace.getPage(currentPageId);
if (!page) {
throw new PageNotFoundError(workspace.blockSuiteWorkspace, currentPageId);
}
return (
<PageDetailEditor
pageId={currentPageId}
onLoad={onLoadEditor}
workspace={workspace.blockSuiteWorkspace}
/>
);
},
NewSettingsDetail: () => {
throw new Error('Not implemented');
},

View File

@ -13,9 +13,3 @@ export const NewWorkspaceSettingDetail = lazy(() =>
})
)
);
export const PageDetailEditor = lazy(() =>
import('../components/page-detail-editor').then(({ PageDetailEditor }) => ({
default: PageDetailEditor,
}))
);

View File

@ -1,5 +0,0 @@
import { atom } from 'jotai/vanilla';
export const appHeaderAtom = atom<HTMLDivElement | null>(null);
export const mainContainerAtom = atom<HTMLDivElement | null>(null);

View File

@ -0,0 +1,12 @@
import { registerTOCComponents } from '@blocksuite/blocks';
registerTOCComponents(components => {
for (const compName in components) {
if (window.customElements.get(compName)) continue;
window.customElements.define(
compName,
components[compName as keyof typeof components]
);
}
});

View File

@ -1,3 +1,5 @@
import './register-blocksuite-components';
import { setupGlobal } from '@affine/env/global';
import type { WorkspaceAdapter } from '@affine/env/workspace';
import type { WorkspaceFlavour } from '@affine/env/workspace';

View File

@ -0,0 +1,45 @@
import { Suspense, useEffect } from 'react';
import { useCurrentLoginStatus } from '../../../hooks/affine/use-current-login-status';
import { useCurrentUser } from '../../../hooks/affine/use-current-user';
import { useCurrentWorkspace } from '../../../hooks/current/use-current-workspace';
const SyncAwarenessInnerLoggedIn = () => {
const currentUser = useCurrentUser();
const [{ blockSuiteWorkspace: workspace }] = useCurrentWorkspace();
useEffect(() => {
if (currentUser && workspace) {
workspace.awarenessStore.awareness.setLocalStateField('user', {
name: currentUser.name,
// todo: add avatar?
});
return () => {
workspace.awarenessStore.awareness.setLocalStateField('user', null);
};
}
return;
}, [currentUser, workspace]);
return null;
};
const SyncAwarenessInner = () => {
const loginStatus = useCurrentLoginStatus();
if (loginStatus === 'authenticated') {
return <SyncAwarenessInnerLoggedIn />;
}
return null;
};
// todo: we could do something more interesting here, e.g., show where the current user is
export const SyncAwareness = () => {
return (
<Suspense>
<SyncAwarenessInner />
</Suspense>
);
};

View File

@ -0,0 +1,11 @@
import { ToolContainer } from '@affine/component/workspace';
import { HelpIsland } from '../../pure/help-island';
export const HubIsland = () => {
return (
<ToolContainer>
<HelpIsland />
</ToolContainer>
);
};

View File

@ -14,7 +14,7 @@ type SharePageModalProps = {
page: Page;
};
export const SharePageModal = ({ workspace, page }: SharePageModalProps) => {
export const SharePageButton = ({ workspace, page }: SharePageModalProps) => {
const onTransformWorkspace = useOnTransformWorkspace();
const [open, setOpen] = useState(false);

View File

@ -60,7 +60,7 @@ const LocalShareMenu = (props: ShareMenuProps) => {
modal: false,
}}
>
<Button data-testid="local-share-menu-button" type="plain">
<Button data-testid="local-share-menu-button" type="primary">
{t['com.affine.share-menu.shareButton']()}
</Button>
</Menu>
@ -86,7 +86,7 @@ const CloudShareMenu = (props: ShareMenuProps) => {
modal: false,
}}
>
<Button data-testid="cloud-share-menu-button" type="plain">
<Button data-testid="cloud-share-menu-button" type="primary">
<div
style={{
color: isSharedPage

View File

@ -2,7 +2,7 @@ import { type ComplexStyleRule, style } from '@vanilla-extract/css';
export const headerTitleContainer = style({
display: 'flex',
justifyContent: 'center',
justifyContent: 'flex-start',
alignItems: 'center',
flexGrow: 1,
position: 'relative',

View File

@ -1,21 +1,8 @@
import { globalStyle, style } from '@vanilla-extract/css';
/**
* Editor container element layer should be lower than header and after auto
* The zIndex of header is 2, defined in packages/frontend/core/src/components/pure/header/style.css.tsx
*/
export const editorContainer = style({
position: 'relative',
zIndex: 0, // it will create stacking context to limit layer of child elements and be lower than after auto zIndex
});
export const pluginContainer = style({
height: '100%',
width: '100%',
});
export const editor = style({
height: '100%',
flex: 1,
overflow: 'auto',
selectors: {
'&.full-screen': {
vars: {

View File

@ -1,32 +1,17 @@
import './page-detail-editor.css';
import { PageNotFoundError } from '@affine/env/constant';
import type { LayoutNode } from '@affine/sdk/entry';
import { assertExists, DisposableGroup } from '@blocksuite/global/utils';
import type { EditorContainer } from '@blocksuite/presets';
import type { Page, Workspace } from '@blocksuite/store';
import { useBlockSuitePageMeta } from '@toeverything/hooks/use-block-suite-page-meta';
import { useBlockSuiteWorkspacePage } from '@toeverything/hooks/use-block-suite-workspace-page';
import {
addCleanup,
pluginEditorAtom,
pluginWindowAtom,
} from '@toeverything/infra/__internal__/plugin';
import { contentLayoutAtom, getCurrentStore } from '@toeverything/infra/atom';
import { pluginEditorAtom } from '@toeverything/infra/__internal__/plugin';
import { getCurrentStore } from '@toeverything/infra/atom';
import clsx from 'clsx';
import { useAtomValue } from 'jotai';
import type { CSSProperties, ReactElement } from 'react';
import {
memo,
startTransition,
Suspense,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
import type { CSSProperties } from 'react';
import { memo, Suspense, useCallback, useMemo, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { type PageMode, pageSettingFamily } from '../atoms';
@ -35,8 +20,6 @@ import { useAppSettingHelper } from '../hooks/affine/use-app-setting-helper';
import { useBlockSuiteMetaHelper } from '../hooks/affine/use-block-suite-meta-helper';
import { BlockSuiteEditor as Editor } from './blocksuite/block-suite-editor';
import * as styles from './page-detail-editor.css';
import { editorContainer, pluginContainer } from './page-detail-editor.css';
import { TrashButtonGroup } from './pure/trash-button-group';
declare global {
// eslint-disable-next-line no-var
@ -57,17 +40,14 @@ function useRouterHash() {
return useLocation().hash.substring(1);
}
const EditorWrapper = memo(function EditorWrapper({
const PageDetailEditorMain = memo(function PageDetailEditorMain({
workspace,
page,
pageId,
onLoad,
isPublic,
publishMode,
}: PageDetailEditorProps) {
const page = useBlockSuiteWorkspacePage(workspace, pageId);
if (!page) {
throw new PageNotFoundError(workspace, pageId);
}
}: PageDetailEditorProps & { page: Page }) {
const meta = useBlockSuitePageMeta(workspace).find(
meta => meta.id === pageId
);
@ -133,6 +113,9 @@ const EditorWrapper = memo(function EditorWrapper({
if (onLoad) {
disposableGroup.add(onLoad(page, editor));
}
// todo: remove the following
// for now this is required for the image-preview plugin to work
const rootStore = getCurrentStore();
const editorItems = rootStore.get(pluginEditorAtom);
let disposes: (() => void)[] = [];
@ -162,132 +145,23 @@ const EditorWrapper = memo(function EditorWrapper({
);
return (
<>
<Editor
className={clsx(styles.editor, {
'full-screen': appSettings.fullWidthLayout,
'is-public-page': isPublic,
})}
style={
{
'--affine-font-family': value,
} as CSSProperties
}
mode={mode}
page={page}
onModeChange={setEditorMode}
defaultSelectedBlockId={blockId}
onLoadEditor={onLoadEditor}
/>
{meta.trash && <TrashButtonGroup />}
</>
);
});
interface PluginContentAdapterProps {
windowItem: (div: HTMLDivElement) => () => void;
pluginName: string;
}
const PluginContentAdapter = memo<PluginContentAdapterProps>(
function PluginContentAdapter({ windowItem, pluginName }) {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const abortController = new AbortController();
const root = rootRef.current;
if (root) {
startTransition(() => {
if (abortController.signal.aborted) {
return;
}
const div = document.createElement('div');
const cleanup = windowItem(div);
root.append(div);
if (abortController.signal.aborted) {
cleanup();
div.remove();
} else {
const cl = () => {
cleanup();
div.remove();
};
const dispose = addCleanup(pluginName, cl);
abortController.signal.addEventListener('abort', () => {
window.setTimeout(() => {
dispose();
cl();
});
});
}
});
return () => {
abortController.abort();
};
<Editor
className={clsx(styles.editor, {
'full-screen': appSettings.fullWidthLayout,
'is-public-page': isPublic,
})}
style={
{
'--affine-font-family': value,
} as CSSProperties
}
return;
}, [pluginName, windowItem]);
return <div className={pluginContainer} ref={rootRef} />;
}
);
interface LayoutPanelProps {
node: LayoutNode;
editorProps: PageDetailEditorProps;
depth: number;
}
const LayoutPanel = memo(function LayoutPanel(
props: LayoutPanelProps
): ReactElement {
const { node, depth, editorProps } = props;
const windowItems = useAtomValue(pluginWindowAtom);
if (typeof node === 'string') {
if (node === 'editor') {
return <EditorWrapper {...editorProps} />;
} else {
const windowItem = windowItems[node];
return <PluginContentAdapter pluginName={node} windowItem={windowItem} />;
}
} else {
return (
<PanelGroup
direction={node.direction}
style={depth === 0 ? { height: 'calc(100% - 52px)' } : undefined}
className={depth === 0 ? editorContainer : undefined}
>
<Panel
defaultSizePercentage={node.splitPercentage}
style={{
maxWidth: node.maxWidth?.[0],
}}
>
<Suspense>
<LayoutPanel
node={node.first}
editorProps={editorProps}
depth={depth + 1}
/>
</Suspense>
</Panel>
<PanelResizeHandle />
<Panel
defaultSizePercentage={100 - node.splitPercentage}
style={{
overflow: 'scroll',
maxWidth: node.maxWidth?.[1],
}}
>
<Suspense>
<LayoutPanel
node={node.second}
editorProps={editorProps}
depth={depth + 1}
/>
</Suspense>
</Panel>
</PanelGroup>
);
}
mode={mode}
page={page}
onModeChange={setEditorMode}
defaultSelectedBlockId={blockId}
onLoadEditor={onLoadEditor}
/>
);
});
export const PageDetailEditor = (props: PageDetailEditorProps) => {
@ -297,27 +171,9 @@ export const PageDetailEditor = (props: PageDetailEditorProps) => {
throw new PageNotFoundError(workspace, pageId);
}
const layout = useAtomValue(contentLayoutAtom);
if (layout === 'editor') {
return (
<Suspense>
<PanelGroup
style={{ height: 'calc(100% - 52px)' }}
direction="horizontal"
className={editorContainer}
>
<Panel>
<EditorWrapper {...props} />
</Panel>
</PanelGroup>
</Suspense>
);
}
return (
<Suspense>
<LayoutPanel node={layout} editorProps={props} depth={0} />
<PageDetailEditorMain {...props} page={page} />
</Suspense>
);
};

View File

@ -5,56 +5,51 @@ import {
} from '@affine/component/app-sidebar';
import { useIsTinyScreen } from '@toeverything/hooks/use-is-tiny-screen';
import clsx from 'clsx';
import { type Atom, useAtomValue } from 'jotai';
import { useAtomValue } from 'jotai';
import type { ReactNode } from 'react';
import { forwardRef, useRef } from 'react';
import { useCallback, useRef, useState } from 'react';
import * as style from './style.css';
import { WindowsAppControls } from './windows-app-controls';
interface HeaderPros {
left?: ReactNode;
right?: ReactNode;
center?: ReactNode;
mainContainerAtom: Atom<HTMLDivElement | null>;
bottomBorder?: boolean;
}
// The Header component is used to solve the following problems
// 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
export const Header = forwardRef<HTMLDivElement, HeaderPros>(function Header(
{ left, center, right, mainContainerAtom, bottomBorder },
ref
) {
export const Header = ({ left, center, right, bottomBorder }: 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 windowControlsRef = useRef<HTMLDivElement | null>(null);
const mainContainer = useAtomValue(mainContainerAtom);
const [headerRoot, setHeaderRoot] = useState<HTMLDivElement | null>(null);
const onSetHeaderRoot = useCallback((node: HTMLDivElement | null) => {
setHeaderRoot(node);
}, []);
const isTinyScreen = useIsTinyScreen({
mainContainer,
container: headerRoot,
leftStatic: sidebarSwitchRef,
leftSlot: [leftSlotRef],
centerDom: centerSlotRef,
rightSlot: [rightSlotRef],
rightStatic: windowControlsRef,
});
const isWindowsDesktop = environment.isDesktop && environment.isWindows;
const open = useAtomValue(appSidebarOpenAtom);
const appSidebarFloating = useAtomValue(appSidebarFloatingAtom);
return (
<div
className={clsx(style.header, bottomBorder && style.bottomBorder)}
// data-has-warning={showWarning}
data-open={open}
data-sidebar-floating={appSidebarFloating}
data-testid="header"
ref={ref}
ref={onSetHeaderRoot}
>
<div
className={clsx(style.headerSideContainer, {
@ -79,7 +74,6 @@ export const Header = forwardRef<HTMLDivElement, HeaderPros>(function Header(
<div
className={clsx({
[style.headerCenter]: center,
'is-window': isWindowsDesktop,
})}
ref={centerSlotRef}
>
@ -90,17 +84,16 @@ export const Header = forwardRef<HTMLDivElement, HeaderPros>(function Header(
block: isTinyScreen,
})}
>
<div className={clsx(style.headerItem, 'top-item')}>
<div ref={windowControlsRef}>
{isWindowsDesktop ? <WindowsAppControls /> : null}
</div>
</div>
<div className={clsx(style.headerItem, 'right')}>
<div ref={rightSlotRef}>{right}</div>
<div ref={rightSlotRef} className={clsx(style.headerItem, 'right')}>
{right}
</div>
</div>
</div>
);
});
};
Header.displayName = 'Header';
export const HeaderDivider = () => {
return <div className={style.headerDivider} />;
};

View File

@ -62,9 +62,6 @@ export const headerCenter = style({
left: '50%',
zIndex: 1,
selectors: {
'&.is-window': {
maxWidth: '50%',
},
'&.shadow': {
position: 'static',
visibility: 'hidden',
@ -89,9 +86,6 @@ export const headerSideContainer = style({
export const windowAppControlsWrapper = style({
display: 'flex',
marginLeft: '20px',
// header padding right
transform: 'translateX(16px)',
});
export const windowAppControl = style({
@ -118,3 +112,10 @@ export const windowAppControl = style({
},
},
} as ComplexStyleRule);
export const headerDivider = style({
height: '20px',
width: '1px',
background: 'var(--affine-border-color)',
margin: '0 12px',
});

View File

@ -4,6 +4,7 @@ import { CloseIcon, NewIcon, UserGuideIcon } from '@blocksuite/icons';
import { useSetAtom } from 'jotai/react';
import { useAtomValue } from 'jotai/react';
import { useCallback, useState } from 'react';
import { useParams } from 'react-router-dom';
import { openOnboardingModalAtom, openSettingModalAtom } from '../../../atoms';
import { currentModeAtom } from '../../../atoms/mode';
@ -22,19 +23,16 @@ const DEFAULT_SHOW_LIST: IslandItemNames[] = [
'shortcuts',
];
const DESKTOP_SHOW_LIST: IslandItemNames[] = [...DEFAULT_SHOW_LIST, 'guide'];
export type IslandItemNames = 'whatNew' | 'contact' | 'shortcuts' | 'guide';
type IslandItemNames = 'whatNew' | 'contact' | 'shortcuts' | 'guide';
export const HelpIsland = ({
showList = environment.isDesktop ? DESKTOP_SHOW_LIST : DEFAULT_SHOW_LIST,
}: {
showList?: IslandItemNames[];
}) => {
const showList = environment.isDesktop ? DESKTOP_SHOW_LIST : DEFAULT_SHOW_LIST;
export const HelpIsland = () => {
const mode = useAtomValue(currentModeAtom);
const setOpenOnboarding = useSetAtom(openOnboardingModalAtom);
const setOpenSettingModalAtom = useSetAtom(openSettingModalAtom);
const [spread, setShowSpread] = useState(false);
const t = useAFFiNEI18N();
const openSettingModal = useCallback(
(tab: SettingProps['activeTab']) => {
setShowSpread(false);
@ -56,6 +54,8 @@ export const HelpIsland = ({
[openSettingModal]
);
const { pageId } = useParams();
return (
<StyledIsland
spread={spread}
@ -63,7 +63,7 @@ export const HelpIsland = ({
onClick={() => {
setShowSpread(!spread);
}}
inEdgelessPage={mode === 'edgeless'}
inEdgelessPage={!!pageId && mode === 'edgeless'}
>
<StyledAnimateWrapper
style={{ height: spread ? `${showList.length * 40 + 4}px` : 0 }}

View File

@ -5,7 +5,6 @@ export const StyledIsland = styled('div')<{
inEdgelessPage?: boolean;
}>(({ spread, inEdgelessPage }) => {
return {
transition: 'box-shadow 0.2s',
width: '44px',
position: 'relative',
boxShadow: spread

View File

@ -1,154 +0,0 @@
import { Button } from '@affine/component/ui/button';
import { ConfirmModal } from '@affine/component/ui/modal';
import { Tooltip } from '@affine/component/ui/tooltip';
import { WorkspaceSubPath } from '@affine/env/workspace';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { assertExists } from '@blocksuite/global/utils';
import { DeleteIcon, ResetIcon } from '@blocksuite/icons';
import { useBlockSuitePageMeta } from '@toeverything/hooks/use-block-suite-page-meta';
import { currentPageIdAtom } from '@toeverything/infra/atom';
import { useAtomValue } from 'jotai';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useAppSettingHelper } from '../../../hooks/affine/use-app-setting-helper';
import { useBlockSuiteMetaHelper } from '../../../hooks/affine/use-block-suite-meta-helper';
import { useCurrentWorkspace } from '../../../hooks/current/use-current-workspace';
import { useNavigateHelper } from '../../../hooks/use-navigate-helper';
import { toast } from '../../../utils';
import * as styles from './styles.css';
export const TrashButtonGroup = () => {
// fixme(himself65): remove these hooks ASAP
const [workspace] = useCurrentWorkspace();
const pageId = useAtomValue(currentPageIdAtom);
assertExists(workspace);
assertExists(pageId);
const blockSuiteWorkspace = workspace.blockSuiteWorkspace;
const pageMeta = useBlockSuitePageMeta(blockSuiteWorkspace).find(
meta => meta.id === pageId
);
assertExists(pageMeta);
const t = useAFFiNEI18N();
const { appSettings } = useAppSettingHelper();
const { jumpToSubPath } = useNavigateHelper();
const { restoreFromTrash } = useBlockSuiteMetaHelper(blockSuiteWorkspace);
const restoreRef = useRef(null);
const deleteRef = useRef(null);
const hintTextRef = useRef(null);
const wrapperRef = useRef<HTMLDivElement | null>(null);
const [open, setOpen] = useState(false);
const [width, setWidth] = useState(0);
const hintText =
'This page has been moved to the trash, you can either restore or permanently delete it.';
useEffect(() => {
const currentRef = wrapperRef.current;
if (!currentRef) {
return;
}
const handleResize = () => {
if (!currentRef) {
return;
}
const wrapperWidth = currentRef?.offsetWidth || 0;
setWidth(wrapperWidth);
};
const resizeObserver = new ResizeObserver(handleResize);
resizeObserver.observe(currentRef);
return () => {
resizeObserver.unobserve(currentRef);
};
}, []);
return (
<div ref={wrapperRef} style={{ width: '100%' }}>
<div
className={styles.deleteHintContainer}
style={{
width: `${width}px`,
}}
data-has-background={!appSettings.clientBorder}
>
<Tooltip
content={hintText}
portalOptions={{
container: hintTextRef.current,
}}
options={{ style: { whiteSpace: 'break-spaces' } }}
>
<div ref={hintTextRef} className={styles.deleteHintText}>
{hintText}
</div>
</Tooltip>
<div className={styles.group}>
<Tooltip
content={t['com.affine.trashOperation.restoreIt']()}
portalOptions={{
container: restoreRef.current,
}}
>
<Button
ref={restoreRef}
data-testid="page-restore-button"
type="primary"
onClick={() => {
restoreFromTrash(pageId);
toast(
t['com.affine.toastMessage.restored']({
title: pageMeta.title || 'Untitled',
})
);
}}
className={styles.buttonContainer}
>
<div className={styles.icon}>
<ResetIcon />
</div>
</Button>
</Tooltip>
<Tooltip
content={t['com.affine.trashOperation.deletePermanently']()}
portalOptions={{ container: deleteRef.current }}
>
<Button
ref={deleteRef}
type="error"
onClick={() => {
setOpen(true);
}}
style={{ color: 'var(--affine-pure-white)' }}
className={styles.buttonContainer}
>
<div className={styles.icon}>
<DeleteIcon />
</div>
</Button>
</Tooltip>
</div>
<ConfirmModal
title={t['com.affine.trashOperation.delete.title']()}
cancelText={t['com.affine.confirmModal.button.cancel']()}
description={t['com.affine.trashOperation.delete.description']()}
confirmButtonOptions={{
type: 'error',
children: t['com.affine.trashOperation.delete'](),
}}
open={open}
onConfirm={useCallback(() => {
jumpToSubPath(workspace.id, WorkspaceSubPath.ALL);
blockSuiteWorkspace.removePage(pageId);
toast(t['com.affine.toastMessage.permanentlyDeleted']());
}, [blockSuiteWorkspace, jumpToSubPath, pageId, workspace.id, t])}
onOpenChange={setOpen}
/>
</div>
</div>
);
};
export default TrashButtonGroup;

View File

@ -0,0 +1,99 @@
import { Button } from '@affine/component/ui/button';
import { ConfirmModal } from '@affine/component/ui/modal';
import { Tooltip } from '@affine/component/ui/tooltip';
import { WorkspaceSubPath } from '@affine/env/workspace';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { assertExists } from '@blocksuite/global/utils';
import { DeleteIcon, ResetIcon } from '@blocksuite/icons';
import { useBlockSuitePageMeta } from '@toeverything/hooks/use-block-suite-page-meta';
import { useCallback, useState } from 'react';
import { useAppSettingHelper } from '../../../hooks/affine/use-app-setting-helper';
import { useBlockSuiteMetaHelper } from '../../../hooks/affine/use-block-suite-meta-helper';
import { useCurrentWorkspace } from '../../../hooks/current/use-current-workspace';
import { useNavigateHelper } from '../../../hooks/use-navigate-helper';
import { toast } from '../../../utils';
import * as styles from './styles.css';
export const TrashPageFooter = ({ pageId }: { pageId: string }) => {
// fixme(himself65): remove these hooks ASAP
const [workspace] = useCurrentWorkspace();
assertExists(workspace);
const blockSuiteWorkspace = workspace.blockSuiteWorkspace;
const pageMeta = useBlockSuitePageMeta(blockSuiteWorkspace).find(
meta => meta.id === pageId
);
assertExists(pageMeta);
const t = useAFFiNEI18N();
const { appSettings } = useAppSettingHelper();
const { jumpToSubPath } = useNavigateHelper();
const { restoreFromTrash } = useBlockSuiteMetaHelper(blockSuiteWorkspace);
const [open, setOpen] = useState(false);
const hintText = t['com.affine.cmdk.affine.editor.trash-footer-hint']();
const onRestore = useCallback(() => {
restoreFromTrash(pageId);
toast(
t['com.affine.toastMessage.restored']({
title: pageMeta.title || 'Untitled',
})
);
}, [pageId, pageMeta.title, restoreFromTrash, t]);
const onConfirmDelete = useCallback(() => {
jumpToSubPath(workspace.id, WorkspaceSubPath.ALL);
blockSuiteWorkspace.removePage(pageId);
toast(t['com.affine.toastMessage.permanentlyDeleted']());
}, [blockSuiteWorkspace, jumpToSubPath, pageId, workspace.id, t]);
const onDelete = useCallback(() => {
setOpen(true);
}, []);
return (
<div
className={styles.deleteHintContainer}
data-has-background={!appSettings.clientBorder}
>
<div className={styles.deleteHintText}>{hintText}</div>
<div className={styles.group}>
<Tooltip content={t['com.affine.trashOperation.restoreIt']()}>
<Button
data-testid="page-restore-button"
type="primary"
onClick={onRestore}
className={styles.buttonContainer}
>
<div className={styles.icon}>
<ResetIcon />
</div>
</Button>
</Tooltip>
<Tooltip content={t['com.affine.trashOperation.deletePermanently']()}>
<Button
type="error"
onClick={onDelete}
style={{ color: 'var(--affine-pure-white)' }}
className={styles.buttonContainer}
>
<div className={styles.icon}>
<DeleteIcon />
</div>
</Button>
</Tooltip>
</div>
<ConfirmModal
title={t['com.affine.trashOperation.delete.title']()}
cancelText={t['com.affine.confirmModal.button.cancel']()}
description={t['com.affine.trashOperation.delete.description']()}
confirmButtonOptions={{
type: 'error',
children: t['com.affine.trashOperation.delete'](),
}}
open={open}
onConfirm={onConfirmDelete}
onOpenChange={setOpen}
/>
</div>
);
};

View File

@ -6,12 +6,13 @@ export const group = style({
justifyContent: 'center',
});
export const deleteHintContainer = style({
position: 'fixed',
position: 'relative',
zIndex: 2,
padding: '14px 20px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
flexShrink: 0,
bottom: '0',
gap: '16px',
backgroundColor: 'var(--affine-background-primary-color)',
@ -32,7 +33,6 @@ export const deleteHintText = style({
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
overflow: 'hidden',
cursor: 'pointer',
});
export const buttonContainer = style({
color: 'var(--affine-pure-white)',

View File

@ -1,189 +0,0 @@
import {
CollectionList,
FilterList,
SaveAsCollectionButton,
useCollectionManager,
} from '@affine/component/page-list';
import { Unreachable } from '@affine/env/constant';
import type { Collection, Filter } from '@affine/env/filter';
import type {
WorkspaceFlavour,
WorkspaceHeaderProps,
} from '@affine/env/workspace';
import { WorkspaceSubPath } from '@affine/env/workspace';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { DeleteIcon } from '@blocksuite/icons';
import { useAsyncCallback } from '@toeverything/hooks/affine-async-hooks';
import { useSetAtom } from 'jotai/react';
import { useCallback } from 'react';
import { collectionsCRUDAtom } from '../atoms/collections';
import { appHeaderAtom, mainContainerAtom } from '../atoms/element';
import { useAllPageListConfig } from '../hooks/affine/use-all-page-list-config';
import { useDeleteCollectionInfo } from '../hooks/affine/use-delete-collection-info';
import { useNavigateHelper } from '../hooks/use-navigate-helper';
import { useWorkspace } from '../hooks/use-workspace';
import { SharePageModal } from './affine/share-page-modal';
import { BlockSuiteHeaderTitle } from './blocksuite/block-suite-header-title';
import { filterContainerStyle } from './filter-container.css';
import { Header } from './pure/header';
import { PluginHeader } from './pure/plugin-header';
import { WorkspaceModeFilterTab } from './pure/workspace-mode-filter-tab';
import { TopTip } from './top-tip';
import * as styles from './workspace-header.css';
const FilterContainer = ({ workspaceId }: { workspaceId: string }) => {
const currentWorkspace = useWorkspace(workspaceId);
const navigateHelper = useNavigateHelper();
const setting = useCollectionManager(collectionsCRUDAtom);
const saveToCollection = useCallback(
async (collection: Collection) => {
await setting.createCollection({
...collection,
filterList: setting.currentCollection.filterList,
});
navigateHelper.jumpToCollection(workspaceId, collection.id);
},
[setting, navigateHelper, workspaceId]
);
const onFilterChange = useAsyncCallback(
async (filterList: Filter[]) => {
await setting.updateCollection({
...setting.currentCollection,
filterList,
});
},
[setting]
);
if (!setting.isDefault || !setting.currentCollection.filterList.length) {
return null;
}
return (
<div className={filterContainerStyle}>
<div style={{ flex: 1 }}>
<FilterList
propertiesMeta={currentWorkspace.blockSuiteWorkspace.meta.properties}
value={setting.currentCollection.filterList}
onChange={onFilterChange}
/>
</div>
<div>
{setting.currentCollection.filterList.length > 0 ? (
<SaveAsCollectionButton
onConfirm={saveToCollection}
></SaveAsCollectionButton>
) : null}
</div>
</div>
);
};
export function WorkspaceHeader({
currentWorkspaceId,
currentEntry,
rightSlot,
}: WorkspaceHeaderProps<WorkspaceFlavour>) {
const setAppHeader = useSetAtom(appHeaderAtom);
const currentWorkspace = useWorkspace(currentWorkspaceId);
const workspace = currentWorkspace.blockSuiteWorkspace;
const setting = useCollectionManager(collectionsCRUDAtom);
const config = useAllPageListConfig();
const userInfo = useDeleteCollectionInfo();
const t = useAFFiNEI18N();
// route in all page
if (
'subPath' in currentEntry &&
currentEntry.subPath === WorkspaceSubPath.ALL
) {
return (
<>
<Header
mainContainerAtom={mainContainerAtom}
ref={setAppHeader}
left={
<CollectionList
userInfo={userInfo}
allPageListConfig={config}
setting={setting}
propertiesMeta={workspace.meta.properties}
/>
}
right={rightSlot}
center={<WorkspaceModeFilterTab />}
/>
<FilterContainer workspaceId={currentWorkspaceId} />
</>
);
}
// route in shared
if (
'subPath' in currentEntry &&
currentEntry.subPath === WorkspaceSubPath.SHARED
) {
return (
<Header
mainContainerAtom={mainContainerAtom}
ref={setAppHeader}
center={<WorkspaceModeFilterTab />}
/>
);
}
// route in trash
if (
'subPath' in currentEntry &&
currentEntry.subPath === WorkspaceSubPath.TRASH
) {
return (
<Header
mainContainerAtom={mainContainerAtom}
ref={setAppHeader}
left={
<div className={styles.trashTitle}>
<DeleteIcon className={styles.trashIcon} />
{t['com.affine.workspaceSubPath.trash']()}
</div>
}
/>
);
}
// route in edit page
if ('pageId' in currentEntry) {
const currentPage = workspace.getPage(currentEntry.pageId);
const sharePageModal = currentPage ? (
<SharePageModal workspace={currentWorkspace} page={currentPage} />
) : null;
return (
<>
<Header
mainContainerAtom={mainContainerAtom}
ref={setAppHeader}
center={
<BlockSuiteHeaderTitle
workspace={currentWorkspace}
pageId={currentEntry.pageId}
/>
}
right={
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
{sharePageModal}
<PluginHeader />
</div>
}
bottomBorder
/>
<TopTip workspace={currentWorkspace} />
</>
);
}
throw new Unreachable();
}

View File

@ -106,5 +106,6 @@ export function useCurrentUser(): CheckedUser {
hasPassword: user?.hasPassword ?? false,
update,
};
}, [user, update]);
// spread the user object to make sure the hook will not be re-rendered when user ref changed but the properties not.
}, [user.id, user.name, user.email, user.image, user.hasPassword, update]);
}

View File

@ -0,0 +1,12 @@
import {
currentPageIdAtom,
currentWorkspaceAtom,
} from '@toeverything/infra/atom';
import { useAtomValue } from 'jotai';
export const useCurrentPage = () => {
const currentPageId = useAtomValue(currentPageIdAtom);
const currentWorkspace = useAtomValue(currentWorkspaceAtom);
return currentPageId ? currentWorkspace.getPage(currentPageId) : null;
};

View File

@ -6,11 +6,7 @@ import {
type DraggableTitleCellData,
PageListDragOverlay,
} from '@affine/component/page-list';
import {
MainContainer,
ToolContainer,
WorkspaceFallback,
} from '@affine/component/workspace';
import { MainContainer, WorkspaceFallback } from '@affine/component/workspace';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { rootWorkspacesMetadataAtom } from '@affine/workspace/atom';
import { getBlobEngine } from '@affine/workspace/manager';
@ -35,12 +31,10 @@ import { useLocation, useParams } from 'react-router-dom';
import { Map as YMap } from 'yjs';
import { openQuickSearchModalAtom, openSettingModalAtom } from '../atoms';
import { mainContainerAtom } from '../atoms/element';
import { AdapterProviderWrapper } from '../components/adapter-worksapce-wrapper';
import { AppContainer } from '../components/affine/app-container';
import { SyncAwareness } from '../components/affine/awareness';
import { usePageHelper } from '../components/blocksuite/block-suite-page-list/utils';
import type { IslandItemNames } from '../components/pure/help-island';
import { HelpIsland } from '../components/pure/help-island';
import { processCollectionsDrag } from '../components/pure/workspace-slider-bar/collections';
import {
DROPPABLE_SIDEBAR_TRASH,
@ -90,10 +84,6 @@ export const QuickSearch = () => {
);
};
const showList: IslandItemNames[] = environment.isDesktop
? ['whatNew', 'contact', 'guide']
: ['whatNew', 'contact'];
export const CurrentWorkspaceContext = ({
children,
}: PropsWithChildren): ReactNode => {
@ -258,12 +248,6 @@ export const WorkspaceLayoutInner = ({
const { appSettings } = useAppSettingHelper();
const location = useLocation();
const { pageId } = useParams();
const pageMeta = useBlockSuitePageMeta(
currentWorkspace.blockSuiteWorkspace
).find(meta => meta.id === pageId);
const inTrashPage = pageMeta?.trash ?? false;
const setMainContainer = useSetAtom(mainContainerAtom);
return (
<>
@ -292,26 +276,20 @@ export const WorkspaceLayoutInner = ({
paths={pathGenerator}
/>
</Suspense>
<Suspense fallback={<MainContainer ref={setMainContainer} />}>
<MainContainer
ref={setMainContainer}
padding={appSettings.clientBorder}
inTrashPage={inTrashPage}
>
<Suspense fallback={<MainContainer />}>
<MainContainer padding={appSettings.clientBorder}>
{migration ? (
<WorkspaceUpgrade migration={migration} />
) : (
children
)}
<ToolContainer inTrashPage={inTrashPage}>
<HelpIsland showList={pageId ? undefined : showList} />
</ToolContainer>
</MainContainer>
</Suspense>
</AppContainer>
<PageListTitleCellDragOverlay />
</DndContext>
<QuickSearch />
<SyncAwareness />
</>
);
};

View File

@ -18,11 +18,11 @@ import {
} from 'react-router-dom';
import { applyUpdate } from 'yjs';
import { PageDetailEditor } from '../../adapters/shared';
import type { PageMode } from '../../atoms';
import { AppContainer } from '../../components/affine/app-container';
import { ShareHeader } from '../../components/share-header';
import { PageDetailEditor } from '../../components/page-detail-editor';
import { SharePageNotFoundError } from '../../components/share-page-not-found-error';
import { ShareHeader } from './share-header';
type LoaderData = {
page: Page;

View File

@ -1,13 +1,11 @@
import type { Workspace } from '@blocksuite/store';
import { useSetAtom } from 'jotai/react';
import type { PageMode } from '../atoms';
import { appHeaderAtom, mainContainerAtom } from '../atoms/element';
import { useWorkspace } from '../hooks/use-workspace';
import { BlockSuiteHeaderTitle } from './blocksuite/block-suite-header-title';
import ShareHeaderLeftItem from './cloud/share-header-left-item';
import ShareHeaderRightItem from './cloud/share-header-right-item';
import { Header } from './pure/header';
import type { PageMode } from '../../atoms';
import { BlockSuiteHeaderTitle } from '../../components/blocksuite/block-suite-header-title';
import ShareHeaderLeftItem from '../../components/cloud/share-header-left-item';
import ShareHeaderRightItem from '../../components/cloud/share-header-right-item';
import { Header } from '../../components/pure/header';
import { useWorkspace } from '../../hooks/use-workspace';
export function ShareHeader({
workspace,
@ -18,14 +16,10 @@ export function ShareHeader({
pageId: string;
publishMode: PageMode;
}) {
const setAppHeader = useSetAtom(appHeaderAtom);
const currentWorkspace = useWorkspace(workspace.id);
return (
<Header
mainContainerAtom={mainContainerAtom}
ref={setAppHeader}
left={<ShareHeaderLeftItem />}
center={
<BlockSuiteHeaderTitle

View File

@ -0,0 +1,60 @@
import {
FilterList,
SaveAsCollectionButton,
useCollectionManager,
} from '@affine/component/page-list';
import type { Collection, Filter } from '@affine/env/filter';
import { useAsyncCallback } from '@toeverything/hooks/affine-async-hooks';
import { useCallback } from 'react';
import { collectionsCRUDAtom } from '../../../atoms/collections';
import { filterContainerStyle } from '../../../components/filter-container.css';
import { useNavigateHelper } from '../../../hooks/use-navigate-helper';
import { useWorkspace } from '../../../hooks/use-workspace';
export const FilterContainer = ({ workspaceId }: { workspaceId: string }) => {
const currentWorkspace = useWorkspace(workspaceId);
const navigateHelper = useNavigateHelper();
const setting = useCollectionManager(collectionsCRUDAtom);
const saveToCollection = useCallback(
async (collection: Collection) => {
await setting.createCollection({
...collection,
filterList: setting.currentCollection.filterList,
});
navigateHelper.jumpToCollection(workspaceId, collection.id);
},
[setting, navigateHelper, workspaceId]
);
const onFilterChange = useAsyncCallback(
async (filterList: Filter[]) => {
await setting.updateCollection({
...setting.currentCollection,
filterList,
});
},
[setting]
);
if (!setting.isDefault || !setting.currentCollection.filterList.length) {
return null;
}
return (
<div className={filterContainerStyle}>
<div style={{ flex: 1 }}>
<FilterList
propertiesMeta={currentWorkspace.blockSuiteWorkspace.meta.properties}
value={setting.currentCollection.filterList}
onChange={onFilterChange}
/>
</div>
<div>
{setting.currentCollection.filterList.length > 0 ? (
<SaveAsCollectionButton onConfirm={saveToCollection} />
) : null}
</div>
</div>
);
};

View File

@ -67,3 +67,10 @@ export const headerCreateNewButtonHidden = style({
opacity: 0,
pointerEvents: 'none',
});
export const headerRightWindows = style({
display: 'flex',
alignItems: 'center',
gap: 8,
transform: 'translateX(16px)',
});

View File

@ -1,5 +1,6 @@
import { toast } from '@affine/component';
import {
CollectionList,
currentCollectionAtom,
FloatingToolbar,
NewPageButton as PureNewPageButton,
@ -8,7 +9,7 @@ import {
useCollectionManager,
VirtualizedPageList,
} from '@affine/component/page-list';
import { WorkspaceFlavour, WorkspaceSubPath } from '@affine/env/workspace';
import { WorkspaceFlavour } from '@affine/env/workspace';
import { Trans } from '@affine/i18n';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { assertExists } from '@blocksuite/global/utils';
@ -18,7 +19,7 @@ import {
PlusIcon,
ViewLayersIcon,
} from '@blocksuite/icons';
import type { PageMeta } from '@blocksuite/store';
import type { PageMeta, Workspace } from '@blocksuite/store';
import { useBlockSuitePageMeta } from '@toeverything/hooks/use-block-suite-page-meta';
import { getBlockSuiteWorkspaceAtom } from '@toeverything/infra/__internal__/workspace';
import { getCurrentStore } from '@toeverything/infra/atom';
@ -34,16 +35,22 @@ import type { LoaderFunction } from 'react-router-dom';
import { redirect } from 'react-router-dom';
import { NIL } from 'uuid';
import { collectionsCRUDAtom } from '../../atoms/collections';
import { usePageHelper } from '../../components/blocksuite/block-suite-page-list/utils';
import { WorkspaceHeader } from '../../components/workspace-header';
import { useBlockSuiteMetaHelper } from '../../hooks/affine/use-block-suite-meta-helper';
import { useTrashModalHelper } from '../../hooks/affine/use-trash-modal-helper';
import { useCurrentWorkspace } from '../../hooks/current/use-current-workspace';
import { performanceRenderLogger } from '../../shared';
import { collectionsCRUDAtom } from '../../../atoms/collections';
import { HubIsland } from '../../../components/affine/hub-island';
import { usePageHelper } from '../../../components/blocksuite/block-suite-page-list/utils';
import { Header } from '../../../components/pure/header';
import { WindowsAppControls } from '../../../components/pure/header/windows-app-controls';
import { WorkspaceModeFilterTab } from '../../../components/pure/workspace-mode-filter-tab';
import { useAllPageListConfig } from '../../../hooks/affine/use-all-page-list-config';
import { useBlockSuiteMetaHelper } from '../../../hooks/affine/use-block-suite-meta-helper';
import { useDeleteCollectionInfo } from '../../../hooks/affine/use-delete-collection-info';
import { useTrashModalHelper } from '../../../hooks/affine/use-trash-modal-helper';
import { useCurrentWorkspace } from '../../../hooks/current/use-current-workspace';
import { performanceRenderLogger } from '../../../shared';
import { EmptyPageList } from '../page-list-empty';
import { useFilteredPageMetas } from '../pages';
import * as styles from './all-page.css';
import { EmptyPageList } from './page-list-empty';
import { useFilteredPageMetas } from './pages';
import { FilterContainer } from './all-page-filter';
export const loader: LoaderFunction = async args => {
const rootStore = getCurrentStore();
@ -217,6 +224,50 @@ const NewPageButton = ({
);
};
const AllPageHeader = ({
workspace,
showCreateNew,
}: {
workspace: Workspace;
showCreateNew: boolean;
}) => {
const setting = useCollectionManager(collectionsCRUDAtom);
const config = useAllPageListConfig();
const userInfo = useDeleteCollectionInfo();
const isWindowsDesktop = environment.isDesktop && environment.isWindows;
return (
<>
<Header
left={
<CollectionList
userInfo={userInfo}
allPageListConfig={config}
setting={setting}
propertiesMeta={workspace.meta.properties}
/>
}
right={
<div className={styles.headerRightWindows}>
<NewPageButton
size="small"
className={clsx(
styles.headerCreateNewButton,
!showCreateNew && styles.headerCreateNewButtonHidden
)}
>
<PlusIcon />
</NewPageButton>
{isWindowsDesktop ? <WindowsAppControls /> : null}
</div>
}
center={<WorkspaceModeFilterTab />}
/>
<FilterContainer workspaceId={workspace.id} />
</>
);
};
// even though it is called all page, it is also being used for collection route as well
export const AllPage = () => {
const [currentWorkspace] = useCurrentWorkspace();
@ -250,22 +301,9 @@ export const AllPage = () => {
return (
<div className={styles.root}>
{currentWorkspace.flavour !== WorkspaceFlavour.AFFINE_PUBLIC ? (
<WorkspaceHeader
currentWorkspaceId={currentWorkspace.id}
currentEntry={{
subPath: WorkspaceSubPath.ALL,
}}
rightSlot={
<NewPageButton
size="small"
className={clsx(
styles.headerCreateNewButton,
hideHeaderCreateNewPage && styles.headerCreateNewButtonHidden
)}
>
<PlusIcon />
</NewPageButton>
}
<AllPageHeader
workspace={currentWorkspace.blockSuiteWorkspace}
showCreateNew={!hideHeaderCreateNewPage}
/>
) : null}
{filteredPageMetas.length > 0 ? (
@ -299,6 +337,7 @@ export const AllPage = () => {
blockSuiteWorkspace={currentWorkspace.blockSuiteWorkspace}
/>
)}
<HubIsland />
</div>
);
};

View File

@ -30,7 +30,7 @@ import { useCurrentWorkspace } from '../../hooks/current/use-current-workspace';
import { useNavigateHelper } from '../../hooks/use-navigate-helper';
import { WorkspaceSubPath } from '../../shared';
import { getWorkspaceSetting } from '../../utils/workspace-setting';
import { AllPage } from './all-page';
import { AllPage } from './all-page/all-page';
import * as styles from './collection.css';
export const loader: LoaderFunction = async args => {

View File

@ -1,215 +0,0 @@
import { PageDetailSkeleton } from '@affine/component/page-detail-skeleton';
import {
createTagFilter,
useCollectionManager,
} from '@affine/component/page-list';
import { WorkspaceSubPath } from '@affine/env/workspace';
import { globalBlockSuiteSchema } from '@affine/workspace/manager';
import { SyncEngineStep } from '@affine/workspace/providers';
import { assertExists } from '@blocksuite/global/utils';
import type { EditorContainer } from '@blocksuite/presets';
import type { Page } from '@blocksuite/store';
import {
contentLayoutAtom,
currentPageIdAtom,
currentWorkspaceIdAtom,
} from '@toeverything/infra/atom';
import { useAtomValue, useSetAtom } from 'jotai';
import { type ReactElement, useCallback, useEffect, useState } from 'react';
import { type LoaderFunction, useParams } from 'react-router-dom';
import type { Map as YMap } from 'yjs';
import { getUIAdapter } from '../../adapters/workspace';
import { setPageModeAtom } from '../../atoms';
import { collectionsCRUDAtom } from '../../atoms/collections';
import { currentModeAtom } from '../../atoms/mode';
import { AffineErrorBoundary } from '../../components/affine/affine-error-boundary';
import { GlobalPageHistoryModal } from '../../components/affine/page-history-modal';
import { WorkspaceHeader } from '../../components/workspace-header';
import { useRegisterBlocksuiteEditorCommands } from '../../hooks/affine/use-register-blocksuite-editor-commands';
import {
useCurrentSyncEngine,
useCurrentSyncEngineStatus,
} from '../../hooks/current/use-current-sync-engine';
import { useCurrentWorkspace } from '../../hooks/current/use-current-workspace';
import { useNavigateHelper } from '../../hooks/use-navigate-helper';
import { performanceRenderLogger } from '../../shared';
const DetailPageImpl = (): ReactElement => {
const { openPage, jumpToSubPath } = useNavigateHelper();
const currentPageId = useAtomValue(currentPageIdAtom);
const [currentWorkspace] = useCurrentWorkspace();
assertExists(currentWorkspace);
assertExists(currentPageId);
const blockSuiteWorkspace = currentWorkspace.blockSuiteWorkspace;
const { setTemporaryFilter } = useCollectionManager(collectionsCRUDAtom);
const mode = useAtomValue(currentModeAtom);
const setPageMode = useSetAtom(setPageModeAtom);
useRegisterBlocksuiteEditorCommands(currentPageId, mode);
const onLoad = useCallback(
(page: Page, editor: EditorContainer) => {
try {
const surfaceBlock = page.getBlockByFlavour('affine:surface')[0];
// hotfix for old page
if (
surfaceBlock &&
(surfaceBlock.yBlock.get('prop:elements') as YMap<any>).get(
'type'
) !== '$blocksuite:internal:native$'
) {
globalBlockSuiteSchema.upgradePage(
0,
{
'affine:surface': 3,
},
page.spaceDoc
);
}
} catch {}
setPageMode(currentPageId, mode);
const dispose = editor.slots.pageLinkClicked.on(({ pageId }) => {
return openPage(blockSuiteWorkspace.id, pageId);
});
const disposeTagClick = editor.slots.tagClicked.on(async ({ tagId }) => {
jumpToSubPath(currentWorkspace.id, WorkspaceSubPath.ALL);
setTemporaryFilter([createTagFilter(tagId)]);
});
return () => {
dispose.dispose();
disposeTagClick.dispose();
};
},
[
blockSuiteWorkspace.id,
currentPageId,
currentWorkspace.id,
jumpToSubPath,
mode,
openPage,
setPageMode,
setTemporaryFilter,
]
);
const { PageDetail } = getUIAdapter(currentWorkspace.flavour);
return (
<>
<WorkspaceHeader
currentWorkspaceId={currentWorkspace.id}
currentEntry={{
pageId: currentPageId,
}}
/>
<PageDetail
currentWorkspaceId={currentWorkspace.id}
currentPageId={currentPageId}
onLoadEditor={onLoad}
/>
<GlobalPageHistoryModal />
</>
);
};
export const DetailPage = (): ReactElement => {
const [currentWorkspace] = useCurrentWorkspace();
const currentSyncEngineStatus = useCurrentSyncEngineStatus();
const currentSyncEngine = useCurrentSyncEngine();
const currentPageId = useAtomValue(currentPageIdAtom);
const [page, setPage] = useState<Page | null>(null);
// set sync engine priority target
useEffect(() => {
if (!currentPageId) {
return;
}
currentSyncEngine?.setPriorityRule(id => id.endsWith(currentPageId));
}, [currentPageId, currentSyncEngine, currentWorkspace]);
// load page by current page id
useEffect(() => {
if (!currentPageId) {
setPage(null);
return;
}
const exists = currentWorkspace.blockSuiteWorkspace.getPage(currentPageId);
if (exists) {
setPage(exists);
return;
}
const dispose = currentWorkspace.blockSuiteWorkspace.slots.pagesUpdated.on(
() => {
const exists =
currentWorkspace.blockSuiteWorkspace.getPage(currentPageId);
if (exists) {
setPage(exists);
}
}
);
return dispose.dispose;
}, [currentPageId, currentWorkspace]);
const navigate = useNavigateHelper();
// if sync engine has been synced and the page is null, wait 1s and jump to 404 page.
useEffect(() => {
if (currentSyncEngineStatus?.step === SyncEngineStep.Synced && !page) {
const timeout = setTimeout(() => {
navigate.jumpTo404();
}, 1000);
return () => {
clearTimeout(timeout);
};
}
return;
}, [currentSyncEngineStatus, navigate, page]);
if (!currentPageId || !page) {
return <PageDetailSkeleton key="current-page-is-null" />;
}
if (page.meta.jumpOnce) {
currentWorkspace.blockSuiteWorkspace.setPageMeta(page.id, {
jumpOnce: false,
});
}
return <DetailPageImpl />;
};
export const loader: LoaderFunction = async () => {
return null;
};
export const Component = () => {
performanceRenderLogger.info('DetailPage');
const setContentLayout = useSetAtom(contentLayoutAtom);
const setCurrentWorkspaceId = useSetAtom(currentWorkspaceIdAtom);
const setCurrentPageId = useSetAtom(currentPageIdAtom);
const params = useParams();
useEffect(() => {
setContentLayout('editor');
if (params.workspaceId) {
localStorage.setItem('last_workspace_id', params.workspaceId);
setCurrentWorkspaceId(params.workspaceId);
}
if (params.pageId) {
localStorage.setItem('last_page_id', params.pageId);
setCurrentPageId(params.pageId);
}
}, [params, setContentLayout, setCurrentPageId, setCurrentWorkspaceId]);
// Add a key to force rerender when page changed, to avoid error boundary persisting.
return (
<AffineErrorBoundary key={params.pageId}>
<DetailPage />
</AffineErrorBoundary>
);
};

View File

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

View File

@ -0,0 +1,159 @@
import { IconButton } from '@affine/component';
import {
appSidebarFloatingAtom,
appSidebarOpenAtom,
SidebarSwitch,
} from '@affine/component/app-sidebar';
import type { AllWorkspace } from '@affine/core/shared';
import { RightSidebarIcon } from '@blocksuite/icons';
import type { Page } from '@blocksuite/store';
import { useAtomValue, useSetAtom } from 'jotai';
import { SharePageButton } from '../../../components/affine/share-page-modal';
import { BlockSuiteHeaderTitle } from '../../../components/blocksuite/block-suite-header-title';
import { HeaderDivider } from '../../../components/pure/header';
import { WindowsAppControls } from '../../../components/pure/header/windows-app-controls';
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: editorSidebarOpen ? 0 : -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({
children,
style,
className,
}: {
children: React.ReactNode;
className?: string;
style?: React.CSSProperties;
}) {
const appSidebarFloating = useAtomValue(appSidebarFloatingAtom);
return (
<div
data-testid="header"
style={style}
className={className}
data-sidebar-floating={appSidebarFloating}
>
{children}
</div>
);
}
export function DetailPageHeader({
page,
workspace,
showSidebarSwitch = true,
}: {
page: Page;
workspace: AllWorkspace;
showSidebarSwitch?: boolean;
}) {
const leftSidebarOpen = useAtomValue(appSidebarOpenAtom);
const RightHeader = isWindowsDesktop
? WindowsMainPageHeaderRight
: NonWindowsMainPageHeaderRight;
return (
<Header className={styles.mainHeader}>
<SidebarSwitch show={!leftSidebarOpen} />
{!leftSidebarOpen ? <HeaderDivider /> : null}
<BlockSuiteHeaderTitle pageId={page.id} workspace={workspace} />
<div className={styles.spacer} />
{page ? <SharePageButton workspace={workspace} page={page} /> : null}
<RightHeader showSidebarSwitch={showSidebarSwitch} />
</Header>
);
}
function WindowsSidebarHeader() {
return (
<>
<Header className={styles.sidebarHeader} style={{ paddingRight: 0 }}>
<div className={styles.spacer} />
<ToggleSidebarButton />
<WindowsAppControls />
</Header>
<div className={styles.standaloneExtensionSwitcherWrapper}>
<ExtensionTabs />
</div>
</>
);
}
function NonWindowsSidebarHeader() {
return (
<Header className={styles.sidebarHeader}>
<ExtensionTabs />
<div className={styles.spacer} />
<ToggleSidebarButton />
</Header>
);
}
export function RightSidebarHeader() {
return isWindowsDesktop ? (
<WindowsSidebarHeader />
) : (
<NonWindowsSidebarHeader />
);
}

View File

@ -0,0 +1,95 @@
import { style } from '@vanilla-extract/css';
export const root = style({
display: 'flex',
height: '100%',
overflow: 'hidden',
width: '100%',
});
export const mainContainer = style({
display: 'flex',
flex: 1,
height: '100%',
position: 'relative',
flexDirection: 'column',
width: '100%',
selectors: {
[`${root}[data-client-border] &`]: {
borderRadius: '4px',
},
},
});
export const editorContainer = style({
position: 'relative',
display: 'flex',
flexDirection: 'column',
flex: 1,
overflow: 'hidden',
zIndex: 0, // it will create stacking context to limit layer of child elements and be lower than after auto zIndex
});
export const resizeHandle = style({
width: '1px',
position: 'relative',
backgroundColor: 'var(--affine-border-color)',
selectors: {
'&[data-collapsed=true]': {
display: 'none',
},
[`${root}[data-client-border] &`]: {
width: '8px',
backgroundColor: 'transparent',
},
},
});
export const resizeHandleInner = style({
height: '100%',
width: '10px', // this is the real hit box
position: 'absolute',
transform: 'translateX(-50%)',
zIndex: 10,
transition: 'all 0.2s ease-in-out',
display: 'flex',
justifyContent: 'center',
'::before': {
content: '""',
width: '0px',
height: '100%',
borderRadius: '2px',
transition: 'all 0.2s ease-in-out',
},
selectors: {
[`${root}[data-client-border] &`]: {
transform: 'translateX(-1px)',
},
[`:is(${resizeHandle}:hover, ${resizeHandle}[data-resize-handle-active]) &::before`]:
{
width: '2px',
backgroundColor: 'var(--affine-primary-color)',
},
[`${resizeHandle}[data-resize-handle-active] &::before`]: {
width: '4px',
borderRadius: '4px',
},
},
});
export const sidebarContainer = style({
transition: 'flex 0.2s ease-in-out',
display: 'flex',
flexDirection: 'column',
selectors: {
[`${resizeHandle}[data-resize-handle-active] + &`]: {
transition: 'none',
},
[`${root}[data-disable-animation] &`]: {
transition: 'none',
},
[`${root}[data-client-border] &`]: {
borderRadius: '4px',
},
},
});

View File

@ -0,0 +1,363 @@
import { PageDetailSkeleton } from '@affine/component/page-detail-skeleton';
import {
createTagFilter,
useCollectionManager,
} from '@affine/component/page-list';
import { WorkspaceSubPath } from '@affine/env/workspace';
import { globalBlockSuiteSchema } from '@affine/workspace/manager';
import { SyncEngineStep } from '@affine/workspace/providers';
import { assertExists } from '@blocksuite/global/utils';
import type { EditorContainer } from '@blocksuite/presets';
import type { Page, Workspace } from '@blocksuite/store';
import { useBlockSuitePageMeta } from '@toeverything/hooks/use-block-suite-page-meta';
import {
currentPageIdAtom,
currentWorkspaceIdAtom,
} from '@toeverything/infra/atom';
import { useAtomValue, useSetAtom } from 'jotai';
import {
type ReactElement,
type ReactNode,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import type { PanelOnResize } from 'react-resizable-panels';
import {
type ImperativePanelHandle,
Panel,
PanelGroup,
PanelResizeHandle,
} from 'react-resizable-panels';
import { type LoaderFunction, useParams } from 'react-router-dom';
import type { Map as YMap } from 'yjs';
import { setPageModeAtom } from '../../../atoms';
import { collectionsCRUDAtom } from '../../../atoms/collections';
import { currentModeAtom } from '../../../atoms/mode';
import { appSettingAtom } from '../../../atoms/settings';
import { AffineErrorBoundary } from '../../../components/affine/affine-error-boundary';
import { HubIsland } from '../../../components/affine/hub-island';
import { GlobalPageHistoryModal } from '../../../components/affine/page-history-modal';
import { PageDetailEditor } from '../../../components/page-detail-editor';
import { TrashPageFooter } from '../../../components/pure/trash-page-footer';
import { TopTip } from '../../../components/top-tip';
import { useRegisterBlocksuiteEditorCommands } from '../../../hooks/affine/use-register-blocksuite-editor-commands';
import {
useCurrentSyncEngine,
useCurrentSyncEngineStatus,
} from '../../../hooks/current/use-current-sync-engine';
import { useCurrentWorkspace } from '../../../hooks/current/use-current-workspace';
import { useNavigateHelper } from '../../../hooks/use-navigate-helper';
import { performanceRenderLogger } from '../../../shared';
import * as styles from './detail-page.css';
import { DetailPageHeader, RightSidebarHeader } from './detail-page-header';
import {
EditorSidebar,
editorSidebarOpenAtom,
editorSidebarStateAtom,
editorSidebarWidthAtom,
} from './editor-sidebar';
interface DetailPageLayoutProps {
main: ReactNode;
header: ReactNode;
footer: ReactNode;
sidebar: ReactNode;
}
// disable animation to avoid UI flash
function useEnableAnimation() {
const [enable, setEnable] = useState(false);
useEffect(() => {
window.setTimeout(() => {
setEnable(true);
}, 500);
}, []);
return enable;
}
// 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 sidebarState = useAtomValue(editorSidebarStateAtom);
const setSidebarWidth = useSetAtom(editorSidebarWidthAtom);
const setSidebarOpen = useSetAtom(editorSidebarOpenAtom);
const { clientBorder } = useAtomValue(appSettingAtom);
const sidebarRef = useRef<ImperativePanelHandle>(null);
const onExpandSidebar = useCallback(() => {
setSidebarOpen(true);
}, [setSidebarOpen]);
const onCollapseSidebar = useCallback(() => {
setSidebarOpen(false);
}, [setSidebarOpen]);
const onResize: PanelOnResize = useCallback(
e => {
if (e.sizePixels > 0) {
setSidebarWidth(e.sizePixels);
}
},
[setSidebarWidth]
);
useEffect(() => {
const panelHandle = sidebarRef.current;
if (!panelHandle) {
return;
}
if (sidebarState.isOpen) {
panelHandle.expand();
} else {
panelHandle.collapse();
}
}, [sidebarState.isOpen]);
const enableAnimation = useEnableAnimation();
return (
<PanelGroup
direction="horizontal"
className={styles.root}
dataAttributes={{
'data-disable-animation': !enableAnimation ? 'true' : undefined,
'data-client-border': clientBorder ? 'true' : undefined,
}}
>
<Panel id="editor" className={styles.mainContainer}>
{header}
{main}
{footer}
</Panel>
{sidebar ? (
<>
<PanelResizeHandle
dataAttributes={{
'data-collapsed': !sidebarState.isOpen,
}}
className={styles.resizeHandle}
>
<div className={styles.resizeHandleInner} />
</PanelResizeHandle>
<Panel
id="editor-sidebar"
className={styles.sidebarContainer}
onResize={onResize}
collapsedSizePixels={0}
collapsible
onCollapse={onCollapseSidebar}
onExpand={onExpandSidebar}
ref={sidebarRef}
defaultSizePixels={Math.max(sidebarState.width, 240)}
minSizePixels={240}
maxSizePercentage={50}
>
{sidebar}
</Panel>
</>
) : null}
</PanelGroup>
);
};
const DetailPageImpl = ({ page }: { page: Page }) => {
const currentPageId = page.id;
const { openPage, jumpToSubPath } = useNavigateHelper();
const [currentWorkspace] = useCurrentWorkspace();
assertExists(
currentWorkspace,
'current workspace is null when rendering detail'
);
const blockSuiteWorkspace = currentWorkspace.blockSuiteWorkspace;
const pageMeta = useBlockSuitePageMeta(blockSuiteWorkspace).find(
meta => meta.id === page.id
);
const isInTrash = pageMeta?.trash;
const { setTemporaryFilter } = useCollectionManager(collectionsCRUDAtom);
const mode = useAtomValue(currentModeAtom);
const setPageMode = useSetAtom(setPageModeAtom);
useRegisterBlocksuiteEditorCommands(currentPageId, mode);
const onLoad = useCallback(
(page: Page, editor: EditorContainer) => {
try {
// todo(joooye34): improve the following migration code
const surfaceBlock = page.getBlockByFlavour('affine:surface')[0];
// hotfix for old page
if (
surfaceBlock &&
(surfaceBlock.yBlock.get('prop:elements') as YMap<any>).get(
'type'
) !== '$blocksuite:internal:native$'
) {
globalBlockSuiteSchema.upgradePage(
0,
{
'affine:surface': 3,
},
page.spaceDoc
);
}
} catch {}
setPageMode(currentPageId, mode);
const dispose = editor.slots.pageLinkClicked.on(({ pageId }) => {
return openPage(blockSuiteWorkspace.id, pageId);
});
const disposeTagClick = editor.slots.tagClicked.on(async ({ tagId }) => {
jumpToSubPath(currentWorkspace.id, WorkspaceSubPath.ALL);
setTemporaryFilter([createTagFilter(tagId)]);
});
return () => {
dispose.dispose();
disposeTagClick.dispose();
};
},
[
blockSuiteWorkspace.id,
currentPageId,
currentWorkspace.id,
jumpToSubPath,
mode,
openPage,
setPageMode,
setTemporaryFilter,
]
);
return (
<>
<DetailPageLayout
header={
<>
<DetailPageHeader
page={page}
workspace={currentWorkspace}
showSidebarSwitch={!isInTrash}
/>
<TopTip workspace={currentWorkspace} />
</>
}
main={
<div className={styles.editorContainer}>
<PageDetailEditor
pageId={currentPageId}
onLoad={onLoad}
workspace={blockSuiteWorkspace}
/>
<HubIsland />
</div>
}
footer={isInTrash ? <TrashPageFooter pageId={page.id} /> : null}
sidebar={
!isInTrash ? (
<>
<RightSidebarHeader />
<EditorSidebar />
</>
) : null
}
/>
<GlobalPageHistoryModal />
</>
);
};
const useForceUpdate = () => {
const [, setCount] = useState(0);
return useCallback(() => setCount(count => count + 1), []);
};
const useSafePage = (workspace: Workspace, pageId: string) => {
const forceUpdate = useForceUpdate();
useEffect(() => {
const disposable = workspace.slots.pagesUpdated.on(() => {
forceUpdate();
});
return disposable.dispose;
}, [pageId, workspace.slots.pagesUpdated, forceUpdate]);
return workspace.getPage(pageId);
};
export const DetailPage = ({ pageId }: { pageId: string }): ReactElement => {
const [currentWorkspace] = useCurrentWorkspace();
const currentSyncEngineStatus = useCurrentSyncEngineStatus();
const currentSyncEngine = useCurrentSyncEngine();
// set sync engine priority target
useEffect(() => {
currentSyncEngine?.setPriorityRule(id => id.endsWith(pageId));
}, [pageId, currentSyncEngine, currentWorkspace]);
const page = useSafePage(currentWorkspace?.blockSuiteWorkspace, pageId);
const navigate = useNavigateHelper();
// if sync engine has been synced and the page is null, wait 1s and jump to 404 page.
useEffect(() => {
if (currentSyncEngineStatus?.step === SyncEngineStep.Synced && !page) {
const timeout = setTimeout(() => {
navigate.jumpTo404();
}, 1000);
return () => {
clearTimeout(timeout);
};
}
return;
}, [currentSyncEngineStatus, navigate, page]);
if (!page) {
return <PageDetailSkeleton key="current-page-is-null" />;
}
if (page.meta.jumpOnce) {
currentWorkspace.blockSuiteWorkspace.setPageMeta(page.id, {
jumpOnce: false,
});
}
return <DetailPageImpl page={page} />;
};
export const loader: LoaderFunction = async () => {
return null;
};
export const Component = () => {
performanceRenderLogger.info('DetailPage');
const setCurrentWorkspaceId = useSetAtom(currentWorkspaceIdAtom);
const setCurrentPageId = useSetAtom(currentPageIdAtom);
const params = useParams();
useEffect(() => {
if (params.workspaceId) {
localStorage.setItem('last_workspace_id', params.workspaceId);
setCurrentWorkspaceId(params.workspaceId);
}
if (params.pageId) {
localStorage.setItem('last_page_id', params.pageId);
setCurrentPageId(params.pageId);
}
}, [params, setCurrentPageId, setCurrentWorkspaceId]);
const pageId = params.pageId;
// Add a key to force rerender when page changed, to avoid error boundary persisting.
return (
<AffineErrorBoundary key={params.pageId}>
{pageId ? <DetailPage pageId={pageId} /> : null}
</AffineErrorBoundary>
);
};

View File

@ -0,0 +1,79 @@
// main editor sidebar states
import { assertExists } from '@blocksuite/global/utils';
import { atom } from 'jotai';
import { selectAtom } from 'jotai/utils';
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[] = [outlineExtension];
export interface EditorSidebarState {
isOpen: boolean;
width: number;
activeExtension?: EditorExtension;
extensions: EditorExtension[];
}
const baseStateAtom = atom<EditorSidebarState>({
isOpen: false,
width: 300, // todo: should be resizable
activeExtension: extensions[0],
extensions: extensions, // todo: maybe should be dynamic (by feature flag?)
});
export const editorSidebarStateAtom = atom(get => get(baseStateAtom));
const isOpenAtom = selectAtom(baseStateAtom, state => state.isOpen);
const activeExtensionAtom = selectAtom(
baseStateAtom,
state => state.activeExtension
);
const widthAtom = selectAtom(baseStateAtom, state => state.width);
export const editorExtensionsAtom = selectAtom(
baseStateAtom,
state => state.extensions
);
// get/set sidebar open state
export const editorSidebarOpenAtom = atom(
get => get(isOpenAtom),
(_, set, isOpen: boolean) => {
set(baseStateAtom, prev => {
return { ...prev, isOpen };
});
}
);
// 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

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

View File

@ -0,0 +1,16 @@
import { useAtomValue } from 'jotai';
import { editorSidebarStateAtom } from './atoms';
import * as styles from './editor-sidebar.css';
export const EditorSidebar = () => {
const sidebarState = useAtomValue(editorSidebarStateAtom);
const Component = sidebarState.activeExtension?.Component;
// do we need this?
if (!sidebarState.isOpen) {
return null;
}
return <div className={styles.root}>{Component ? <Component /> : null}</div>;
};

View File

@ -0,0 +1,45 @@
import { createVar, style } from '@vanilla-extract/css';
export const activeIdx = createVar();
export const switchRoot = style({
vars: {
[activeIdx]: '0',
},
display: 'flex',
alignItems: 'center',
gap: '8px',
height: '32px',
borderRadius: '12px',
padding: '4px',
position: 'relative',
background: 'var(--affine-background-secondary-color)',
'::after': {
content: '""',
display: 'block',
width: '24px',
height: '24px',
background: 'var(--affine-background-primary-color)',
boxShadow: 'var(--affine-shadow-1)',
borderRadius: '8px',
position: 'absolute',
transform: `translateX(calc(${activeIdx} * 32px))`,
transition: 'all .15s',
},
});
export const button = style({
width: '24px',
height: '24px',
borderRadius: '8px',
color: 'var(--affine-icon-color)',
position: 'relative',
zIndex: 1,
selectors: {
'&[data-active=true]': {
color: 'var(--affine-primary-color)',
},
},
});

View File

@ -0,0 +1,37 @@
import { IconButton } from '@affine/component';
import { assignInlineVars } from '@vanilla-extract/dynamic';
import { useAtom, useAtomValue } from 'jotai';
import {
editorExtensionsAtom,
editorSidebarActiveExtensionAtom,
} from '../atoms';
import * as styles from './extensions.css';
// provide a switcher for active extensions
// will be used in global top header (MacOS) or sidebar (Windows)
export const ExtensionTabs = () => {
const exts = useAtomValue(editorExtensionsAtom);
const [selected, setSelected] = useAtom(editorSidebarActiveExtensionAtom);
const vars = assignInlineVars({
[styles.activeIdx]: String(
exts.findIndex(ext => ext.name === selected?.name) ?? 0
),
});
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

@ -0,0 +1,7 @@
import { style } from '@vanilla-extract/css';
export const root = style({
display: 'flex',
height: '100%',
width: '100%',
});

View File

@ -0,0 +1,41 @@
import { TOCNotesPanel } from '@blocksuite/blocks';
import { assertExists } from '@blocksuite/global/utils';
import { FrameIcon } from '@blocksuite/icons';
import { useCallback, useRef } from 'react';
import { useCurrentPage } from '../../../../../hooks/current/use-current-page';
import type { EditorExtension } from '../types';
import * as styles from './frame.css';
// A wrapper for TOCNotesPanel
const EditorOutline = () => {
const tocPanelRef = useRef<TOCNotesPanel | null>(null);
const currentPage = useCurrentPage();
const onRefChange = useCallback((container: HTMLDivElement | null) => {
if (container) {
assertExists(tocPanelRef.current, 'toc panel should be initialized');
container.append(tocPanelRef.current);
}
}, []);
if (!currentPage) {
return;
}
if (!tocPanelRef.current) {
tocPanelRef.current = new TOCNotesPanel();
}
if (currentPage !== tocPanelRef.current?.page) {
(tocPanelRef.current as TOCNotesPanel).page = currentPage;
}
return <div className={styles.root} ref={onRefChange} />;
};
export const frameExtension: EditorExtension = {
name: 'frame',
icon: <FrameIcon />,
Component: EditorOutline,
};

View File

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

View File

@ -0,0 +1,7 @@
import { style } from '@vanilla-extract/css';
export const root = style({
display: 'flex',
height: '100%',
width: '100%',
});

View File

@ -0,0 +1,41 @@
import { TOCNotesPanel } from '@blocksuite/blocks';
import { assertExists } from '@blocksuite/global/utils';
import { TocIcon } from '@blocksuite/icons';
import { useCallback, useRef } from 'react';
import { useCurrentPage } from '../../../../../hooks/current/use-current-page';
import type { EditorExtension } from '../types';
import * as styles from './outline.css';
// A wrapper for TOCNotesPanel
const EditorOutline = () => {
const tocPanelRef = useRef<TOCNotesPanel | null>(null);
const currentPage = useCurrentPage();
const onRefChange = useCallback((container: HTMLDivElement | null) => {
if (container) {
assertExists(tocPanelRef.current, 'toc panel should be initialized');
container.append(tocPanelRef.current);
}
}, []);
if (!currentPage) {
return;
}
if (!tocPanelRef.current) {
tocPanelRef.current = new TOCNotesPanel();
}
if (currentPage !== tocPanelRef.current?.page) {
(tocPanelRef.current as TOCNotesPanel).page = currentPage;
}
return <div className={styles.root} ref={onRefChange} />;
};
export const outlineExtension: EditorExtension = {
name: 'outline',
icon: <TocIcon />,
Component: EditorOutline,
};

View File

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

View File

@ -0,0 +1,7 @@
export type EditorExtensionName = 'outline' | 'frame';
export interface EditorExtension {
name: EditorExtensionName;
icon: React.ReactNode;
Component: React.ComponentType;
}

View File

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

View File

@ -1,5 +1,7 @@
import { style } from '@vanilla-extract/css';
export { root } from './all-page/all-page.css';
export const trashTitle = style({
display: 'flex',
alignItems: 'center',

View File

@ -3,20 +3,43 @@ import {
TrashOperationCell,
VirtualizedPageList,
} from '@affine/component/page-list';
import { WorkspaceSubPath } from '@affine/env/workspace';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { assertExists } from '@blocksuite/global/utils';
import { DeleteIcon } from '@blocksuite/icons';
import type { PageMeta } from '@blocksuite/store';
import { useBlockSuitePageMeta } from '@toeverything/hooks/use-block-suite-page-meta';
import { useCallback } from 'react';
import { usePageHelper } from '../../components/blocksuite/block-suite-page-list/utils';
import { WorkspaceHeader } from '../../components/workspace-header';
import { Header } from '../../components/pure/header';
import { WindowsAppControls } from '../../components/pure/header/windows-app-controls';
import { useBlockSuiteMetaHelper } from '../../hooks/affine/use-block-suite-meta-helper';
import { useCurrentWorkspace } from '../../hooks/current/use-current-workspace';
import * as styles from './all-page.css';
import { EmptyPageList } from './page-list-empty';
import { useFilteredPageMetas } from './pages';
import * as styles from './trash-page.css';
const isWindowsDesktop = environment.isDesktop && environment.isWindows;
const TrashHeader = () => {
const t = useAFFiNEI18N();
return (
<Header
left={
<div className={styles.trashTitle}>
<DeleteIcon className={styles.trashIcon} />
{t['com.affine.workspaceSubPath.trash']()}
</div>
}
right={
isWindowsDesktop ? (
<div style={{ marginRight: -16 }}>
<WindowsAppControls />
</div>
) : null
}
/>
);
};
export const TrashPage = () => {
const [currentWorkspace] = useCurrentWorkspace();
@ -60,12 +83,7 @@ export const TrashPage = () => {
);
return (
<div className={styles.root}>
<WorkspaceHeader
currentWorkspaceId={currentWorkspace.id}
currentEntry={{
subPath: WorkspaceSubPath.TRASH,
}}
/>
<TrashHeader />
{filteredPageMetas.length > 0 ? (
<VirtualizedPageList
pages={filteredPageMetas}

View File

@ -13,7 +13,7 @@ export const routes = [
children: [
{
path: 'all',
lazy: () => import('./pages/workspace/all-page'),
lazy: () => import('./pages/workspace/all-page/all-page'),
},
{
path: 'collection/:collectionId',
@ -25,13 +25,13 @@ export const routes = [
},
{
path: ':pageId',
lazy: () => import('./pages/workspace/detail-page'),
lazy: () => import('./pages/workspace/detail-page/detail-page'),
},
],
},
{
path: '/share/:workspaceId/:pageId',
lazy: () => import('./pages/share/detail-page'),
lazy: () => import('./pages/share/share-detail-page'),
},
{
path: '/404',

View File

@ -1,21 +1,16 @@
import type { ToastOptions } from '@affine/component';
import { toast as basicToast } from '@affine/component';
import { assertEquals, assertExists } from '@blocksuite/global/utils';
import { getCurrentStore } from '@toeverything/infra/atom';
import { mainContainerAtom } from '../atoms/element';
import { assertEquals } from '@blocksuite/global/utils';
export const toast = (message: string, options?: ToastOptions) => {
const mainContainer = getCurrentStore().get(mainContainerAtom);
const modal = document.querySelector(
'[role=presentation]'
) as HTMLDivElement | null;
assertExists(mainContainer, 'main container should exist');
if (modal) {
assertEquals(modal.constructor, HTMLDivElement, 'modal should be div');
}
return basicToast(message, {
portal: modal || mainContainer || document.body,
portal: modal || document.body,
...options,
});
};

View File

@ -4,24 +4,22 @@ import debounce from 'lodash.debounce';
import { type RefObject, useEffect, useState } from 'react';
export function useIsTinyScreen({
mainContainer,
container,
leftStatic,
leftSlot,
centerDom,
rightStatic,
rightSlot,
}: {
mainContainer: HTMLElement | null;
container: HTMLElement | null;
leftStatic: RefObject<HTMLElement>;
leftSlot: RefObject<HTMLElement>[];
centerDom: RefObject<HTMLElement>;
rightStatic: RefObject<HTMLElement>;
rightSlot: RefObject<HTMLElement>[];
}) {
const [isTinyScreen, setIsTinyScreen] = useState(false);
useEffect(() => {
if (!mainContainer) {
if (!container) {
return;
}
const handleResize = debounce(() => {
@ -33,8 +31,6 @@ export function useIsTinyScreen({
return accWidth + (dom.current?.clientWidth || 0);
}, 0);
const rightStaticWidth = rightStatic.current?.clientWidth || 0;
const rightSlotWidth = rightSlot.reduce((accWidth, dom) => {
return accWidth + (dom.current?.clientWidth || 0);
}, 0);
@ -46,14 +42,13 @@ export function useIsTinyScreen({
return;
}
const containerRect = mainContainer.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
const centerRect = centerDom.current.getBoundingClientRect();
if (
leftStaticWidth + leftSlotWidth + containerRect.left >=
centerRect.left ||
containerRect.right - centerRect.right <=
rightSlotWidth + rightStaticWidth
containerRect.right - centerRect.right <= rightSlotWidth
) {
setIsTinyScreen(true);
} else {
@ -67,20 +62,12 @@ export function useIsTinyScreen({
handleResize();
});
resizeObserver.observe(mainContainer);
resizeObserver.observe(container);
return () => {
resizeObserver.unobserve(mainContainer);
resizeObserver.unobserve(container);
};
}, [
centerDom,
isTinyScreen,
leftSlot,
leftStatic,
mainContainer,
rightSlot,
rightStatic,
]);
}, [centerDom, isTinyScreen, leftSlot, leftStatic, container, rightSlot]);
return isTinyScreen;
}

View File

@ -510,6 +510,7 @@
"com.affine.cmdk.affine.editor.edgeless.presentation-start": "Start Presentation",
"com.affine.cmdk.affine.editor.remove-from-favourites": "Remove from Favourites",
"com.affine.cmdk.affine.editor.restore-from-trash": "Restore from Trash",
"com.affine.cmdk.affine.editor.trash-footer-hint": "This page has been moved to the trash, you can either restore or permanently delete it.",
"com.affine.cmdk.affine.font-style.to": "Change Font Style to",
"com.affine.cmdk.affine.full-width-layout.to": "Change Full Width Layout to",
"com.affine.cmdk.affine.getting-started": "Getting Started",

View File

@ -160,9 +160,9 @@ test('affine onboarding button', async ({ page }) => {
test('windows only check', async ({ page }) => {
const windowOnlyUI = page.locator('[data-platform-target=win32]');
if (process.platform === 'win32') {
await expect(windowOnlyUI).toBeVisible();
await expect(windowOnlyUI.first()).toBeVisible();
} else {
await expect(windowOnlyUI).not.toBeVisible();
await expect(windowOnlyUI.first()).not.toBeVisible();
}
});