From 5352736eba19711b8b3def21b34a05c0739ccfee Mon Sep 17 00:00:00 2001 From: Peng Xiao Date: Fri, 8 Dec 2023 01:03:47 +0000 Subject: [PATCH] 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 ``). **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. --- packages/common/env/src/workspace.ts | 21 - .../src/components/workspace/index.css.ts | 26 +- .../src/components/workspace/index.tsx | 14 +- .../component/src/ui/button/button.css.ts | 1 + .../frontend/core/src/adapters/cloud/ui.tsx | 42 +- .../core/src/adapters/local/index.tsx | 28 +- .../core/src/adapters/public-cloud/ui.tsx | 18 +- packages/frontend/core/src/adapters/shared.ts | 6 - packages/frontend/core/src/atoms/element.ts | 5 - .../register-blocksuite-components.ts | 12 + packages/frontend/core/src/bootstrap/setup.ts | 2 + .../src/components/affine/awareness/index.tsx | 45 +++ .../components/affine/hub-island/index.tsx | 11 + .../affine/share-page-modal/index.tsx | 2 +- .../share-menu/share-menu.tsx | 4 +- .../block-suite-header-title/styles.css.ts | 2 +- .../src/components/page-detail-editor.css.ts | 17 +- .../src/components/page-detail-editor.tsx | 198 ++-------- .../core/src/components/pure/header/index.tsx | 41 +- .../src/components/pure/header/style.css.tsx | 13 +- .../src/components/pure/help-island/index.tsx | 16 +- .../src/components/pure/help-island/style.ts | 1 - .../pure/trash-button-group/index.tsx | 154 -------- .../pure/trash-page-footer/index.tsx | 99 +++++ .../styles.css.ts | 4 +- .../core/src/components/workspace-header.tsx | 189 --------- .../core/src/hooks/affine/use-current-user.ts | 3 +- .../src/hooks/current/use-current-page.ts | 12 + .../core/src/layouts/workspace-layout.tsx | 32 +- ...{detail-page.tsx => share-detail-page.tsx} | 4 +- .../share}/share-header.tsx | 18 +- .../workspace/all-page/all-page-filter.tsx | 60 +++ .../workspace/{ => all-page}/all-page.css.ts | 7 + .../workspace/{ => all-page}/all-page.tsx | 93 +++-- .../core/src/pages/workspace/collection.tsx | 2 +- .../core/src/pages/workspace/detail-page.tsx | 215 ----------- .../detail-page/detail-page-header.css.ts | 59 +++ .../detail-page/detail-page-header.tsx | 159 ++++++++ .../workspace/detail-page/detail-page.css.ts | 95 +++++ .../workspace/detail-page/detail-page.tsx | 363 ++++++++++++++++++ .../detail-page/editor-sidebar/atoms.ts | 79 ++++ .../editor-sidebar/editor-sidebar.css.ts | 10 + .../editor-sidebar/editor-sidebar.tsx | 16 + .../extensions/extensions.css.ts | 45 +++ .../editor-sidebar/extensions/extensions.tsx | 37 ++ .../editor-sidebar/extensions/frame.css.ts | 7 + .../editor-sidebar/extensions/frame.tsx | 41 ++ .../editor-sidebar/extensions/index.tsx | 1 + .../editor-sidebar/extensions/outline.css.ts | 7 + .../editor-sidebar/extensions/outline.tsx | 41 ++ .../detail-page/editor-sidebar/index.ts | 4 + .../detail-page/editor-sidebar/types.ts | 7 + .../src/pages/workspace/detail-page/index.ts | 1 + .../workspace/trash-page.css.ts} | 2 + .../core/src/pages/workspace/trash-page.tsx | 36 +- packages/frontend/core/src/router.ts | 6 +- packages/frontend/core/src/utils/toast.ts | 9 +- .../frontend/hooks/src/use-is-tiny-screen.ts | 29 +- packages/frontend/i18n/src/resources/en.json | 1 + tests/affine-desktop/e2e/basic.spec.ts | 4 +- 60 files changed, 1424 insertions(+), 1052 deletions(-) delete mode 100644 packages/frontend/core/src/atoms/element.ts create mode 100644 packages/frontend/core/src/bootstrap/register-blocksuite-components.ts create mode 100644 packages/frontend/core/src/components/affine/awareness/index.tsx create mode 100644 packages/frontend/core/src/components/affine/hub-island/index.tsx delete mode 100644 packages/frontend/core/src/components/pure/trash-button-group/index.tsx create mode 100644 packages/frontend/core/src/components/pure/trash-page-footer/index.tsx rename packages/frontend/core/src/components/pure/{trash-button-group => trash-page-footer}/styles.css.ts (96%) delete mode 100644 packages/frontend/core/src/components/workspace-header.tsx create mode 100644 packages/frontend/core/src/hooks/current/use-current-page.ts rename packages/frontend/core/src/pages/share/{detail-page.tsx => share-detail-page.tsx} (96%) rename packages/frontend/core/src/{components => pages/share}/share-header.tsx (52%) create mode 100644 packages/frontend/core/src/pages/workspace/all-page/all-page-filter.tsx rename packages/frontend/core/src/pages/workspace/{ => all-page}/all-page.css.ts (91%) rename packages/frontend/core/src/pages/workspace/{ => all-page}/all-page.tsx (78%) delete mode 100644 packages/frontend/core/src/pages/workspace/detail-page.tsx create mode 100644 packages/frontend/core/src/pages/workspace/detail-page/detail-page-header.css.ts create mode 100644 packages/frontend/core/src/pages/workspace/detail-page/detail-page-header.tsx create mode 100644 packages/frontend/core/src/pages/workspace/detail-page/detail-page.css.ts create mode 100644 packages/frontend/core/src/pages/workspace/detail-page/detail-page.tsx create mode 100644 packages/frontend/core/src/pages/workspace/detail-page/editor-sidebar/atoms.ts create mode 100644 packages/frontend/core/src/pages/workspace/detail-page/editor-sidebar/editor-sidebar.css.ts create mode 100644 packages/frontend/core/src/pages/workspace/detail-page/editor-sidebar/editor-sidebar.tsx create mode 100644 packages/frontend/core/src/pages/workspace/detail-page/editor-sidebar/extensions/extensions.css.ts create mode 100644 packages/frontend/core/src/pages/workspace/detail-page/editor-sidebar/extensions/extensions.tsx create mode 100644 packages/frontend/core/src/pages/workspace/detail-page/editor-sidebar/extensions/frame.css.ts create mode 100644 packages/frontend/core/src/pages/workspace/detail-page/editor-sidebar/extensions/frame.tsx create mode 100644 packages/frontend/core/src/pages/workspace/detail-page/editor-sidebar/extensions/index.tsx create mode 100644 packages/frontend/core/src/pages/workspace/detail-page/editor-sidebar/extensions/outline.css.ts create mode 100644 packages/frontend/core/src/pages/workspace/detail-page/editor-sidebar/extensions/outline.tsx create mode 100644 packages/frontend/core/src/pages/workspace/detail-page/editor-sidebar/index.ts create mode 100644 packages/frontend/core/src/pages/workspace/detail-page/editor-sidebar/types.ts create mode 100644 packages/frontend/core/src/pages/workspace/detail-page/index.ts rename packages/frontend/core/src/{components/workspace-header.css.ts => pages/workspace/trash-page.css.ts} (85%) diff --git a/packages/common/env/src/workspace.ts b/packages/common/env/src/workspace.ts index 91ed56f6a..b92b8e352 100644 --- a/packages/common/env/src/workspace.ts +++ b/packages/common/env/src/workspace.ts @@ -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 = - UIBaseProps & { - rightSlot?: ReactNode; - currentEntry: - | { - subPath: WorkspaceSubPath; - } - | { - pageId: string; - }; - }; - type NewSettingProps = UIBaseProps & { onDeleteLocalWorkspace: () => void; @@ -161,18 +147,11 @@ type NewSettingProps = ) => void; }; -type PageDetailProps = - UIBaseProps & { - currentPageId: string; - onLoadEditor: (page: Page, editor: EditorContainer) => () => void; - }; - interface FC

