mirror of
https://github.com/toeverything/AFFiNE.git
synced 2024-12-29 04:13:52 +03:00
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:
parent
980831f9f1
commit
5352736eba
21
packages/common/env/src/workspace.ts
vendored
21
packages/common/env/src/workspace.ts
vendored
@ -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>;
|
||||
|
@ -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',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
@ -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 => {
|
||||
|
@ -6,6 +6,7 @@ export const button = style({
|
||||
alignItems: 'center',
|
||||
userSelect: 'none',
|
||||
touchAction: 'manipulation',
|
||||
flexShrink: 0,
|
||||
outline: '0',
|
||||
border: '1px solid',
|
||||
padding: '0 18px',
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
|
@ -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');
|
||||
},
|
||||
|
@ -13,9 +13,3 @@ export const NewWorkspaceSettingDetail = lazy(() =>
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
export const PageDetailEditor = lazy(() =>
|
||||
import('../components/page-detail-editor').then(({ PageDetailEditor }) => ({
|
||||
default: PageDetailEditor,
|
||||
}))
|
||||
);
|
||||
|
@ -1,5 +0,0 @@
|
||||
import { atom } from 'jotai/vanilla';
|
||||
|
||||
export const appHeaderAtom = atom<HTMLDivElement | null>(null);
|
||||
|
||||
export const mainContainerAtom = atom<HTMLDivElement | null>(null);
|
@ -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]
|
||||
);
|
||||
}
|
||||
});
|
@ -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';
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
@ -0,0 +1,11 @@
|
||||
import { ToolContainer } from '@affine/component/workspace';
|
||||
|
||||
import { HelpIsland } from '../../pure/help-island';
|
||||
|
||||
export const HubIsland = () => {
|
||||
return (
|
||||
<ToolContainer>
|
||||
<HelpIsland />
|
||||
</ToolContainer>
|
||||
);
|
||||
};
|
@ -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);
|
||||
|
||||
|
@ -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
|
||||
|
@ -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',
|
||||
|
@ -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: {
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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} />;
|
||||
};
|
||||
|
@ -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',
|
||||
});
|
||||
|
@ -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 }}
|
||||
|
@ -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
|
||||
|
@ -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;
|
@ -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>
|
||||
);
|
||||
};
|
@ -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)',
|
@ -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();
|
||||
}
|
@ -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]);
|
||||
}
|
||||
|
12
packages/frontend/core/src/hooks/current/use-current-page.ts
Normal file
12
packages/frontend/core/src/hooks/current/use-current-page.ts
Normal 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;
|
||||
};
|
@ -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 />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -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;
|
@ -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
|
@ -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>
|
||||
);
|
||||
};
|
@ -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)',
|
||||
});
|
@ -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>
|
||||
);
|
||||
};
|
@ -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 => {
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
@ -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',
|
||||
});
|
@ -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 />
|
||||
);
|
||||
}
|
@ -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',
|
||||
},
|
||||
},
|
||||
});
|
@ -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>
|
||||
);
|
||||
};
|
@ -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 };
|
||||
});
|
||||
}
|
||||
);
|
@ -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',
|
||||
});
|
@ -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>;
|
||||
};
|
@ -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)',
|
||||
},
|
||||
},
|
||||
});
|
@ -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>
|
||||
);
|
||||
};
|
@ -0,0 +1,7 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const root = style({
|
||||
display: 'flex',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
});
|
@ -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,
|
||||
};
|
@ -0,0 +1 @@
|
||||
export * from './extensions';
|
@ -0,0 +1,7 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const root = style({
|
||||
display: 'flex',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
});
|
@ -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,
|
||||
};
|
@ -0,0 +1,4 @@
|
||||
export * from './atoms';
|
||||
export * from './editor-sidebar';
|
||||
export * from './extensions';
|
||||
export * from './types';
|
@ -0,0 +1,7 @@
|
||||
export type EditorExtensionName = 'outline' | 'frame';
|
||||
|
||||
export interface EditorExtension {
|
||||
name: EditorExtensionName;
|
||||
icon: React.ReactNode;
|
||||
Component: React.ComponentType;
|
||||
}
|
@ -0,0 +1 @@
|
||||
export * from './detail-page';
|
@ -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',
|
@ -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}
|
||||
|
@ -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',
|
||||
|
@ -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,
|
||||
});
|
||||
};
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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",
|
||||
|
@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user