mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-17 18:16:15 +08:00
feat(component): new right sidebar (#5169)
Refactor AFFiNE layout to support new right sidebar. The new layout:  **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:
@@ -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);
|
||||
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+1
-1
@@ -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>
|
||||
);
|
||||
};
|
||||
+2
-2
@@ -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,44 +0,0 @@
|
||||
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';
|
||||
|
||||
export function ShareHeader({
|
||||
workspace,
|
||||
pageId,
|
||||
publishMode,
|
||||
}: {
|
||||
workspace: Workspace;
|
||||
pageId: string;
|
||||
publishMode: PageMode;
|
||||
}) {
|
||||
const setAppHeader = useSetAtom(appHeaderAtom);
|
||||
|
||||
const currentWorkspace = useWorkspace(workspace.id);
|
||||
|
||||
return (
|
||||
<Header
|
||||
mainContainerAtom={mainContainerAtom}
|
||||
ref={setAppHeader}
|
||||
left={<ShareHeaderLeftItem />}
|
||||
center={
|
||||
<BlockSuiteHeaderTitle
|
||||
workspace={currentWorkspace}
|
||||
pageId={pageId}
|
||||
isPublic={true}
|
||||
publicMode={publishMode}
|
||||
/>
|
||||
}
|
||||
right={
|
||||
<ShareHeaderRightItem workspaceId={workspace.id} pageId={pageId} />
|
||||
}
|
||||
bottomBorder
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const trashTitle = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
padding: '0 8px',
|
||||
fontWeight: 600,
|
||||
});
|
||||
|
||||
export const trashIcon = style({
|
||||
color: 'var(--affine-icon-color)',
|
||||
fontSize: 'var(--affine-font-h-5)',
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user