{ (props: P): ReactNode; } export interface WorkspaceUISchema { - PageDetail: FC>; NewSettingsDetail: FC>; Provider: FC; LoginCard?: FC; diff --git a/packages/frontend/component/src/components/workspace/index.css.ts b/packages/frontend/component/src/components/workspace/index.css.ts index ce31f3cd3..a5a91dee9 100644 --- a/packages/frontend/component/src/components/workspace/index.css.ts +++ b/packages/frontend/component/src/components/workspace/index.css.ts @@ -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', - }, - }, - }, - }, }); diff --git a/packages/frontend/component/src/components/workspace/index.tsx b/packages/frontend/component/src/components/workspace/index.tsx index 347266012..157df7861 100644 --- a/packages/frontend/component/src/components/workspace/index.tsx +++ b/packages/frontend/component/src/components/workspace/index.tsx @@ -35,14 +35,13 @@ export const AppContainer = ({ export interface MainContainerProps extends HTMLAttributes { className?: string; padding?: boolean; - inTrashPage?: boolean; } export const MainContainer = forwardRef< HTMLDivElement, PropsWithChildren >(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 ( -
- {props.children} -
- ); +export const ToolContainer = (props: PropsWithChildren): ReactElement => { + return
{props.children}
; }; export const WorkspaceFallback = (): ReactElement => { diff --git a/packages/frontend/component/src/ui/button/button.css.ts b/packages/frontend/component/src/ui/button/button.css.ts index f74f0a266..bb48c4c0e 100644 --- a/packages/frontend/component/src/ui/button/button.css.ts +++ b/packages/frontend/component/src/ui/button/button.css.ts @@ -6,6 +6,7 @@ export const button = style({ alignItems: 'center', userSelect: 'none', touchAction: 'manipulation', + flexShrink: 0, outline: '0', border: '1px solid', padding: '0 18px', diff --git a/packages/frontend/core/src/adapters/cloud/ui.tsx b/packages/frontend/core/src/adapters/cloud/ui.tsx index 08e113719..af1cca88c 100644 --- a/packages/frontend/core/src/adapters/cloud/ui.tsx +++ b/packages/frontend/core/src/adapters/cloud/ui.tsx @@ -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( - (...args) => { - const dispose = onLoadEditor(...args); - workspace.blockSuiteWorkspace.awarenessStore.awareness.setLocalStateField( - 'user', - { - name: currentUser.name, - } - ); - return dispose; - }, - [currentUser, workspace, onLoadEditor] - ); - - return ( - - ); - }, NewSettingsDetail: ({ currentWorkspaceId, onTransformWorkspace, diff --git a/packages/frontend/core/src/adapters/local/index.tsx b/packages/frontend/core/src/adapters/local/index.tsx index 1b99823f2..fbb0bee26 100644 --- a/packages/frontend/core/src/adapters/local/index.tsx +++ b/packages/frontend/core/src/adapters/local/index.tsx @@ -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 = { 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 ( - - ); - }, NewSettingsDetail: ({ currentWorkspaceId, onTransformWorkspace, diff --git a/packages/frontend/core/src/adapters/public-cloud/ui.tsx b/packages/frontend/core/src/adapters/public-cloud/ui.tsx index 120be6362..0e033dd58 100644 --- a/packages/frontend/core/src/adapters/public-cloud/ui.tsx +++ b/packages/frontend/core/src/adapters/public-cloud/ui.tsx @@ -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 ( - - ); - }, NewSettingsDetail: () => { throw new Error('Not implemented'); }, diff --git a/packages/frontend/core/src/adapters/shared.ts b/packages/frontend/core/src/adapters/shared.ts index 79f369bf3..6a075b042 100644 --- a/packages/frontend/core/src/adapters/shared.ts +++ b/packages/frontend/core/src/adapters/shared.ts @@ -13,9 +13,3 @@ export const NewWorkspaceSettingDetail = lazy(() => }) ) ); - -export const PageDetailEditor = lazy(() => - import('../components/page-detail-editor').then(({ PageDetailEditor }) => ({ - default: PageDetailEditor, - })) -); diff --git a/packages/frontend/core/src/atoms/element.ts b/packages/frontend/core/src/atoms/element.ts deleted file mode 100644 index 2f33acdc6..000000000 --- a/packages/frontend/core/src/atoms/element.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { atom } from 'jotai/vanilla'; - -export const appHeaderAtom = atom(null); - -export const mainContainerAtom = atom(null); diff --git a/packages/frontend/core/src/bootstrap/register-blocksuite-components.ts b/packages/frontend/core/src/bootstrap/register-blocksuite-components.ts new file mode 100644 index 000000000..d5f5b07f8 --- /dev/null +++ b/packages/frontend/core/src/bootstrap/register-blocksuite-components.ts @@ -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] + ); + } +}); diff --git a/packages/frontend/core/src/bootstrap/setup.ts b/packages/frontend/core/src/bootstrap/setup.ts index 36af51425..caa79875a 100644 --- a/packages/frontend/core/src/bootstrap/setup.ts +++ b/packages/frontend/core/src/bootstrap/setup.ts @@ -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'; diff --git a/packages/frontend/core/src/components/affine/awareness/index.tsx b/packages/frontend/core/src/components/affine/awareness/index.tsx new file mode 100644 index 000000000..db308bd26 --- /dev/null +++ b/packages/frontend/core/src/components/affine/awareness/index.tsx @@ -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 ; + } + + return null; +}; + +// todo: we could do something more interesting here, e.g., show where the current user is +export const SyncAwareness = () => { + return ( + + + + ); +}; diff --git a/packages/frontend/core/src/components/affine/hub-island/index.tsx b/packages/frontend/core/src/components/affine/hub-island/index.tsx new file mode 100644 index 000000000..732c220b8 --- /dev/null +++ b/packages/frontend/core/src/components/affine/hub-island/index.tsx @@ -0,0 +1,11 @@ +import { ToolContainer } from '@affine/component/workspace'; + +import { HelpIsland } from '../../pure/help-island'; + +export const HubIsland = () => { + return ( + + + + ); +}; diff --git a/packages/frontend/core/src/components/affine/share-page-modal/index.tsx b/packages/frontend/core/src/components/affine/share-page-modal/index.tsx index 1e4395ce4..353f9cd7c 100644 --- a/packages/frontend/core/src/components/affine/share-page-modal/index.tsx +++ b/packages/frontend/core/src/components/affine/share-page-modal/index.tsx @@ -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); diff --git a/packages/frontend/core/src/components/affine/share-page-modal/share-menu/share-menu.tsx b/packages/frontend/core/src/components/affine/share-page-modal/share-menu/share-menu.tsx index b77f8c190..b4492cfec 100644 --- a/packages/frontend/core/src/components/affine/share-page-modal/share-menu/share-menu.tsx +++ b/packages/frontend/core/src/components/affine/share-page-modal/share-menu/share-menu.tsx @@ -60,7 +60,7 @@ const LocalShareMenu = (props: ShareMenuProps) => { modal: false, }} > - @@ -86,7 +86,7 @@ const CloudShareMenu = (props: ShareMenuProps) => { modal: false, }} > - - - - - - - { - jumpToSubPath(workspace.id, WorkspaceSubPath.ALL); - blockSuiteWorkspace.removePage(pageId); - toast(t['com.affine.toastMessage.permanentlyDeleted']()); - }, [blockSuiteWorkspace, jumpToSubPath, pageId, workspace.id, t])} - onOpenChange={setOpen} - /> - - - ); -}; - -export default TrashButtonGroup; diff --git a/packages/frontend/core/src/components/pure/trash-page-footer/index.tsx b/packages/frontend/core/src/components/pure/trash-page-footer/index.tsx new file mode 100644 index 000000000..57ac8fcc0 --- /dev/null +++ b/packages/frontend/core/src/components/pure/trash-page-footer/index.tsx @@ -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 ( +
+
{hintText}
+
+ + + + + + +
+ +
+ ); +}; diff --git a/packages/frontend/core/src/components/pure/trash-button-group/styles.css.ts b/packages/frontend/core/src/components/pure/trash-page-footer/styles.css.ts similarity index 96% rename from packages/frontend/core/src/components/pure/trash-button-group/styles.css.ts rename to packages/frontend/core/src/components/pure/trash-page-footer/styles.css.ts index 71b07c64f..8895b4118 100644 --- a/packages/frontend/core/src/components/pure/trash-button-group/styles.css.ts +++ b/packages/frontend/core/src/components/pure/trash-page-footer/styles.css.ts @@ -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)', diff --git a/packages/frontend/core/src/components/workspace-header.tsx b/packages/frontend/core/src/components/workspace-header.tsx deleted file mode 100644 index 18d15026e..000000000 --- a/packages/frontend/core/src/components/workspace-header.tsx +++ /dev/null @@ -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 ( -
-
- -
-
- {setting.currentCollection.filterList.length > 0 ? ( - - ) : null} -
-
- ); -}; - -export function WorkspaceHeader({ - currentWorkspaceId, - currentEntry, - rightSlot, -}: WorkspaceHeaderProps) { - 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 ( - <> -
- } - right={rightSlot} - center={} - /> - - - ); - } - - // route in shared - if ( - 'subPath' in currentEntry && - currentEntry.subPath === WorkspaceSubPath.SHARED - ) { - return ( -
} - /> - ); - } - - // route in trash - if ( - 'subPath' in currentEntry && - currentEntry.subPath === WorkspaceSubPath.TRASH - ) { - return ( -
- - {t['com.affine.workspaceSubPath.trash']()} - - } - /> - ); - } - - // route in edit page - if ('pageId' in currentEntry) { - const currentPage = workspace.getPage(currentEntry.pageId); - const sharePageModal = currentPage ? ( - - ) : null; - return ( - <> -
- } - right={ -
- {sharePageModal} - -
- } - bottomBorder - /> - - - ); - } - - throw new Unreachable(); -} diff --git a/packages/frontend/core/src/hooks/affine/use-current-user.ts b/packages/frontend/core/src/hooks/affine/use-current-user.ts index 94b4b967a..35cbe9bbc 100644 --- a/packages/frontend/core/src/hooks/affine/use-current-user.ts +++ b/packages/frontend/core/src/hooks/affine/use-current-user.ts @@ -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]); } diff --git a/packages/frontend/core/src/hooks/current/use-current-page.ts b/packages/frontend/core/src/hooks/current/use-current-page.ts new file mode 100644 index 000000000..0e5a35ada --- /dev/null +++ b/packages/frontend/core/src/hooks/current/use-current-page.ts @@ -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; +}; diff --git a/packages/frontend/core/src/layouts/workspace-layout.tsx b/packages/frontend/core/src/layouts/workspace-layout.tsx index d9603b7e9..ab848eeb0 100644 --- a/packages/frontend/core/src/layouts/workspace-layout.tsx +++ b/packages/frontend/core/src/layouts/workspace-layout.tsx @@ -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} /> - }> - + }> + {migration ? ( ) : ( children )} - - - + ); }; diff --git a/packages/frontend/core/src/pages/share/detail-page.tsx b/packages/frontend/core/src/pages/share/share-detail-page.tsx similarity index 96% rename from packages/frontend/core/src/pages/share/detail-page.tsx rename to packages/frontend/core/src/pages/share/share-detail-page.tsx index 0776f8323..e255212a6 100644 --- a/packages/frontend/core/src/pages/share/detail-page.tsx +++ b/packages/frontend/core/src/pages/share/share-detail-page.tsx @@ -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; diff --git a/packages/frontend/core/src/components/share-header.tsx b/packages/frontend/core/src/pages/share/share-header.tsx similarity index 52% rename from packages/frontend/core/src/components/share-header.tsx rename to packages/frontend/core/src/pages/share/share-header.tsx index 20f6c6ab6..675902637 100644 --- a/packages/frontend/core/src/components/share-header.tsx +++ b/packages/frontend/core/src/pages/share/share-header.tsx @@ -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 (
} center={ { + 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 ( +
+
+ +
+
+ {setting.currentCollection.filterList.length > 0 ? ( + + ) : null} +
+
+ ); +}; diff --git a/packages/frontend/core/src/pages/workspace/all-page.css.ts b/packages/frontend/core/src/pages/workspace/all-page/all-page.css.ts similarity index 91% rename from packages/frontend/core/src/pages/workspace/all-page.css.ts rename to packages/frontend/core/src/pages/workspace/all-page/all-page.css.ts index 32ecc9f4c..70db74ebc 100644 --- a/packages/frontend/core/src/pages/workspace/all-page.css.ts +++ b/packages/frontend/core/src/pages/workspace/all-page/all-page.css.ts @@ -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)', +}); diff --git a/packages/frontend/core/src/pages/workspace/all-page.tsx b/packages/frontend/core/src/pages/workspace/all-page/all-page.tsx similarity index 78% rename from packages/frontend/core/src/pages/workspace/all-page.tsx rename to packages/frontend/core/src/pages/workspace/all-page/all-page.tsx index d62bf16b2..d07a8fb2d 100644 --- a/packages/frontend/core/src/pages/workspace/all-page.tsx +++ b/packages/frontend/core/src/pages/workspace/all-page/all-page.tsx @@ -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 ( + <> +
+ } + right={ +
+ + + + {isWindowsDesktop ? : null} +
+ } + center={} + /> + + + ); +}; + // 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 (
{currentWorkspace.flavour !== WorkspaceFlavour.AFFINE_PUBLIC ? ( - - - - } + ) : null} {filteredPageMetas.length > 0 ? ( @@ -299,6 +337,7 @@ export const AllPage = () => { blockSuiteWorkspace={currentWorkspace.blockSuiteWorkspace} /> )} +
); }; diff --git a/packages/frontend/core/src/pages/workspace/collection.tsx b/packages/frontend/core/src/pages/workspace/collection.tsx index f66bb5fe7..3db8be598 100644 --- a/packages/frontend/core/src/pages/workspace/collection.tsx +++ b/packages/frontend/core/src/pages/workspace/collection.tsx @@ -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 => { diff --git a/packages/frontend/core/src/pages/workspace/detail-page.tsx b/packages/frontend/core/src/pages/workspace/detail-page.tsx deleted file mode 100644 index e270ad26f..000000000 --- a/packages/frontend/core/src/pages/workspace/detail-page.tsx +++ /dev/null @@ -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).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 ( - <> - - - - - - ); -}; - -export const DetailPage = (): ReactElement => { - const [currentWorkspace] = useCurrentWorkspace(); - const currentSyncEngineStatus = useCurrentSyncEngineStatus(); - const currentSyncEngine = useCurrentSyncEngine(); - const currentPageId = useAtomValue(currentPageIdAtom); - const [page, setPage] = useState(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 ; - } - - if (page.meta.jumpOnce) { - currentWorkspace.blockSuiteWorkspace.setPageMeta(page.id, { - jumpOnce: false, - }); - } - - return ; -}; - -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 ( - - - - ); -}; diff --git a/packages/frontend/core/src/pages/workspace/detail-page/detail-page-header.css.ts b/packages/frontend/core/src/pages/workspace/detail-page/detail-page-header.css.ts new file mode 100644 index 000000000..73b4f6052 --- /dev/null +++ b/packages/frontend/core/src/pages/workspace/detail-page/detail-page-header.css.ts @@ -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', +}); diff --git a/packages/frontend/core/src/pages/workspace/detail-page/detail-page-header.tsx b/packages/frontend/core/src/pages/workspace/detail-page/detail-page-header.tsx new file mode 100644 index 000000000..6628da77e --- /dev/null +++ b/packages/frontend/core/src/pages/workspace/detail-page/detail-page-header.tsx @@ -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 ( + + + + ); +}; +const isWindowsDesktop = environment.isDesktop && environment.isWindows; + +const WindowsMainPageHeaderRight = ({ + showSidebarSwitch, +}: PageHeaderRightProps) => { + const editorSidebarOpen = useAtomValue(editorSidebarOpenAtom); + + if (editorSidebarOpen) { + return null; + } + + return ( + <> + +
+ {showSidebarSwitch ? : null} + +
+ + ); +}; + +const NonWindowsMainPageHeaderRight = ({ + showSidebarSwitch, +}: PageHeaderRightProps) => { + const editorSidebarOpen = useAtomValue(editorSidebarOpenAtom); + + if (editorSidebarOpen || !showSidebarSwitch) { + return null; + } + + return ( + <> + +
+ +
+ + ); +}; + +function Header({ + children, + style, + className, +}: { + children: React.ReactNode; + className?: string; + style?: React.CSSProperties; +}) { + const appSidebarFloating = useAtomValue(appSidebarFloatingAtom); + return ( +
+ {children} +
+ ); +} + +export function DetailPageHeader({ + page, + workspace, + showSidebarSwitch = true, +}: { + page: Page; + workspace: AllWorkspace; + showSidebarSwitch?: boolean; +}) { + const leftSidebarOpen = useAtomValue(appSidebarOpenAtom); + const RightHeader = isWindowsDesktop + ? WindowsMainPageHeaderRight + : NonWindowsMainPageHeaderRight; + return ( +
+ + {!leftSidebarOpen ? : null} + +
+ {page ? : null} + +
+ ); +} + +function WindowsSidebarHeader() { + return ( + <> +
+
+ + +
+
+ +
+ + ); +} + +function NonWindowsSidebarHeader() { + return ( +
+ +
+ +
+ ); +} + +export function RightSidebarHeader() { + return isWindowsDesktop ? ( + + ) : ( + + ); +} diff --git a/packages/frontend/core/src/pages/workspace/detail-page/detail-page.css.ts b/packages/frontend/core/src/pages/workspace/detail-page/detail-page.css.ts new file mode 100644 index 000000000..4c01eab62 --- /dev/null +++ b/packages/frontend/core/src/pages/workspace/detail-page/detail-page.css.ts @@ -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', + }, + }, +}); diff --git a/packages/frontend/core/src/pages/workspace/detail-page/detail-page.tsx b/packages/frontend/core/src/pages/workspace/detail-page/detail-page.tsx new file mode 100644 index 000000000..a4aa1c82d --- /dev/null +++ b/packages/frontend/core/src/pages/workspace/detail-page/detail-page.tsx @@ -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(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 ( + + + {header} + {main} + {footer} + + {sidebar ? ( + <> + +
+ + + {sidebar} + + + ) : null} + + ); +}; + +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).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 ( + <> + + + + + } + main={ +
+ + +
+ } + footer={isInTrash ? : null} + sidebar={ + !isInTrash ? ( + <> + + + + ) : null + } + /> + + + ); +}; + +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 ; + } + + if (page.meta.jumpOnce) { + currentWorkspace.blockSuiteWorkspace.setPageMeta(page.id, { + jumpOnce: false, + }); + } + + return ; +}; + +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 ( + + {pageId ? : null} + + ); +}; diff --git a/packages/frontend/core/src/pages/workspace/detail-page/editor-sidebar/atoms.ts b/packages/frontend/core/src/pages/workspace/detail-page/editor-sidebar/atoms.ts new file mode 100644 index 000000000..0d0fcfb8d --- /dev/null +++ b/packages/frontend/core/src/pages/workspace/detail-page/editor-sidebar/atoms.ts @@ -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({ + 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 }; + }); + } +); diff --git a/packages/frontend/core/src/pages/workspace/detail-page/editor-sidebar/editor-sidebar.css.ts b/packages/frontend/core/src/pages/workspace/detail-page/editor-sidebar/editor-sidebar.css.ts new file mode 100644 index 000000000..5c9fd6fa1 --- /dev/null +++ b/packages/frontend/core/src/pages/workspace/detail-page/editor-sidebar/editor-sidebar.css.ts @@ -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', +}); diff --git a/packages/frontend/core/src/pages/workspace/detail-page/editor-sidebar/editor-sidebar.tsx b/packages/frontend/core/src/pages/workspace/detail-page/editor-sidebar/editor-sidebar.tsx new file mode 100644 index 000000000..9d0427f2c --- /dev/null +++ b/packages/frontend/core/src/pages/workspace/detail-page/editor-sidebar/editor-sidebar.tsx @@ -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
{Component ? : null}
; +}; diff --git a/packages/frontend/core/src/pages/workspace/detail-page/editor-sidebar/extensions/extensions.css.ts b/packages/frontend/core/src/pages/workspace/detail-page/editor-sidebar/extensions/extensions.css.ts new file mode 100644 index 000000000..f5231a4e1 --- /dev/null +++ b/packages/frontend/core/src/pages/workspace/detail-page/editor-sidebar/extensions/extensions.css.ts @@ -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)', + }, + }, +}); diff --git a/packages/frontend/core/src/pages/workspace/detail-page/editor-sidebar/extensions/extensions.tsx b/packages/frontend/core/src/pages/workspace/detail-page/editor-sidebar/extensions/extensions.tsx new file mode 100644 index 000000000..01c0a0722 --- /dev/null +++ b/packages/frontend/core/src/pages/workspace/detail-page/editor-sidebar/extensions/extensions.tsx @@ -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 ( +
+ {exts.map(extension => { + return ( + setSelected(extension.name)} + key={extension.name} + data-active={selected?.name === extension.name} + className={styles.button} + > + {extension.icon} + + ); + })} +
+ ); +}; diff --git a/packages/frontend/core/src/pages/workspace/detail-page/editor-sidebar/extensions/frame.css.ts b/packages/frontend/core/src/pages/workspace/detail-page/editor-sidebar/extensions/frame.css.ts new file mode 100644 index 000000000..0a2dbb442 --- /dev/null +++ b/packages/frontend/core/src/pages/workspace/detail-page/editor-sidebar/extensions/frame.css.ts @@ -0,0 +1,7 @@ +import { style } from '@vanilla-extract/css'; + +export const root = style({ + display: 'flex', + height: '100%', + width: '100%', +}); diff --git a/packages/frontend/core/src/pages/workspace/detail-page/editor-sidebar/extensions/frame.tsx b/packages/frontend/core/src/pages/workspace/detail-page/editor-sidebar/extensions/frame.tsx new file mode 100644 index 000000000..9ac170544 --- /dev/null +++ b/packages/frontend/core/src/pages/workspace/detail-page/editor-sidebar/extensions/frame.tsx @@ -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(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
; +}; + +export const frameExtension: EditorExtension = { + name: 'frame', + icon: , + Component: EditorOutline, +}; diff --git a/packages/frontend/core/src/pages/workspace/detail-page/editor-sidebar/extensions/index.tsx b/packages/frontend/core/src/pages/workspace/detail-page/editor-sidebar/extensions/index.tsx new file mode 100644 index 000000000..4509f5ccb --- /dev/null +++ b/packages/frontend/core/src/pages/workspace/detail-page/editor-sidebar/extensions/index.tsx @@ -0,0 +1 @@ +export * from './extensions'; diff --git a/packages/frontend/core/src/pages/workspace/detail-page/editor-sidebar/extensions/outline.css.ts b/packages/frontend/core/src/pages/workspace/detail-page/editor-sidebar/extensions/outline.css.ts new file mode 100644 index 000000000..0a2dbb442 --- /dev/null +++ b/packages/frontend/core/src/pages/workspace/detail-page/editor-sidebar/extensions/outline.css.ts @@ -0,0 +1,7 @@ +import { style } from '@vanilla-extract/css'; + +export const root = style({ + display: 'flex', + height: '100%', + width: '100%', +}); diff --git a/packages/frontend/core/src/pages/workspace/detail-page/editor-sidebar/extensions/outline.tsx b/packages/frontend/core/src/pages/workspace/detail-page/editor-sidebar/extensions/outline.tsx new file mode 100644 index 000000000..eaa2f3cac --- /dev/null +++ b/packages/frontend/core/src/pages/workspace/detail-page/editor-sidebar/extensions/outline.tsx @@ -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(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
; +}; + +export const outlineExtension: EditorExtension = { + name: 'outline', + icon: , + Component: EditorOutline, +}; diff --git a/packages/frontend/core/src/pages/workspace/detail-page/editor-sidebar/index.ts b/packages/frontend/core/src/pages/workspace/detail-page/editor-sidebar/index.ts new file mode 100644 index 000000000..3ee1e3b1b --- /dev/null +++ b/packages/frontend/core/src/pages/workspace/detail-page/editor-sidebar/index.ts @@ -0,0 +1,4 @@ +export * from './atoms'; +export * from './editor-sidebar'; +export * from './extensions'; +export * from './types'; diff --git a/packages/frontend/core/src/pages/workspace/detail-page/editor-sidebar/types.ts b/packages/frontend/core/src/pages/workspace/detail-page/editor-sidebar/types.ts new file mode 100644 index 000000000..ab7ab4d04 --- /dev/null +++ b/packages/frontend/core/src/pages/workspace/detail-page/editor-sidebar/types.ts @@ -0,0 +1,7 @@ +export type EditorExtensionName = 'outline' | 'frame'; + +export interface EditorExtension { + name: EditorExtensionName; + icon: React.ReactNode; + Component: React.ComponentType; +} diff --git a/packages/frontend/core/src/pages/workspace/detail-page/index.ts b/packages/frontend/core/src/pages/workspace/detail-page/index.ts new file mode 100644 index 000000000..dbf539bf2 --- /dev/null +++ b/packages/frontend/core/src/pages/workspace/detail-page/index.ts @@ -0,0 +1 @@ +export * from './detail-page'; diff --git a/packages/frontend/core/src/components/workspace-header.css.ts b/packages/frontend/core/src/pages/workspace/trash-page.css.ts similarity index 85% rename from packages/frontend/core/src/components/workspace-header.css.ts rename to packages/frontend/core/src/pages/workspace/trash-page.css.ts index 6b483a609..ec4d58ba7 100644 --- a/packages/frontend/core/src/components/workspace-header.css.ts +++ b/packages/frontend/core/src/pages/workspace/trash-page.css.ts @@ -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', diff --git a/packages/frontend/core/src/pages/workspace/trash-page.tsx b/packages/frontend/core/src/pages/workspace/trash-page.tsx index 26345cc5a..82f2a87f2 100644 --- a/packages/frontend/core/src/pages/workspace/trash-page.tsx +++ b/packages/frontend/core/src/pages/workspace/trash-page.tsx @@ -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 ( +
+ + {t['com.affine.workspaceSubPath.trash']()} +
+ } + right={ + isWindowsDesktop ? ( +
+ +
+ ) : null + } + /> + ); +}; export const TrashPage = () => { const [currentWorkspace] = useCurrentWorkspace(); @@ -60,12 +83,7 @@ export const TrashPage = () => { ); return (
- + {filteredPageMetas.length > 0 ? ( 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', diff --git a/packages/frontend/core/src/utils/toast.ts b/packages/frontend/core/src/utils/toast.ts index 4e951259c..b65e99d96 100644 --- a/packages/frontend/core/src/utils/toast.ts +++ b/packages/frontend/core/src/utils/toast.ts @@ -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, }); }; diff --git a/packages/frontend/hooks/src/use-is-tiny-screen.ts b/packages/frontend/hooks/src/use-is-tiny-screen.ts index 506416a98..e87971da7 100644 --- a/packages/frontend/hooks/src/use-is-tiny-screen.ts +++ b/packages/frontend/hooks/src/use-is-tiny-screen.ts @@ -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; leftSlot: RefObject[]; centerDom: RefObject; - rightStatic: RefObject; rightSlot: RefObject[]; }) { 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; } diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json index 70ede531f..32aa077c2 100644 --- a/packages/frontend/i18n/src/resources/en.json +++ b/packages/frontend/i18n/src/resources/en.json @@ -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", diff --git a/tests/affine-desktop/e2e/basic.spec.ts b/tests/affine-desktop/e2e/basic.spec.ts index 5ff31caf8..1089934ac 100644 --- a/tests/affine-desktop/e2e/basic.spec.ts +++ b/tests/affine-desktop/e2e/basic.spec.ts @@ -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(); } });