mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-14 21:27:20 +00:00
feat(electron): multi tabs support (#7440)
use https://www.electronjs.org/docs/latest/api/web-contents-view to serve different tab views added tabs view manager in electron to handle multi-view actions and events. fix AF-1111 fix AF-999 fix PD-1459 fix AF-964 PD-1458
This commit is contained in:
@@ -168,7 +168,7 @@ export class ChatPanelMessages extends WithDisposable(ShadowlessElement) {
|
||||
|
||||
private _renderAIOnboarding() {
|
||||
return this.isLoading ||
|
||||
!this.host.doc.awarenessStore.getFlag('enable_ai_onboarding')
|
||||
!this.host?.doc.awarenessStore.getFlag('enable_ai_onboarding')
|
||||
? nothing
|
||||
: html`<div
|
||||
style=${styleMap({
|
||||
|
||||
@@ -98,7 +98,7 @@ export const tableHeaderTimestamp = style({
|
||||
|
||||
export const tableHeaderDivider = style({
|
||||
height: 0,
|
||||
borderTop: `1px solid ${cssVar('borderColor')}`,
|
||||
borderTop: `0.5px solid ${cssVar('borderColor')}`,
|
||||
width: '100%',
|
||||
margin: '8px 0',
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { globalStyle, style } from '@vanilla-extract/css';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
export const floatingMaxWidth = 768;
|
||||
export const navWrapperStyle = style({
|
||||
paddingBottom: '8px',
|
||||
@@ -11,7 +11,7 @@ export const navWrapperStyle = style({
|
||||
},
|
||||
selectors: {
|
||||
'&[data-has-border=true]': {
|
||||
borderRight: `1px solid ${cssVar('borderColor')}`,
|
||||
borderRight: `0.5px solid ${cssVar('borderColor')}`,
|
||||
},
|
||||
'&[data-is-floating="true"]': {
|
||||
backgroundColor: cssVar('backgroundPrimaryColor'),
|
||||
@@ -45,14 +45,6 @@ export const navHeaderStyle = style({
|
||||
['WebkitAppRegion' as string]: 'drag',
|
||||
});
|
||||
|
||||
globalStyle(
|
||||
`html[data-fullscreen="false"]
|
||||
${navHeaderStyle}[data-is-macos-electron="true"]`,
|
||||
{
|
||||
paddingLeft: '90px',
|
||||
}
|
||||
);
|
||||
|
||||
export const navBodyStyle = style({
|
||||
flex: '1 1 auto',
|
||||
height: 'calc(100% - 52px)',
|
||||
|
||||
@@ -2,7 +2,7 @@ import { atom } from 'jotai';
|
||||
import { atomWithStorage } from 'jotai/utils';
|
||||
|
||||
export const APP_SIDEBAR_OPEN = 'app-sidebar-open';
|
||||
export const isMobile = window.innerWidth < 768;
|
||||
export const isMobile = window.innerWidth < 768 && !environment.isDesktop;
|
||||
|
||||
export const appSidebarOpenAtom = atomWithStorage(APP_SIDEBAR_OPEN, !isMobile);
|
||||
export const appSidebarFloatingAtom = atom(isMobile);
|
||||
|
||||
@@ -41,7 +41,6 @@ const MIN_WIDTH = 248;
|
||||
export function AppSidebar({
|
||||
children,
|
||||
clientBorder,
|
||||
translucentUI,
|
||||
}: AppSidebarProps): ReactElement {
|
||||
const [open, setOpen] = useAtom(appSidebarOpenAtom);
|
||||
const [width, setWidth] = useAtom(appSidebarWidthAtom);
|
||||
@@ -49,6 +48,11 @@ export function AppSidebar({
|
||||
const [resizing, setResizing] = useAtom(appSidebarResizingAtom);
|
||||
|
||||
useEffect(() => {
|
||||
// do not float app sidebar on desktop
|
||||
if (environment.isDesktop) {
|
||||
return;
|
||||
}
|
||||
|
||||
function onResize() {
|
||||
const isFloatingMaxWidth = window.matchMedia(
|
||||
`(max-width: ${floatingMaxWidth}px)`
|
||||
@@ -75,10 +79,8 @@ export function AppSidebar({
|
||||
};
|
||||
}, [open, setFloating, setOpen, width]);
|
||||
|
||||
const hasRightBorder = !environment.isDesktop && !clientBorder;
|
||||
const isMacosDesktop = environment.isDesktop && environment.isMacOs;
|
||||
const hasRightBorder =
|
||||
!environment.isDesktop || (!clientBorder && !translucentUI);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ResizePanel
|
||||
@@ -96,12 +98,13 @@ export function AppSidebar({
|
||||
resizeHandleOffset={clientBorder ? 8 : 0}
|
||||
resizeHandleVerticalPadding={clientBorder ? 16 : 0}
|
||||
data-transparent
|
||||
data-open={open}
|
||||
data-has-border={hasRightBorder}
|
||||
data-testid="app-sidebar-wrapper"
|
||||
data-is-macos-electron={isMacosDesktop}
|
||||
>
|
||||
<nav className={navStyle} data-testid="app-sidebar">
|
||||
<SidebarHeader />
|
||||
{!environment.isDesktop && <SidebarHeader />}
|
||||
<div className={navBodyStyle} data-testid="sliderBar-inner">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useHasScrollTop } from '@affine/component';
|
||||
import * as ScrollArea from '@radix-ui/react-scroll-area';
|
||||
import clsx from 'clsx';
|
||||
import type { PropsWithChildren } from 'react';
|
||||
import { useRef } from 'react';
|
||||
import { type PropsWithChildren } from 'react';
|
||||
|
||||
import * as styles from './index.css';
|
||||
|
||||
@@ -11,8 +10,7 @@ export function SidebarContainer({ children }: PropsWithChildren) {
|
||||
}
|
||||
|
||||
export function SidebarScrollableContainer({ children }: PropsWithChildren) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const hasScrollTop = useHasScrollTop(ref);
|
||||
const [setContainer, hasScrollTop] = useHasScrollTop();
|
||||
return (
|
||||
<ScrollArea.Root className={styles.scrollableContainerRoot}>
|
||||
<div
|
||||
@@ -21,7 +19,7 @@ export function SidebarScrollableContainer({ children }: PropsWithChildren) {
|
||||
/>
|
||||
<ScrollArea.Viewport
|
||||
className={clsx([styles.scrollableViewport])}
|
||||
ref={ref}
|
||||
ref={setContainer}
|
||||
>
|
||||
<div className={clsx([styles.scrollableContainer])}>{children}</div>
|
||||
</ScrollArea.Viewport>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useAtomValue } from 'jotai';
|
||||
|
||||
import { NavigationButtons } from '../../../modules/navigation';
|
||||
import { navHeaderStyle } from '../index.css';
|
||||
import { appSidebarOpenAtom } from '../index.jotai';
|
||||
import { SidebarSwitch } from './sidebar-switch';
|
||||
@@ -9,13 +8,8 @@ export const SidebarHeader = () => {
|
||||
const open = useAtomValue(appSidebarOpenAtom);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={navHeaderStyle}
|
||||
data-open={open}
|
||||
data-is-macos-electron={environment.isDesktop && environment.isMacOs}
|
||||
>
|
||||
<div className={navHeaderStyle} data-open={open}>
|
||||
<SidebarSwitch show={open} />
|
||||
<NavigationButtons />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
} from 'react';
|
||||
|
||||
import { usePageHeaderColsDef } from './header-col-def';
|
||||
@@ -156,8 +155,7 @@ export const ListScrollContainer = forwardRef<
|
||||
HTMLDivElement,
|
||||
PropsWithChildren<ListScrollContainerProps>
|
||||
>(({ className, children, style }, ref) => {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const hasScrollTop = useHasScrollTop(containerRef);
|
||||
const [setContainer, hasScrollTop] = useHasScrollTop();
|
||||
|
||||
const setNodeRef = useCallback(
|
||||
(r: HTMLDivElement) => {
|
||||
@@ -168,9 +166,9 @@ export const ListScrollContainer = forwardRef<
|
||||
ref.current = r;
|
||||
}
|
||||
}
|
||||
containerRef.current = r;
|
||||
return setContainer(r);
|
||||
},
|
||||
[ref]
|
||||
[ref, setContainer]
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -19,7 +19,7 @@ export const tableHeader = style({
|
||||
transform: 'translateY(-0.5px)', // fix sticky look through issue
|
||||
});
|
||||
globalStyle(`[data-has-scroll-top=true] ${tableHeader}`, {
|
||||
boxShadow: `0 1px ${cssVar('borderColor')}`,
|
||||
boxShadow: `0 0.5px ${cssVar('borderColor')}`,
|
||||
});
|
||||
export const headerTitleSelectionIconWrapper = style({
|
||||
display: 'flex',
|
||||
|
||||
@@ -56,6 +56,7 @@ export const headerSideContainer = style({
|
||||
|
||||
export const windowAppControlsWrapper = style({
|
||||
display: 'flex',
|
||||
flexShrink: 0,
|
||||
});
|
||||
|
||||
export const windowAppControl = style({
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { openSettingModalAtom } from '@affine/core/atoms';
|
||||
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
|
||||
import { useNavigateHelper } from '@affine/core/hooks/use-navigate-helper';
|
||||
import {
|
||||
ExplorerCollections,
|
||||
ExplorerFavorites,
|
||||
@@ -7,17 +9,19 @@ import {
|
||||
ExplorerOrganize,
|
||||
} from '@affine/core/modules/explorer';
|
||||
import { ExplorerTags } from '@affine/core/modules/explorer/views/sections/tags';
|
||||
import { CMDKQuickSearchService } from '@affine/core/modules/quicksearch/services/cmdk';
|
||||
import { TelemetryWorkspaceContextService } from '@affine/core/modules/telemetry/services/telemetry';
|
||||
import { pathGenerator } from '@affine/core/shared';
|
||||
import { mixpanel } from '@affine/core/utils';
|
||||
import { apis, events } from '@affine/electron-api';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { FolderIcon, SettingsIcon } from '@blocksuite/icons/rc';
|
||||
import type { Doc } from '@blocksuite/store';
|
||||
import type { Workspace } from '@toeverything/infra';
|
||||
import { useLiveData, useService } from '@toeverything/infra';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { useLiveData, useService, WorkspaceService } from '@toeverything/infra';
|
||||
import { useAtomValue, useSetAtom } from 'jotai';
|
||||
import type { ReactElement } from 'react';
|
||||
import { memo, useEffect } from 'react';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
|
||||
import { useAppSettingHelper } from '../../hooks/affine/use-app-setting-helper';
|
||||
import { WorkbenchService } from '../../modules/workbench';
|
||||
@@ -33,6 +37,7 @@ import {
|
||||
SidebarContainer,
|
||||
SidebarScrollableContainer,
|
||||
} from '../app-sidebar';
|
||||
import { usePageHelper } from '../blocksuite/block-suite-page-list/utils';
|
||||
import { WorkspaceSelector } from '../workspace-selector';
|
||||
import ImportPage from './import-page';
|
||||
import {
|
||||
@@ -64,127 +69,147 @@ export type RootAppSidebarProps = {
|
||||
* This is for the whole affine app sidebar.
|
||||
* This component wraps the app sidebar in `@affine/component` with logic and data.
|
||||
*
|
||||
* @todo(himself65): rewrite all styled component into @vanilla-extract/css
|
||||
*/
|
||||
export const RootAppSidebar = memo(
|
||||
({
|
||||
currentWorkspace,
|
||||
openPage,
|
||||
createPage,
|
||||
paths,
|
||||
onOpenQuickSearchModal,
|
||||
onOpenSettingModal,
|
||||
}: RootAppSidebarProps): ReactElement => {
|
||||
const currentWorkspaceId = currentWorkspace.id;
|
||||
const { appSettings } = useAppSettingHelper();
|
||||
const docCollection = currentWorkspace.docCollection;
|
||||
const t = useI18n();
|
||||
const currentPath = useLiveData(
|
||||
useService(WorkbenchService).workbench.location$.map(
|
||||
location => location.pathname
|
||||
)
|
||||
);
|
||||
export const RootAppSidebar = (): ReactElement => {
|
||||
const currentWorkspace = useService(WorkspaceService).workspace;
|
||||
const currentWorkspaceId = currentWorkspace.id;
|
||||
const { openPage } = useNavigateHelper();
|
||||
const { appSettings } = useAppSettingHelper();
|
||||
const docCollection = currentWorkspace.docCollection;
|
||||
const t = useI18n();
|
||||
const currentPath = useLiveData(
|
||||
useService(WorkbenchService).workbench.location$.map(
|
||||
location => location.pathname
|
||||
)
|
||||
);
|
||||
const cmdkQuickSearchService = useService(CMDKQuickSearchService);
|
||||
const onOpenQuickSearchModal = useCallback(() => {
|
||||
cmdkQuickSearchService.toggle();
|
||||
mixpanel.track('QuickSearchOpened', {
|
||||
segment: 'navigation panel',
|
||||
control: 'search button',
|
||||
});
|
||||
}, [cmdkQuickSearchService]);
|
||||
|
||||
const telemetry = useService(TelemetryWorkspaceContextService);
|
||||
const telemetry = useService(TelemetryWorkspaceContextService);
|
||||
|
||||
const allPageActive = currentPath === '/all';
|
||||
const allPageActive = currentPath === '/all';
|
||||
|
||||
const onClickNewPage = useAsyncCallback(async () => {
|
||||
const page = createPage();
|
||||
page.load();
|
||||
openPage(page.id);
|
||||
mixpanel.track('DocCreated', {
|
||||
page: telemetry.getPageContext(),
|
||||
segment: 'navigation panel',
|
||||
module: 'bottom button',
|
||||
control: 'new doc button',
|
||||
category: 'page',
|
||||
type: 'doc',
|
||||
const pageHelper = usePageHelper(currentWorkspace.docCollection);
|
||||
const createPage = useCallback(() => {
|
||||
return pageHelper.createPage();
|
||||
}, [pageHelper]);
|
||||
|
||||
const onClickNewPage = useAsyncCallback(async () => {
|
||||
const page = createPage();
|
||||
page.load();
|
||||
openPage(currentWorkspaceId, page.id);
|
||||
mixpanel.track('DocCreated', {
|
||||
page: telemetry.getPageContext(),
|
||||
segment: 'navigation panel',
|
||||
module: 'bottom button',
|
||||
control: 'new doc button',
|
||||
category: 'page',
|
||||
type: 'doc',
|
||||
});
|
||||
}, [createPage, currentWorkspaceId, openPage, telemetry]);
|
||||
|
||||
// Listen to the "New Page" action from the menu
|
||||
useEffect(() => {
|
||||
if (environment.isDesktop) {
|
||||
return events?.applicationMenu.onNewPageAction(onClickNewPage);
|
||||
}
|
||||
return;
|
||||
}, [onClickNewPage]);
|
||||
|
||||
const setOpenSettingModalAtom = useSetAtom(openSettingModalAtom);
|
||||
|
||||
const onOpenSettingModal = useCallback(() => {
|
||||
setOpenSettingModalAtom({
|
||||
activeTab: 'appearance',
|
||||
open: true,
|
||||
});
|
||||
mixpanel.track('SettingsViewed', {
|
||||
// page:
|
||||
segment: 'navigation panel',
|
||||
module: 'general list',
|
||||
control: 'settings button',
|
||||
});
|
||||
}, [setOpenSettingModalAtom]);
|
||||
|
||||
const sidebarOpen = useAtomValue(appSidebarOpenAtom);
|
||||
useEffect(() => {
|
||||
if (environment.isDesktop) {
|
||||
apis?.ui.handleSidebarVisibilityChange(sidebarOpen).catch(err => {
|
||||
console.error(err);
|
||||
});
|
||||
}, [createPage, openPage, telemetry]);
|
||||
}
|
||||
}, [sidebarOpen]);
|
||||
|
||||
// Listen to the "New Page" action from the menu
|
||||
useEffect(() => {
|
||||
if (environment.isDesktop) {
|
||||
return events?.applicationMenu.onNewPageAction(onClickNewPage);
|
||||
}
|
||||
return;
|
||||
}, [onClickNewPage]);
|
||||
|
||||
const sidebarOpen = useAtomValue(appSidebarOpenAtom);
|
||||
useEffect(() => {
|
||||
if (environment.isDesktop) {
|
||||
apis?.ui.handleSidebarVisibilityChange(sidebarOpen).catch(err => {
|
||||
console.error(err);
|
||||
});
|
||||
}
|
||||
}, [sidebarOpen]);
|
||||
|
||||
return (
|
||||
<AppSidebar
|
||||
clientBorder={appSettings.clientBorder}
|
||||
translucentUI={appSettings.enableBlurBackground}
|
||||
>
|
||||
<SidebarContainer>
|
||||
<div className={workspaceAndUserWrapper}>
|
||||
<div className={workspaceWrapper}>
|
||||
<WorkspaceSelector />
|
||||
</div>
|
||||
<UserInfo />
|
||||
return (
|
||||
<AppSidebar
|
||||
clientBorder={appSettings.clientBorder}
|
||||
translucentUI={appSettings.enableBlurBackground}
|
||||
>
|
||||
<SidebarContainer>
|
||||
<div className={workspaceAndUserWrapper}>
|
||||
<div className={workspaceWrapper}>
|
||||
<WorkspaceSelector />
|
||||
</div>
|
||||
<div className={quickSearchAndNewPage}>
|
||||
<QuickSearchInput
|
||||
className={quickSearch}
|
||||
data-testid="slider-bar-quick-search-button"
|
||||
onClick={onOpenQuickSearchModal}
|
||||
/>
|
||||
<AddPageButton onClick={onClickNewPage} />
|
||||
</div>
|
||||
<MenuLinkItem
|
||||
icon={<FolderIcon />}
|
||||
active={allPageActive}
|
||||
to={paths.all(currentWorkspaceId)}
|
||||
>
|
||||
<span data-testid="all-pages">
|
||||
{t['com.affine.workspaceSubPath.all']()}
|
||||
</span>
|
||||
</MenuLinkItem>
|
||||
<AppSidebarJournalButton
|
||||
docCollection={currentWorkspace.docCollection}
|
||||
<UserInfo />
|
||||
</div>
|
||||
<div className={quickSearchAndNewPage}>
|
||||
<QuickSearchInput
|
||||
className={quickSearch}
|
||||
data-testid="slider-bar-quick-search-button"
|
||||
onClick={onOpenQuickSearchModal}
|
||||
/>
|
||||
<MenuItem
|
||||
data-testid="slider-bar-workspace-setting-button"
|
||||
icon={<SettingsIcon />}
|
||||
onClick={onOpenSettingModal}
|
||||
>
|
||||
<span data-testid="settings-modal-trigger">
|
||||
{t['com.affine.settingSidebar.title']()}
|
||||
</span>
|
||||
</MenuItem>
|
||||
</SidebarContainer>
|
||||
<SidebarScrollableContainer>
|
||||
{runtimeConfig.enableNewFavorite && <ExplorerFavorites />}
|
||||
{runtimeConfig.enableOrganize && <ExplorerOrganize />}
|
||||
{runtimeConfig.enableNewFavorite && <ExplorerMigrationFavorites />}
|
||||
{runtimeConfig.enableOldFavorite && (
|
||||
<ExplorerOldFavorites defaultCollapsed />
|
||||
)}
|
||||
<ExplorerCollections defaultCollapsed />
|
||||
<ExplorerTags defaultCollapsed />
|
||||
<CategoryDivider label={t['com.affine.rootAppSidebar.others']()} />
|
||||
{/* fixme: remove the following spacer */}
|
||||
<div style={{ height: '4px' }} />
|
||||
<div style={{ padding: '0 8px' }}>
|
||||
<TrashButton />
|
||||
<ImportPage docCollection={docCollection} />
|
||||
</div>
|
||||
</SidebarScrollableContainer>
|
||||
<SidebarContainer>
|
||||
{environment.isDesktop ? <UpdaterButton /> : <AppDownloadButton />}
|
||||
</SidebarContainer>
|
||||
</AppSidebar>
|
||||
);
|
||||
}
|
||||
);
|
||||
<AddPageButton onClick={onClickNewPage} />
|
||||
</div>
|
||||
<MenuLinkItem
|
||||
icon={<FolderIcon />}
|
||||
active={allPageActive}
|
||||
to={pathGenerator.all(currentWorkspaceId)}
|
||||
>
|
||||
<span data-testid="all-pages">
|
||||
{t['com.affine.workspaceSubPath.all']()}
|
||||
</span>
|
||||
</MenuLinkItem>
|
||||
<AppSidebarJournalButton
|
||||
docCollection={currentWorkspace.docCollection}
|
||||
/>
|
||||
<MenuItem
|
||||
data-testid="slider-bar-workspace-setting-button"
|
||||
icon={<SettingsIcon />}
|
||||
onClick={onOpenSettingModal}
|
||||
>
|
||||
<span data-testid="settings-modal-trigger">
|
||||
{t['com.affine.settingSidebar.title']()}
|
||||
</span>
|
||||
</MenuItem>
|
||||
</SidebarContainer>
|
||||
<SidebarScrollableContainer>
|
||||
{runtimeConfig.enableNewFavorite && <ExplorerFavorites />}
|
||||
{runtimeConfig.enableOrganize && <ExplorerOrganize />}
|
||||
{runtimeConfig.enableNewFavorite && <ExplorerMigrationFavorites />}
|
||||
{runtimeConfig.enableOldFavorite && (
|
||||
<ExplorerOldFavorites defaultCollapsed />
|
||||
)}
|
||||
<ExplorerCollections defaultCollapsed />
|
||||
<ExplorerTags defaultCollapsed />
|
||||
<CategoryDivider label={t['com.affine.rootAppSidebar.others']()} />
|
||||
{/* fixme: remove the following spacer */}
|
||||
<div style={{ height: '4px' }} />
|
||||
<div style={{ padding: '0 8px' }}>
|
||||
<TrashButton />
|
||||
<ImportPage docCollection={docCollection} />
|
||||
</div>
|
||||
</SidebarScrollableContainer>
|
||||
<SidebarContainer>
|
||||
{environment.isDesktop ? <UpdaterButton /> : <AppDownloadButton />}
|
||||
</SidebarContainer>
|
||||
</AppSidebar>
|
||||
);
|
||||
};
|
||||
|
||||
RootAppSidebar.displayName = 'memo(RootAppSidebar)';
|
||||
|
||||
@@ -4,9 +4,8 @@ export const appStyle = style({
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
height: '100vh',
|
||||
display: 'flex',
|
||||
flexGrow: '1',
|
||||
flexDirection: 'row',
|
||||
display: 'flex',
|
||||
backgroundColor: cssVar('backgroundPrimaryColor'),
|
||||
selectors: {
|
||||
'&[data-is-resizing="true"]': {
|
||||
@@ -42,11 +41,11 @@ globalStyle(`html[data-theme="dark"] ${appStyle}`, {
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const mainContainerStyle = style({
|
||||
position: 'relative',
|
||||
zIndex: 0,
|
||||
// it will create stacking context to limit layer of child elements and be lower than after auto zIndex
|
||||
width: 0,
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flex: 1,
|
||||
overflow: 'clip',
|
||||
@@ -71,14 +70,16 @@ export const mainContainerStyle = style({
|
||||
'&[data-client-border="true"][data-side-bar-open="true"]': {
|
||||
marginLeft: 0,
|
||||
},
|
||||
'&[data-client-border="true"]:before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
height: '8px',
|
||||
width: '100%',
|
||||
top: '-8px',
|
||||
left: 0,
|
||||
['WebkitAppRegion' as string]: 'drag',
|
||||
'&[data-client-border="true"][data-is-desktop="true"]': {
|
||||
marginTop: 0,
|
||||
},
|
||||
'&[data-client-border="false"][data-is-desktop="true"][data-side-bar-open="true"]':
|
||||
{
|
||||
borderTopLeftRadius: 6,
|
||||
},
|
||||
'&[data-client-border="false"][data-is-desktop="true"]': {
|
||||
borderTop: `0.5px solid ${cssVar('borderColor')}`,
|
||||
borderLeft: `0.5px solid ${cssVar('borderColor')}`,
|
||||
},
|
||||
'&[data-transparent=true]': {
|
||||
backgroundColor: 'transparent',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useAppSettingHelper } from '@affine/core/hooks/affine/use-app-setting-helper';
|
||||
import {
|
||||
DocsService,
|
||||
GlobalContextService,
|
||||
@@ -26,45 +27,44 @@ export const AppContainer = ({
|
||||
...rest
|
||||
}: WorkspaceRootProps) => {
|
||||
const noisyBackground = useNoisyBackground && environment.isDesktop;
|
||||
const blurBackground = environment.isDesktop && useBlurBackground;
|
||||
return (
|
||||
<div
|
||||
{...rest}
|
||||
className={clsx(appStyle, {
|
||||
'noisy-background': noisyBackground,
|
||||
'blur-background': environment.isDesktop && useBlurBackground,
|
||||
'blur-background': blurBackground,
|
||||
})}
|
||||
data-noise-background={noisyBackground}
|
||||
data-is-resizing={resizing}
|
||||
data-blur-background={blurBackground}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export interface MainContainerProps extends HTMLAttributes<HTMLDivElement> {
|
||||
clientBorder?: boolean;
|
||||
}
|
||||
export interface MainContainerProps extends HTMLAttributes<HTMLDivElement> {}
|
||||
|
||||
export const MainContainer = forwardRef<
|
||||
HTMLDivElement,
|
||||
PropsWithChildren<MainContainerProps>
|
||||
>(function MainContainer(
|
||||
{ className, children, clientBorder, ...props },
|
||||
ref
|
||||
): ReactElement {
|
||||
>(function MainContainer({ className, children, ...props }, ref): ReactElement {
|
||||
const appSideBarOpen = useAtomValue(appSidebarOpenAtom);
|
||||
const { appSettings } = useAppSettingHelper();
|
||||
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className={clsx(mainContainerStyle, className)}
|
||||
data-is-macos={environment.isDesktop && environment.isMacOs}
|
||||
data-is-desktop={environment.isDesktop}
|
||||
data-transparent={false}
|
||||
data-client-border={clientBorder}
|
||||
data-client-border={appSettings.clientBorder}
|
||||
data-side-bar-open={appSideBarOpen}
|
||||
data-testid="main-container"
|
||||
ref={ref}
|
||||
>
|
||||
{children}
|
||||
<div className={mainContainerStyle}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -10,9 +10,8 @@ export enum RouteLogic {
|
||||
}
|
||||
|
||||
function defaultNavigate(to: To, option?: { replace?: boolean }) {
|
||||
console.log(to, option);
|
||||
setTimeout(() => {
|
||||
router.navigate(to, option).catch(err => {
|
||||
router?.navigate(to, option).catch(err => {
|
||||
console.error('Failed to navigate', err);
|
||||
});
|
||||
}, 100);
|
||||
|
||||
45
packages/frontend/core/src/layouts/styles.css.ts
Normal file
45
packages/frontend/core/src/layouts/styles.css.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const browserAppViewContainer = style({
|
||||
display: 'flex',
|
||||
flexFlow: 'row',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
});
|
||||
|
||||
export const desktopAppViewContainer = style({
|
||||
display: 'flex',
|
||||
flexFlow: 'column',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
});
|
||||
|
||||
export const desktopAppViewMain = style({
|
||||
display: 'flex',
|
||||
flexFlow: 'row',
|
||||
width: '100%',
|
||||
height: 'calc(100% - 52px)',
|
||||
position: 'relative',
|
||||
});
|
||||
|
||||
export const desktopTabsHeader = style({
|
||||
display: 'flex',
|
||||
flexFlow: 'row',
|
||||
height: '52px',
|
||||
zIndex: 1,
|
||||
width: '100%',
|
||||
overflow: 'hidden',
|
||||
});
|
||||
|
||||
export const desktopTabsHeaderTopLeft = style({
|
||||
display: 'flex',
|
||||
flexFlow: 'row',
|
||||
alignItems: 'center',
|
||||
transition: 'width 0.3s, padding 0.3s',
|
||||
justifyContent: 'space-between',
|
||||
marginRight: -8, // make room for tab's padding
|
||||
padding: '0 16px',
|
||||
flexShrink: 0,
|
||||
['WebkitAppRegion' as string]: 'drag',
|
||||
});
|
||||
@@ -5,12 +5,12 @@ import {
|
||||
} from '@affine/component/global-loading';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { ZipTransformer } from '@blocksuite/blocks';
|
||||
import { assertExists } from '@blocksuite/global/utils';
|
||||
import {
|
||||
type DocMode,
|
||||
DocsService,
|
||||
effect,
|
||||
fromPromise,
|
||||
LiveData,
|
||||
onStart,
|
||||
throwIfAborted,
|
||||
useLiveData,
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
} from '@toeverything/infra';
|
||||
import { useAtomValue, useSetAtom } from 'jotai';
|
||||
import type { PropsWithChildren } from 'react';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import {
|
||||
catchError,
|
||||
EMPTY,
|
||||
@@ -30,33 +30,35 @@ import {
|
||||
} from 'rxjs';
|
||||
import { Map as YMap } from 'yjs';
|
||||
|
||||
import { openSettingModalAtom } from '../atoms';
|
||||
import { AIProvider } from '../blocksuite/presets/ai';
|
||||
import { WorkspaceAIOnboarding } from '../components/affine/ai-onboarding';
|
||||
import { AppContainer } from '../components/affine/app-container';
|
||||
import { SyncAwareness } from '../components/affine/awareness';
|
||||
import { appSidebarResizingAtom } from '../components/app-sidebar';
|
||||
import { usePageHelper } from '../components/blocksuite/block-suite-page-list/utils';
|
||||
import {
|
||||
appSidebarFloatingAtom,
|
||||
appSidebarOpenAtom,
|
||||
appSidebarResizingAtom,
|
||||
SidebarSwitch,
|
||||
} from '../components/app-sidebar';
|
||||
import { appSidebarWidthAtom } from '../components/app-sidebar/index.jotai';
|
||||
import { AIIsland } from '../components/pure/ai-island';
|
||||
import { RootAppSidebar } from '../components/root-app-sidebar';
|
||||
import { MainContainer } from '../components/workspace';
|
||||
import { WorkspaceUpgrade } from '../components/workspace-upgrade';
|
||||
import { useAppSettingHelper } from '../hooks/affine/use-app-setting-helper';
|
||||
import { useRegisterFindInPageCommands } from '../hooks/affine/use-register-find-in-page-commands';
|
||||
import { useSubscriptionNotifyReader } from '../hooks/affine/use-subscription-notify';
|
||||
import { useNavigateHelper } from '../hooks/use-navigate-helper';
|
||||
import { useRegisterWorkspaceCommands } from '../hooks/use-register-workspace-commands';
|
||||
import { AppTabsHeader } from '../modules/app-tabs-header';
|
||||
import { NavigationButtons } from '../modules/navigation';
|
||||
import { useRegisterNavigationCommands } from '../modules/navigation/view/use-register-navigation-commands';
|
||||
import { QuickSearchContainer } from '../modules/quicksearch';
|
||||
import { CMDKQuickSearchService } from '../modules/quicksearch/services/cmdk';
|
||||
import { WorkbenchService } from '../modules/workbench';
|
||||
import {
|
||||
AllWorkspaceModals,
|
||||
CurrentWorkspaceModals,
|
||||
} from '../providers/modal-provider';
|
||||
import { SWRConfigProvider } from '../providers/swr-config-provider';
|
||||
import { pathGenerator } from '../shared';
|
||||
import { mixpanel } from '../utils';
|
||||
import * as styles from './styles.css';
|
||||
|
||||
export const WorkspaceLayout = function WorkspaceLayout({
|
||||
children,
|
||||
@@ -74,26 +76,14 @@ export const WorkspaceLayout = function WorkspaceLayout({
|
||||
);
|
||||
};
|
||||
|
||||
export const WorkspaceLayoutInner = ({ children }: PropsWithChildren) => {
|
||||
const WorkspaceLayoutProviders = ({ children }: PropsWithChildren) => {
|
||||
const t = useI18n();
|
||||
const pushGlobalLoadingEvent = useSetAtom(pushGlobalLoadingEventAtom);
|
||||
const resolveGlobalLoadingEvent = useSetAtom(resolveGlobalLoadingEventAtom);
|
||||
const currentWorkspace = useService(WorkspaceService).workspace;
|
||||
const docsList = useService(DocsService).list;
|
||||
const { openPage } = useNavigateHelper();
|
||||
const pageHelper = usePageHelper(currentWorkspace.docCollection);
|
||||
|
||||
const upgrading = useLiveData(currentWorkspace.upgrade.upgrading$);
|
||||
const needUpgrade = useLiveData(currentWorkspace.upgrade.needUpgrade$);
|
||||
|
||||
const workbench = useService(WorkbenchService).workbench;
|
||||
|
||||
const basename = useLiveData(workbench.basename$);
|
||||
|
||||
const currentPath = useLiveData(
|
||||
workbench.location$.map(location => basename + location.pathname)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const insertTemplate = effect(
|
||||
switchMap(({ template, mode }: { template: string; mode: string }) => {
|
||||
@@ -180,64 +170,89 @@ export const WorkspaceLayoutInner = ({ children }: PropsWithChildren) => {
|
||||
}
|
||||
}, [currentWorkspace.docCollection.doc]);
|
||||
|
||||
const handleCreatePage = useCallback(() => {
|
||||
return pageHelper.createPage();
|
||||
}, [pageHelper]);
|
||||
|
||||
const cmdkQuickSearchService = useService(CMDKQuickSearchService);
|
||||
const handleOpenQuickSearchModal = useCallback(() => {
|
||||
cmdkQuickSearchService.toggle();
|
||||
mixpanel.track('QuickSearchOpened', {
|
||||
segment: 'navigation panel',
|
||||
control: 'search button',
|
||||
});
|
||||
}, [cmdkQuickSearchService]);
|
||||
|
||||
const setOpenSettingModalAtom = useSetAtom(openSettingModalAtom);
|
||||
|
||||
const handleOpenSettingModal = useCallback(() => {
|
||||
setOpenSettingModalAtom({
|
||||
activeTab: 'appearance',
|
||||
open: true,
|
||||
});
|
||||
|
||||
mixpanel.track('SettingsViewed', {
|
||||
// page:
|
||||
segment: 'navigation panel',
|
||||
module: 'general list',
|
||||
control: 'settings button',
|
||||
});
|
||||
}, [setOpenSettingModalAtom]);
|
||||
|
||||
const resizing = useAtomValue(appSidebarResizingAtom);
|
||||
|
||||
const { appSettings } = useAppSettingHelper();
|
||||
|
||||
return (
|
||||
<>
|
||||
<AppContainer data-current-path={currentPath} resizing={resizing}>
|
||||
<RootAppSidebar
|
||||
isPublicWorkspace={false}
|
||||
onOpenQuickSearchModal={handleOpenQuickSearchModal}
|
||||
onOpenSettingModal={handleOpenSettingModal}
|
||||
currentWorkspace={currentWorkspace}
|
||||
openPage={useCallback(
|
||||
(pageId: string) => {
|
||||
assertExists(currentWorkspace);
|
||||
return openPage(currentWorkspace.id, pageId);
|
||||
},
|
||||
[currentWorkspace, openPage]
|
||||
)}
|
||||
createPage={handleCreatePage}
|
||||
paths={pathGenerator}
|
||||
/>
|
||||
|
||||
<MainContainer clientBorder={appSettings.clientBorder}>
|
||||
{needUpgrade || upgrading ? <WorkspaceUpgrade /> : children}
|
||||
</MainContainer>
|
||||
</AppContainer>
|
||||
{/* This DndContext is used for drag page from all-pages list into a folder in sidebar */}
|
||||
{children}
|
||||
<QuickSearchContainer />
|
||||
<SyncAwareness />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const DesktopLayout = ({ children }: PropsWithChildren) => {
|
||||
const resizing = useAtomValue(appSidebarResizingAtom);
|
||||
const sidebarWidth = useAtomValue(appSidebarWidthAtom);
|
||||
const sidebarOpen = useAtomValue(appSidebarOpenAtom);
|
||||
const sidebarFloating = useAtomValue(appSidebarFloatingAtom);
|
||||
const sidebarResizing = useAtomValue(appSidebarResizingAtom);
|
||||
const isMacosDesktop = environment.isDesktop && environment.isMacOs;
|
||||
|
||||
return (
|
||||
<div className={styles.desktopAppViewContainer}>
|
||||
<div className={styles.desktopTabsHeader}>
|
||||
<div
|
||||
className={styles.desktopTabsHeaderTopLeft}
|
||||
style={{
|
||||
transition: sidebarResizing ? 'none' : undefined,
|
||||
paddingLeft:
|
||||
isMacosDesktop && sidebarOpen && !sidebarFloating ? 90 : 16,
|
||||
width: sidebarOpen && !sidebarFloating ? sidebarWidth : 130,
|
||||
}}
|
||||
>
|
||||
<SidebarSwitch show />
|
||||
<NavigationButtons />
|
||||
</div>
|
||||
<AppTabsHeader reportBoundingUpdate={!resizing} />
|
||||
</div>
|
||||
<div className={styles.desktopAppViewMain}>
|
||||
<RootAppSidebar />
|
||||
<MainContainer>{children}</MainContainer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const BrowserLayout = ({ children }: PropsWithChildren) => {
|
||||
return (
|
||||
<div className={styles.browserAppViewContainer}>
|
||||
<RootAppSidebar />
|
||||
<MainContainer>{children}</MainContainer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Wraps the workspace layout main router view
|
||||
*/
|
||||
const WorkspaceLayoutUIContainer = ({ children }: PropsWithChildren) => {
|
||||
const workbench = useService(WorkbenchService).workbench;
|
||||
const currentPath = useLiveData(
|
||||
LiveData.computed(get => {
|
||||
return get(workbench.basename$) + get(workbench.location$).pathname;
|
||||
})
|
||||
);
|
||||
|
||||
const resizing = useAtomValue(appSidebarResizingAtom);
|
||||
const LayoutComponent = environment.isDesktop ? DesktopLayout : BrowserLayout;
|
||||
|
||||
return (
|
||||
<AppContainer data-current-path={currentPath} resizing={resizing}>
|
||||
<LayoutComponent>{children}</LayoutComponent>
|
||||
</AppContainer>
|
||||
);
|
||||
};
|
||||
export const WorkspaceLayoutInner = ({ children }: PropsWithChildren) => {
|
||||
const currentWorkspace = useService(WorkspaceService).workspace;
|
||||
|
||||
const upgrading = useLiveData(currentWorkspace.upgrade.upgrading$);
|
||||
const needUpgrade = useLiveData(currentWorkspace.upgrade.needUpgrade$);
|
||||
|
||||
return (
|
||||
<WorkspaceLayoutProviders>
|
||||
<WorkspaceLayoutUIContainer>
|
||||
{needUpgrade || upgrading ? <WorkspaceUpgrade /> : children}
|
||||
</WorkspaceLayoutUIContainer>
|
||||
</WorkspaceLayoutProviders>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { type Framework } from '@toeverything/infra';
|
||||
|
||||
import { AppTabsHeaderService } from './services/app-tabs-header-service';
|
||||
|
||||
export { AppTabsHeader } from './views/app-tabs-header';
|
||||
|
||||
export function configureAppTabsHeaderModule(framework: Framework) {
|
||||
framework.service(AppTabsHeaderService);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { apis, events } from '@affine/electron-api';
|
||||
import { LiveData, Service } from '@toeverything/infra';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
export type TabStatus = Parameters<
|
||||
Parameters<NonNullable<typeof events>['ui']['onTabsStatusChange']>[0]
|
||||
>[0][number];
|
||||
|
||||
export class AppTabsHeaderService extends Service {
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
tabsStatus$ = LiveData.from<TabStatus[]>(
|
||||
new Observable(subscriber => {
|
||||
let unsub: (() => void) | undefined;
|
||||
apis?.ui
|
||||
.getTabsStatus()
|
||||
.then(tabs => {
|
||||
subscriber.next(tabs);
|
||||
unsub = events?.ui.onTabsStatusChange(tabs => {
|
||||
subscriber.next(tabs);
|
||||
});
|
||||
})
|
||||
.catch(console.error);
|
||||
|
||||
return () => {
|
||||
unsub?.();
|
||||
};
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
showContextMenu = async (workbenchId: string, viewIdx: number) => {
|
||||
await apis?.ui.showTabContextMenu(workbenchId, viewIdx);
|
||||
};
|
||||
|
||||
activateView = async (workbenchId: string, viewIdx: number) => {
|
||||
await apis?.ui.activateView(workbenchId, viewIdx);
|
||||
};
|
||||
|
||||
closeTab = async (workbenchId: string) => {
|
||||
await apis?.ui.closeTab(workbenchId);
|
||||
};
|
||||
|
||||
onAddTab = async () => {
|
||||
await apis?.ui.addTab();
|
||||
};
|
||||
|
||||
onToggleRightSidebar = async () => {
|
||||
await apis?.ui.toggleRightSidebar();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
import { IconButton, Loading, observeResize } from '@affine/component';
|
||||
import { WindowsAppControls } from '@affine/core/components/pure/header/windows-app-controls';
|
||||
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
|
||||
import { DesktopStateSynchronizer } from '@affine/core/modules/workbench/services/desktop-state-synchronizer';
|
||||
import type { WorkbenchMeta } from '@affine/electron-api';
|
||||
import { apis } from '@affine/electron-api';
|
||||
import {
|
||||
CloseIcon,
|
||||
DeleteIcon,
|
||||
EdgelessIcon,
|
||||
FolderIcon,
|
||||
PageIcon,
|
||||
PlusIcon,
|
||||
RightSidebarIcon,
|
||||
TagIcon,
|
||||
TodayIcon,
|
||||
ViewLayersIcon,
|
||||
} from '@blocksuite/icons/rc';
|
||||
import {
|
||||
useLiveData,
|
||||
useService,
|
||||
useServiceOptional,
|
||||
} from '@toeverything/infra';
|
||||
import { debounce, partition } from 'lodash-es';
|
||||
import {
|
||||
Fragment,
|
||||
type MouseEventHandler,
|
||||
type ReactNode,
|
||||
useEffect,
|
||||
useRef,
|
||||
} from 'react';
|
||||
|
||||
import {
|
||||
AppTabsHeaderService,
|
||||
type TabStatus,
|
||||
} from '../services/app-tabs-header-service';
|
||||
import * as styles from './styles.css';
|
||||
|
||||
type ModuleName = NonNullable<WorkbenchMeta['views'][0]['moduleName']>;
|
||||
|
||||
const moduleNameToIcon = {
|
||||
all: <FolderIcon />,
|
||||
collection: <ViewLayersIcon />,
|
||||
doc: <PageIcon />,
|
||||
page: <PageIcon />,
|
||||
edgeless: <EdgelessIcon />,
|
||||
journal: <TodayIcon />,
|
||||
tag: <TagIcon />,
|
||||
trash: <DeleteIcon />,
|
||||
} satisfies Record<ModuleName, ReactNode>;
|
||||
|
||||
const WorkbenchTab = ({
|
||||
workbench,
|
||||
active: tabActive,
|
||||
tabsLength,
|
||||
}: {
|
||||
workbench: TabStatus;
|
||||
active: boolean;
|
||||
tabsLength: number;
|
||||
}) => {
|
||||
const tabsHeaderService = useService(AppTabsHeaderService);
|
||||
const activeViewIndex = workbench.activeViewIndex ?? 0;
|
||||
const onContextMenu = useAsyncCallback(
|
||||
async (viewIdx: number) => {
|
||||
await tabsHeaderService.showContextMenu(workbench.id, viewIdx);
|
||||
},
|
||||
[tabsHeaderService, workbench.id]
|
||||
);
|
||||
const onActivateView = useAsyncCallback(
|
||||
async (viewIdx: number) => {
|
||||
await tabsHeaderService.activateView(workbench.id, viewIdx);
|
||||
},
|
||||
[tabsHeaderService, workbench.id]
|
||||
);
|
||||
const onCloseTab: MouseEventHandler = useAsyncCallback(
|
||||
async e => {
|
||||
e.stopPropagation();
|
||||
|
||||
await tabsHeaderService.closeTab(workbench.id);
|
||||
},
|
||||
[tabsHeaderService, workbench.id]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={workbench.id}
|
||||
data-active={tabActive}
|
||||
data-pinned={workbench.pinned}
|
||||
className={styles.tab}
|
||||
>
|
||||
{workbench.views.map((view, viewIdx) => {
|
||||
return (
|
||||
<Fragment key={view.id}>
|
||||
<button
|
||||
key={view.id}
|
||||
className={styles.splitViewLabel}
|
||||
data-active={activeViewIndex === viewIdx && tabActive}
|
||||
onContextMenu={() => {
|
||||
onContextMenu(viewIdx);
|
||||
}}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
onActivateView(viewIdx);
|
||||
}}
|
||||
>
|
||||
<div className={styles.labelIcon}>
|
||||
{workbench.ready || !workbench.loaded ? (
|
||||
moduleNameToIcon[view.moduleName ?? 'all']
|
||||
) : (
|
||||
<Loading />
|
||||
)}
|
||||
</div>
|
||||
{workbench.pinned || !view.title ? null : (
|
||||
<div title={view.title} className={styles.splitViewLabelText}>
|
||||
{view.title}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{viewIdx !== workbench.views.length - 1 ? (
|
||||
<div className={styles.splitViewSeparator} />
|
||||
) : null}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
{!workbench.pinned && tabsLength > 1 ? (
|
||||
<div className={styles.tabCloseButtonWrapper}>
|
||||
<button className={styles.tabCloseButton} onClick={onCloseTab}>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const AppTabsHeader = ({
|
||||
style,
|
||||
reportBoundingUpdate,
|
||||
}: {
|
||||
style?: React.CSSProperties;
|
||||
reportBoundingUpdate?: boolean;
|
||||
}) => {
|
||||
const tabsHeaderService = useService(AppTabsHeaderService);
|
||||
const tabs = useLiveData(tabsHeaderService.tabsStatus$);
|
||||
|
||||
const [pinned, unpinned] = partition(tabs, tab => tab.pinned);
|
||||
|
||||
const onAddTab = useAsyncCallback(async () => {
|
||||
await tabsHeaderService.onAddTab();
|
||||
}, [tabsHeaderService]);
|
||||
|
||||
const onToggleRightSidebar = useAsyncCallback(async () => {
|
||||
await tabsHeaderService.onToggleRightSidebar();
|
||||
}, [tabsHeaderService]);
|
||||
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useServiceOptional(DesktopStateSynchronizer);
|
||||
|
||||
useEffect(() => {
|
||||
if (ref.current && reportBoundingUpdate) {
|
||||
return observeResize(
|
||||
ref.current,
|
||||
debounce(() => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
const rect = ref.current?.getBoundingClientRect();
|
||||
if (!rect) {
|
||||
return;
|
||||
}
|
||||
const toInt = (value: number) => Math.round(value);
|
||||
const boundRect = {
|
||||
height: toInt(rect.height),
|
||||
width: toInt(rect.width),
|
||||
x: toInt(rect.x),
|
||||
y: toInt(rect.y),
|
||||
};
|
||||
apis?.ui.updateTabsBoundingRect(boundRect).catch(console.error);
|
||||
}
|
||||
}, 50)
|
||||
);
|
||||
}
|
||||
return;
|
||||
}, [reportBoundingUpdate]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.root}
|
||||
ref={ref}
|
||||
style={style}
|
||||
data-is-windows={environment.isDesktop && environment.isWindows}
|
||||
>
|
||||
<div className={styles.tabs}>
|
||||
{pinned.map(tab => {
|
||||
return (
|
||||
<WorkbenchTab
|
||||
tabsLength={pinned.length}
|
||||
key={tab.id}
|
||||
workbench={tab}
|
||||
active={tab.active}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{pinned.length > 0 && unpinned.length > 0 && (
|
||||
<div className={styles.pinSeparator} />
|
||||
)}
|
||||
{unpinned.map(workbench => {
|
||||
return (
|
||||
<WorkbenchTab
|
||||
tabsLength={tabs.length}
|
||||
key={workbench.id}
|
||||
workbench={workbench}
|
||||
active={workbench.active}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<IconButton onClick={onAddTab}>
|
||||
<PlusIcon />
|
||||
</IconButton>
|
||||
</div>
|
||||
<div className={styles.spacer} />
|
||||
<IconButton size="large" onClick={onToggleRightSidebar}>
|
||||
<RightSidebarIcon />
|
||||
</IconButton>
|
||||
{environment.isDesktop && environment.isWindows ? (
|
||||
<WindowsAppControls />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,178 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const root = style({
|
||||
width: '100%',
|
||||
height: '52px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
gap: '8px',
|
||||
overflow: 'clip',
|
||||
pointerEvents: 'auto',
|
||||
['WebkitAppRegion' as string]: 'drag',
|
||||
selectors: {
|
||||
'&[data-is-windows="false"]': {
|
||||
paddingRight: 8,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const tabs = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: '0 8px',
|
||||
gap: '8px',
|
||||
overflow: 'clip',
|
||||
height: '100%',
|
||||
selectors: {
|
||||
'&[data-pinned="true"]': {
|
||||
flexShrink: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const pinSeparator = style({
|
||||
background: cssVar('iconSecondary'),
|
||||
width: 1,
|
||||
height: 16,
|
||||
flexShrink: 0,
|
||||
});
|
||||
|
||||
export const splitViewSeparator = style({
|
||||
background: cssVar('borderColor'),
|
||||
width: 1,
|
||||
height: '100%',
|
||||
flexShrink: 0,
|
||||
});
|
||||
|
||||
export const tab = style({
|
||||
height: 32,
|
||||
minWidth: 32,
|
||||
overflow: 'clip',
|
||||
background: cssVar('backgroundSecondaryColor'),
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-start',
|
||||
alignItems: 'center',
|
||||
color: cssVar('textSecondaryColor'),
|
||||
userSelect: 'none',
|
||||
borderRadius: 4,
|
||||
position: 'relative',
|
||||
['WebkitAppRegion' as string]: 'no-drag',
|
||||
selectors: {
|
||||
'&[data-active="true"]': {
|
||||
background: cssVar('backgroundPrimaryColor'),
|
||||
boxShadow: cssVar('shadow1'),
|
||||
},
|
||||
'&[data-pinned="false"]': {
|
||||
paddingRight: 20,
|
||||
},
|
||||
'&[data-pinned="true"]': {
|
||||
flexShrink: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const splitViewLabel = style({
|
||||
minWidth: 32,
|
||||
padding: '0 8px',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
gap: '4px',
|
||||
fontWeight: 500,
|
||||
alignItems: 'center',
|
||||
maxWidth: 180,
|
||||
cursor: 'default',
|
||||
});
|
||||
|
||||
export const splitViewLabelText = style({
|
||||
minWidth: 0,
|
||||
textOverflow: 'ellipsis',
|
||||
overflow: 'clip',
|
||||
whiteSpace: 'nowrap',
|
||||
color: cssVar('textSecondaryColor'),
|
||||
fontSize: cssVar('fontXs'),
|
||||
selectors: {
|
||||
[`${splitViewLabel}:hover &`]: {
|
||||
color: cssVar('textPrimaryColor'),
|
||||
},
|
||||
[`${splitViewLabel}[data-active="true"] &`]: {
|
||||
color: cssVar('primaryColor'),
|
||||
},
|
||||
[`${splitViewLabel}:last-of-type &`]: {
|
||||
textOverflow: 'clip',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const tabIcon = style({
|
||||
color: cssVar('iconSecondary'),
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
});
|
||||
|
||||
export const labelIcon = style([
|
||||
tabIcon,
|
||||
{
|
||||
width: 16,
|
||||
height: 16,
|
||||
fontSize: 16,
|
||||
flexShrink: 0,
|
||||
selectors: {
|
||||
[`${tab}[data-active=true] &`]: {
|
||||
color: cssVar('primaryColor'),
|
||||
},
|
||||
[`${splitViewLabel}[data-active=false]:hover &`]: {
|
||||
color: cssVar('iconColor'),
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
export const tabCloseButtonWrapper = style({
|
||||
pointerEvents: 'none',
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
height: '100%',
|
||||
width: 16,
|
||||
overflow: 'clip',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
paddingRight: 4,
|
||||
justifyContent: 'flex-end',
|
||||
selectors: {
|
||||
[`${tab}:is([data-active=true], :hover) &`]: {
|
||||
width: 40,
|
||||
},
|
||||
[`${tab}[data-active=true] &`]: {
|
||||
background: `linear-gradient(270deg, ${cssVar('backgroundPrimaryColor')} 52.86%, rgba(255, 255, 255, 0.00) 100%)`,
|
||||
},
|
||||
[`${tab}[data-active=false] &`]: {
|
||||
background: `linear-gradient(270deg, ${cssVar('backgroundSecondaryColor')} 65.71%, rgba(244, 244, 245, 0.00) 100%)`,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const tabCloseButton = style([
|
||||
tabIcon,
|
||||
{
|
||||
pointerEvents: 'auto',
|
||||
width: 16,
|
||||
height: '100%',
|
||||
display: 'none',
|
||||
color: cssVar('iconColor'),
|
||||
selectors: {
|
||||
[`${tab}:is([data-active=true], :hover) &`]: {
|
||||
display: 'flex',
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
export const spacer = style({
|
||||
flexGrow: 1,
|
||||
});
|
||||
@@ -16,14 +16,12 @@ import { configureQuickSearchModule } from './quicksearch';
|
||||
import { configureShareDocsModule } from './share-doc';
|
||||
import { configureTagModule } from './tag';
|
||||
import { configureTelemetryModule } from './telemetry';
|
||||
import { configureWorkbenchModule } from './workbench';
|
||||
|
||||
export function configureCommonModules(framework: Framework) {
|
||||
configureInfraModules(framework);
|
||||
configureCollectionModule(framework);
|
||||
configureNavigationModule(framework);
|
||||
configureTagModule(framework);
|
||||
configureWorkbenchModule(framework);
|
||||
configureWorkspacePropertiesModule(framework);
|
||||
configureCloudModule(framework);
|
||||
configureQuotaModule(framework);
|
||||
|
||||
@@ -18,11 +18,13 @@ export const resolveRouteLinkMeta = (href: string) => {
|
||||
return null;
|
||||
}
|
||||
|
||||
// http://xxx/workspace/all/yyy
|
||||
// to { workspaceId: '48__RTCSwASvWZxyAk3Jw', docId: '-Uge-K6SYcAbcNYfQ5U-j', blockId: 'xxxx' }
|
||||
const hash = url.hash;
|
||||
const pathname = url.pathname;
|
||||
|
||||
// http://---/workspace/{workspaceid}/xxx/yyy
|
||||
// http://---/workspace/{workspaceid}/xxx
|
||||
const [_, workspaceId, moduleName, subModuleName] =
|
||||
url.toString().match(/\/workspace\/([^/]+)\/([^#]+)(?:#(.+))?/) || [];
|
||||
pathname.match(/\/workspace\/([^/]+)\/([^/]+)(?:\/([^/]+))?/) || [];
|
||||
|
||||
if (isRouteModulePath(moduleName)) {
|
||||
return {
|
||||
@@ -36,7 +38,7 @@ export const resolveRouteLinkMeta = (href: string) => {
|
||||
workspaceId,
|
||||
moduleName: 'doc' as const,
|
||||
docId: moduleName,
|
||||
blockId: subModuleName,
|
||||
blockId: hash.slice(1),
|
||||
};
|
||||
}
|
||||
return;
|
||||
@@ -48,7 +50,8 @@ export const resolveRouteLinkMeta = (href: string) => {
|
||||
/**
|
||||
* @see /packages/frontend/core/src/router.tsx
|
||||
*/
|
||||
const routeModulePaths = ['all', 'collection', 'tag', 'trash'] as const;
|
||||
export const routeModulePaths = ['all', 'collection', 'tag', 'trash'] as const;
|
||||
export type RouteModulePath = (typeof routeModulePaths)[number];
|
||||
|
||||
const isRouteModulePath = (
|
||||
path: string
|
||||
|
||||
@@ -13,7 +13,14 @@ export class View extends Entity<{
|
||||
scope = this.framework.createScope(ViewScope, {
|
||||
view: this as View,
|
||||
});
|
||||
id = this.props.id;
|
||||
|
||||
get id() {
|
||||
return this.props.id;
|
||||
}
|
||||
|
||||
set id(id: string) {
|
||||
this.props.id = id;
|
||||
}
|
||||
|
||||
sidebarTabs$ = new LiveData<SidebarTab[]>([]);
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Entity, LiveData } from '@toeverything/infra';
|
||||
import type { To } from 'history';
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
import type { WorkbenchDefaultState } from '../services/workbench-view-state';
|
||||
import { View } from './view';
|
||||
|
||||
export type WorkbenchPosition = 'beside' | 'active' | 'head' | 'tail' | number;
|
||||
@@ -13,17 +14,27 @@ interface WorkbenchOpenOptions {
|
||||
}
|
||||
|
||||
export class Workbench extends Entity {
|
||||
readonly views$ = new LiveData([
|
||||
this.framework.createEntity(View, { id: nanoid() }),
|
||||
]);
|
||||
constructor(private readonly defaultState: WorkbenchDefaultState) {
|
||||
super();
|
||||
}
|
||||
|
||||
readonly activeViewIndex$ = new LiveData(this.defaultState.activeViewIndex);
|
||||
readonly basename$ = new LiveData(this.defaultState.basename);
|
||||
|
||||
readonly views$: LiveData<View[]> = new LiveData(
|
||||
this.defaultState.views.map(meta => {
|
||||
return this.framework.createEntity(View, {
|
||||
id: meta.id,
|
||||
defaultLocation: meta.path,
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
activeViewIndex$ = new LiveData(0);
|
||||
activeView$ = LiveData.computed(get => {
|
||||
const activeIndex = get(this.activeViewIndex$);
|
||||
const views = get(this.views$);
|
||||
return views[activeIndex];
|
||||
return views[activeIndex]; // todo: this could be null
|
||||
});
|
||||
basename$ = new LiveData('/');
|
||||
location$ = LiveData.computed(get => {
|
||||
return get(get(this.activeView$).location$);
|
||||
});
|
||||
@@ -34,6 +45,10 @@ export class Workbench extends Entity {
|
||||
this.activeViewIndex$.next(index);
|
||||
}
|
||||
|
||||
updateBasename(basename: string) {
|
||||
this.basename$.next(basename);
|
||||
}
|
||||
|
||||
createView(at: WorkbenchPosition = 'beside', defaultLocation: To) {
|
||||
const view = this.framework.createEntity(View, {
|
||||
id: nanoid(),
|
||||
|
||||
@@ -6,22 +6,55 @@ export { ViewBody, ViewHeader, ViewSidebarTab } from './view/view-islands';
|
||||
export { WorkbenchLink } from './view/workbench-link';
|
||||
export { WorkbenchRoot } from './view/workbench-root';
|
||||
|
||||
import { type Framework, WorkspaceScope } from '@toeverything/infra';
|
||||
import {
|
||||
DocsService,
|
||||
type Framework,
|
||||
GlobalStateService,
|
||||
WorkspaceScope,
|
||||
} from '@toeverything/infra';
|
||||
|
||||
import { WorkspacePropertiesAdapter } from '../properties';
|
||||
import { SidebarTab } from './entities/sidebar-tab';
|
||||
import { View } from './entities/view';
|
||||
import { Workbench } from './entities/workbench';
|
||||
import { ViewScope } from './scopes/view';
|
||||
import { DesktopStateSynchronizer } from './services/desktop-state-synchronizer';
|
||||
import { ViewService } from './services/view';
|
||||
import { WorkbenchService } from './services/workbench';
|
||||
import {
|
||||
DesktopWorkbenchDefaultState,
|
||||
InMemoryWorkbenchDefaultState,
|
||||
WorkbenchDefaultState,
|
||||
} from './services/workbench-view-state';
|
||||
|
||||
export function configureWorkbenchModule(services: Framework) {
|
||||
export function configureWorkbenchCommonModule(services: Framework) {
|
||||
services
|
||||
.scope(WorkspaceScope)
|
||||
.service(WorkbenchService)
|
||||
.entity(Workbench)
|
||||
.entity(Workbench, [WorkbenchDefaultState])
|
||||
.entity(View)
|
||||
.scope(ViewScope)
|
||||
.service(ViewService, [ViewScope])
|
||||
.entity(SidebarTab);
|
||||
}
|
||||
|
||||
export function configureBrowserWorkbenchModule(services: Framework) {
|
||||
configureWorkbenchCommonModule(services);
|
||||
services
|
||||
.scope(WorkspaceScope)
|
||||
.impl(WorkbenchDefaultState, InMemoryWorkbenchDefaultState);
|
||||
}
|
||||
|
||||
export function configureDesktopWorkbenchModule(services: Framework) {
|
||||
configureWorkbenchCommonModule(services);
|
||||
services
|
||||
.scope(WorkspaceScope)
|
||||
.impl(WorkbenchDefaultState, DesktopWorkbenchDefaultState, [
|
||||
GlobalStateService,
|
||||
])
|
||||
.service(DesktopStateSynchronizer, [
|
||||
WorkbenchService,
|
||||
WorkspacePropertiesAdapter,
|
||||
DocsService,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
import {
|
||||
apis,
|
||||
appInfo,
|
||||
events,
|
||||
type WorkbenchViewMeta,
|
||||
} from '@affine/electron-api';
|
||||
import { I18n, type I18nKeys, i18nTime } from '@affine/i18n';
|
||||
import type { DocsService } from '@toeverything/infra';
|
||||
import { Service } from '@toeverything/infra';
|
||||
import { combineLatest, filter, map, of, switchMap } from 'rxjs';
|
||||
|
||||
import { resolveRouteLinkMeta } from '../../navigation';
|
||||
import type { RouteModulePath } from '../../navigation/utils';
|
||||
import type { WorkspacePropertiesAdapter } from '../../properties';
|
||||
import type { WorkbenchService } from '../../workbench';
|
||||
|
||||
const routeModuleToI18n = {
|
||||
all: 'All pages',
|
||||
collection: 'Collections',
|
||||
tag: 'Tags',
|
||||
trash: 'Trash',
|
||||
} satisfies Record<RouteModulePath, I18nKeys>;
|
||||
|
||||
/**
|
||||
* Synchronize workbench state with state stored in main process
|
||||
*/
|
||||
export class DesktopStateSynchronizer extends Service {
|
||||
constructor(
|
||||
private readonly workbenchService: WorkbenchService,
|
||||
private readonly workspaceProperties: WorkspacePropertiesAdapter,
|
||||
private readonly docsService: DocsService
|
||||
) {
|
||||
super();
|
||||
this.startSync();
|
||||
}
|
||||
|
||||
startSync = () => {
|
||||
if (!environment.isDesktop) {
|
||||
return;
|
||||
}
|
||||
|
||||
const workbench = this.workbenchService.workbench;
|
||||
|
||||
events?.ui.onTabAction(event => {
|
||||
if (
|
||||
event.type === 'open-in-split-view' &&
|
||||
event.payload.tabId === appInfo?.viewId
|
||||
) {
|
||||
const activeView = workbench.activeView$.value;
|
||||
if (activeView) {
|
||||
workbench.open(activeView.location$.value, {
|
||||
at: 'beside',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
event.type === 'separate-view' &&
|
||||
event.payload.tabId === appInfo?.viewId
|
||||
) {
|
||||
const view = workbench.viewAt(event.payload.viewIndex);
|
||||
if (view) {
|
||||
workbench.close(view);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
event.type === 'activate-view' &&
|
||||
event.payload.tabId === appInfo?.viewId
|
||||
) {
|
||||
workbench.active(event.payload.viewIndex);
|
||||
}
|
||||
});
|
||||
|
||||
events?.ui.onToggleRightSidebar(tabId => {
|
||||
if (tabId === appInfo?.viewId) {
|
||||
workbench.sidebarOpen$.next(!workbench.sidebarOpen$.value);
|
||||
}
|
||||
});
|
||||
|
||||
// sync workbench state with main process
|
||||
// also fill tab view meta with title & moduleName
|
||||
this.workspaceProperties.workspace.engine.rootDocState$
|
||||
.pipe(
|
||||
filter(v => v.ready),
|
||||
switchMap(() => workbench.views$),
|
||||
switchMap(views => {
|
||||
return combineLatest(
|
||||
views.map(view =>
|
||||
view.location$.map(location => {
|
||||
return {
|
||||
view,
|
||||
location,
|
||||
};
|
||||
})
|
||||
)
|
||||
);
|
||||
}),
|
||||
map(viewLocations => {
|
||||
if (!apis || !appInfo?.viewId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const viewMetas = viewLocations.map(({ view, location }) => {
|
||||
return {
|
||||
id: view.id,
|
||||
path: location,
|
||||
};
|
||||
});
|
||||
|
||||
return viewMetas.map(viewMeta => this.fillTabViewMeta(viewMeta));
|
||||
}),
|
||||
filter(v => !!v),
|
||||
switchMap(viewMetas => {
|
||||
return this.docsService.list.docs$.pipe(
|
||||
switchMap(docs => {
|
||||
return combineLatest(
|
||||
viewMetas.map(vm => {
|
||||
return (
|
||||
docs
|
||||
.find(doc => doc.id === vm.docId)
|
||||
?.mode$.asObservable() ?? of('page')
|
||||
).pipe(
|
||||
map(mode => ({
|
||||
...vm,
|
||||
moduleName:
|
||||
vm.moduleName === 'page' ? mode : vm.moduleName,
|
||||
}))
|
||||
);
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
})
|
||||
)
|
||||
.subscribe(viewMetas => {
|
||||
if (!apis || !appInfo?.viewId) {
|
||||
return;
|
||||
}
|
||||
|
||||
apis.ui
|
||||
.updateWorkbenchMeta(appInfo.viewId, {
|
||||
views: viewMetas,
|
||||
})
|
||||
.catch(console.error);
|
||||
});
|
||||
|
||||
workbench.activeViewIndex$.subscribe(activeViewIndex => {
|
||||
if (!apis || !appInfo?.viewId) {
|
||||
return;
|
||||
}
|
||||
|
||||
apis.ui
|
||||
.updateWorkbenchMeta(appInfo.viewId, {
|
||||
activeViewIndex: activeViewIndex,
|
||||
})
|
||||
.catch(console.error);
|
||||
});
|
||||
|
||||
workbench.basename$.subscribe(basename => {
|
||||
if (!apis || !appInfo?.viewId) {
|
||||
return;
|
||||
}
|
||||
|
||||
apis.ui
|
||||
.updateWorkbenchMeta(appInfo.viewId, {
|
||||
basename: basename,
|
||||
})
|
||||
.catch(console.error);
|
||||
});
|
||||
};
|
||||
|
||||
private toFullUrl(
|
||||
basename: string,
|
||||
location: { hash?: string; pathname?: string; search?: string }
|
||||
) {
|
||||
return basename + location.pathname + location.search + location.hash;
|
||||
}
|
||||
|
||||
// fill tab view meta with title & moduleName
|
||||
private fillTabViewMeta(
|
||||
view: WorkbenchViewMeta
|
||||
): WorkbenchViewMeta & { docId?: string } {
|
||||
if (!view.path) {
|
||||
return view;
|
||||
}
|
||||
|
||||
const url = this.toFullUrl(
|
||||
this.workbenchService.workbench.basename$.value,
|
||||
view.path
|
||||
);
|
||||
const linkMeta = resolveRouteLinkMeta(url);
|
||||
|
||||
if (!linkMeta) {
|
||||
return view;
|
||||
}
|
||||
|
||||
const journalString =
|
||||
linkMeta.moduleName === 'doc'
|
||||
? this.workspaceProperties.getJournalPageDateString(linkMeta.docId)
|
||||
: undefined;
|
||||
const isJournal = !!journalString;
|
||||
|
||||
const title = (() => {
|
||||
// todo: resolve more module types like collections?
|
||||
if (linkMeta?.moduleName === 'doc') {
|
||||
if (journalString) {
|
||||
return i18nTime(journalString, { absolute: { accuracy: 'day' } });
|
||||
}
|
||||
return (
|
||||
this.workspaceProperties.workspace.docCollection.meta.getDocMeta(
|
||||
linkMeta.docId
|
||||
)?.title || I18n['Untitled']()
|
||||
);
|
||||
} else {
|
||||
return I18n[routeModuleToI18n[linkMeta.moduleName]]();
|
||||
}
|
||||
})();
|
||||
|
||||
return {
|
||||
...view,
|
||||
title: title,
|
||||
docId: linkMeta.docId,
|
||||
moduleName: isJournal ? 'journal' : linkMeta.moduleName,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { appInfo, type TabViewsMetaSchema } from '@affine/electron-api';
|
||||
import type { GlobalStateService } from '@toeverything/infra';
|
||||
import { createIdentifier, Service } from '@toeverything/infra';
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
export type WorkbenchDefaultState = {
|
||||
basename: string;
|
||||
views: {
|
||||
id: string;
|
||||
path?: { pathname?: string; hash?: string; search?: string };
|
||||
}[];
|
||||
activeViewIndex: number;
|
||||
};
|
||||
|
||||
export const WorkbenchDefaultState = createIdentifier<WorkbenchDefaultState>(
|
||||
'WorkbenchDefaultState'
|
||||
);
|
||||
|
||||
export const InMemoryWorkbenchDefaultState: WorkbenchDefaultState = {
|
||||
basename: '/',
|
||||
views: [
|
||||
{
|
||||
id: nanoid(),
|
||||
},
|
||||
],
|
||||
activeViewIndex: 0,
|
||||
};
|
||||
|
||||
export class DesktopWorkbenchDefaultState
|
||||
extends Service
|
||||
implements WorkbenchDefaultState
|
||||
{
|
||||
constructor(private readonly globalStateService: GlobalStateService) {
|
||||
super();
|
||||
}
|
||||
|
||||
get value() {
|
||||
const tabViewsMeta =
|
||||
this.globalStateService.globalState.get<TabViewsMetaSchema>(
|
||||
'tabViewsMetaSchema'
|
||||
);
|
||||
|
||||
return (
|
||||
tabViewsMeta?.workbenches.find(w => w.id === appInfo?.viewId) ||
|
||||
InMemoryWorkbenchDefaultState
|
||||
);
|
||||
}
|
||||
|
||||
get basename() {
|
||||
return this.value.basename;
|
||||
}
|
||||
|
||||
get activeViewIndex() {
|
||||
return this.value.activeViewIndex;
|
||||
}
|
||||
|
||||
get views() {
|
||||
return this.value.views;
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,6 @@ export function useBindWorkbenchToDesktopRouter(
|
||||
basename: string
|
||||
) {
|
||||
const browserLocation = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
const newLocation = browserLocationToViewLocation(
|
||||
browserLocation,
|
||||
@@ -37,7 +36,6 @@ export function useBindWorkbenchToDesktopRouter(
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
workbench.open(newLocation);
|
||||
}, [basename, browserLocation, workbench]);
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ export const header = style({
|
||||
flexShrink: 0,
|
||||
background: cssVar('backgroundPrimaryColor'),
|
||||
padding: '0 16px',
|
||||
['WebkitAppRegion' as string]: 'drag',
|
||||
'@media': {
|
||||
print: {
|
||||
display: 'none',
|
||||
@@ -62,10 +61,3 @@ export const viewHeaderContainer = style({
|
||||
flexGrow: 1,
|
||||
minWidth: 12,
|
||||
});
|
||||
|
||||
export const windowsAppControlsContainer = style({
|
||||
display: 'flex',
|
||||
height: '100%',
|
||||
marginRight: '-16px',
|
||||
paddingLeft: '16px',
|
||||
});
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { IconButton } from '@affine/component';
|
||||
import { WindowsAppControls } from '@affine/core/components/pure/header/windows-app-controls';
|
||||
import { RightSidebarIcon } from '@blocksuite/icons/rc';
|
||||
import { useLiveData, useService } from '@toeverything/infra';
|
||||
import { useAtomValue } from 'jotai';
|
||||
@@ -50,12 +49,11 @@ export const RouteContainer = ({ route }: Props) => {
|
||||
const handleToggleSidebar = useCallback(() => {
|
||||
workbench.toggleSidebar();
|
||||
}, [workbench]);
|
||||
const isWindowsDesktop = environment.isDesktop && environment.isWindows;
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<div className={styles.header}>
|
||||
{viewPosition.isFirst && (
|
||||
{viewPosition.isFirst && !environment.isDesktop && (
|
||||
<SidebarSwitch
|
||||
show={!leftSidebarOpen}
|
||||
className={styles.leftSidebarButton}
|
||||
@@ -65,19 +63,12 @@ export const RouteContainer = ({ route }: Props) => {
|
||||
viewId={view.id}
|
||||
className={styles.viewHeaderContainer}
|
||||
/>
|
||||
{viewPosition.isLast && (
|
||||
<>
|
||||
<ToggleButton
|
||||
show={!sidebarOpen}
|
||||
className={styles.rightSidebarButton}
|
||||
onToggle={handleToggleSidebar}
|
||||
/>
|
||||
{isWindowsDesktop && !sidebarOpen && (
|
||||
<div className={styles.windowsAppControlsContainer}>
|
||||
<WindowsAppControls />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
{viewPosition.isLast && !environment.isDesktop && (
|
||||
<ToggleButton
|
||||
show={!sidebarOpen}
|
||||
className={styles.rightSidebarButton}
|
||||
onToggle={handleToggleSidebar}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -25,7 +25,10 @@ export const sidebarBodyTarget = style({
|
||||
height: '100%',
|
||||
overflow: 'hidden',
|
||||
alignItems: 'center',
|
||||
borderTop: `1px solid ${cssVar('borderColor')}`,
|
||||
});
|
||||
|
||||
export const borderTop = style({
|
||||
borderTop: `0.5px solid ${cssVar('borderColor')}`,
|
||||
});
|
||||
|
||||
export const sidebarBodyNoSelection = style({
|
||||
|
||||
@@ -24,16 +24,11 @@ export const SidebarContainer = ({
|
||||
workbench.toggleSidebar();
|
||||
}, [workbench]);
|
||||
|
||||
const isWindowsDesktop = environment.isDesktop && environment.isWindows;
|
||||
|
||||
return (
|
||||
<div className={clsx(styles.sidebarContainerInner, className)} {...props}>
|
||||
<Header floating={false} onToggle={handleToggleOpen}>
|
||||
{!isWindowsDesktop && sidebarTabs.length > 0 && (
|
||||
<SidebarHeaderSwitcher />
|
||||
)}
|
||||
<SidebarHeaderSwitcher />
|
||||
</Header>
|
||||
{isWindowsDesktop && sidebarTabs.length > 0 && <SidebarHeaderSwitcher />}
|
||||
{sidebarTabs.length > 0 ? (
|
||||
sidebarTabs.map(sidebar => (
|
||||
<ViewSidebarTabBodyTarget
|
||||
@@ -41,7 +36,10 @@ export const SidebarContainer = ({
|
||||
key={sidebar.id}
|
||||
style={{ display: activeSidebarTab === sidebar ? 'block' : 'none' }}
|
||||
viewId={view.id}
|
||||
className={styles.sidebarBodyTarget}
|
||||
className={clsx(
|
||||
styles.sidebarBodyTarget,
|
||||
!environment.isDesktop && styles.borderTop
|
||||
)}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
|
||||
@@ -11,11 +11,6 @@ export const header = style({
|
||||
zIndex: 1,
|
||||
gap: '12px',
|
||||
background: cssVar('backgroundPrimaryColor'),
|
||||
selectors: {
|
||||
'&[data-sidebar-floating="false"]': {
|
||||
['WebkitAppRegion' as string]: 'drag',
|
||||
},
|
||||
},
|
||||
'@media': {
|
||||
print: {
|
||||
display: 'none',
|
||||
@@ -36,9 +31,3 @@ export const standaloneExtensionSwitcherWrapper = style({
|
||||
height: '52px',
|
||||
position: 'relative',
|
||||
});
|
||||
|
||||
export const windowsAppControlsContainer = style({
|
||||
display: 'flex',
|
||||
height: '100%',
|
||||
marginRight: '-16px',
|
||||
});
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { IconButton } from '@affine/component';
|
||||
import { WindowsAppControls } from '@affine/core/components/pure/header/windows-app-controls';
|
||||
import { RightSidebarIcon } from '@blocksuite/icons/rc';
|
||||
|
||||
import * as styles from './sidebar-header.css';
|
||||
@@ -41,28 +40,16 @@ const ToggleButton = ({ onToggle }: { onToggle?: () => void }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const Windows = ({ floating, onToggle, children }: HeaderProps) => {
|
||||
export const Header = ({ floating, children, onToggle }: HeaderProps) => {
|
||||
return (
|
||||
<Container className={styles.header} floating={floating}>
|
||||
{children}
|
||||
<div className={styles.spacer} />
|
||||
<ToggleButton onToggle={onToggle} />
|
||||
<div className={styles.windowsAppControlsContainer}>
|
||||
<WindowsAppControls />
|
||||
</div>
|
||||
{!environment.isDesktop && (
|
||||
<>
|
||||
<div className={styles.spacer} />
|
||||
<ToggleButton onToggle={onToggle} />
|
||||
</>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
const NonWindows = ({ floating, children, onToggle }: HeaderProps) => {
|
||||
return (
|
||||
<Container className={styles.header} floating={floating}>
|
||||
{children}
|
||||
<div className={styles.spacer} />
|
||||
<ToggleButton onToggle={onToggle} />
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export const Header =
|
||||
environment.isDesktop && environment.isWindows ? Windows : NonWindows;
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useAppSettingHelper } from '@affine/core/hooks/affine/use-app-setting-helper';
|
||||
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
|
||||
import { popupWindow } from '@affine/core/utils';
|
||||
import { apis } from '@affine/electron-api';
|
||||
import { useLiveData, useService } from '@toeverything/infra';
|
||||
import type { To } from 'history';
|
||||
import { forwardRef, type MouseEvent, useCallback } from 'react';
|
||||
import { parsePath, type To } from 'history';
|
||||
import { forwardRef, type MouseEvent } from 'react';
|
||||
|
||||
import { WorkbenchService } from '../services/workbench';
|
||||
|
||||
@@ -21,8 +23,8 @@ export const WorkbenchLink = forwardRef<
|
||||
const link =
|
||||
basename +
|
||||
(typeof to === 'string' ? to : `${to.pathname}${to.search}${to.hash}`);
|
||||
const handleClick = useCallback(
|
||||
(event: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
const handleClick = useAsyncCallback(
|
||||
async (event: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (onClick?.(event) === false) {
|
||||
@@ -30,8 +32,16 @@ export const WorkbenchLink = forwardRef<
|
||||
}
|
||||
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
if (appSettings.enableMultiView && environment.isDesktop) {
|
||||
workbench.open(to, { at: 'beside' });
|
||||
if (environment.isDesktop) {
|
||||
if (event.altKey && appSettings.enableMultiView) {
|
||||
workbench.open(to, { at: 'tail' });
|
||||
} else {
|
||||
const path = typeof to === 'string' ? parsePath(to) : to;
|
||||
await apis?.ui.addTab({
|
||||
basename,
|
||||
view: { path },
|
||||
});
|
||||
}
|
||||
} else if (!environment.isDesktop) {
|
||||
popupWindow(link);
|
||||
}
|
||||
@@ -39,7 +49,7 @@ export const WorkbenchLink = forwardRef<
|
||||
workbench.open(to);
|
||||
}
|
||||
},
|
||||
[appSettings.enableMultiView, link, onClick, to, workbench]
|
||||
[appSettings.enableMultiView, basename, link, onClick, to, workbench]
|
||||
);
|
||||
|
||||
// eslint suspicious runtime error
|
||||
|
||||
@@ -25,7 +25,7 @@ export const workbenchSidebar = style({
|
||||
borderRadius: 6,
|
||||
},
|
||||
[`&[data-client-border=false]`]: {
|
||||
borderLeft: `1px solid ${cssVar('borderColor')}`,
|
||||
borderLeft: `0.5px solid ${cssVar('borderColor')}`,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -49,8 +49,8 @@ export const WorkbenchRoot = memo(() => {
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
workbench.basename$.next(basename);
|
||||
}, [basename, workbench.basename$]);
|
||||
workbench.updateBasename(basename);
|
||||
}, [basename, workbench]);
|
||||
|
||||
return (
|
||||
<ViewIslandRegistryProvider>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { apis } from '@affine/electron-api';
|
||||
import type { ByteKV, ByteKVBehavior, DocStorage } from '@toeverything/infra';
|
||||
import { AsyncLock, MemoryDocEventBus } from '@toeverything/infra';
|
||||
import { AsyncLock } from '@toeverything/infra';
|
||||
|
||||
import { BroadcastChannelDocEventBus } from './doc-broadcast-channel';
|
||||
|
||||
export class SqliteDocStorage implements DocStorage {
|
||||
constructor(private readonly workspaceId: string) {}
|
||||
eventBus = new MemoryDocEventBus();
|
||||
eventBus = new BroadcastChannelDocEventBus(this.workspaceId);
|
||||
readonly doc = new Doc(this.workspaceId);
|
||||
readonly syncMetadata = new SyncMetadataKV(this.workspaceId);
|
||||
readonly serverClock = new ServerClockKV(this.workspaceId);
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useCallback, useState } from 'react';
|
||||
|
||||
import { SignOutModal } from '../components/affine/sign-out-modal';
|
||||
import { RouteLogic, useNavigateHelper } from '../hooks/use-navigate-helper';
|
||||
import { AppTabsHeader } from '../modules/app-tabs-header';
|
||||
import { AuthService } from '../modules/cloud';
|
||||
import { SignIn } from './sign-in';
|
||||
|
||||
@@ -38,6 +39,14 @@ export const PageNotFound = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
{environment.isDesktop ? (
|
||||
<AppTabsHeader
|
||||
style={{
|
||||
paddingLeft: environment.isMacOs ? 80 : 0,
|
||||
}}
|
||||
reportBoundingUpdate
|
||||
/>
|
||||
) : null}
|
||||
{noPermission ? (
|
||||
<NoPermissionOrNotFound
|
||||
user={account}
|
||||
|
||||
@@ -23,7 +23,6 @@ import type { LoaderFunction } from 'react-router-dom';
|
||||
import { redirect, useParams, useSearchParams } from 'react-router-dom';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { WindowsAppControls } from '../components/pure/header/windows-app-controls';
|
||||
import { useMutation } from '../hooks/use-mutation';
|
||||
import { RouteLogic, useNavigateHelper } from '../hooks/use-navigate-helper';
|
||||
import { AuthService, ServerConfigService } from '../modules/cloud';
|
||||
@@ -106,13 +105,7 @@ export const AuthPage = (): ReactElement | null => {
|
||||
|
||||
switch (authType) {
|
||||
case 'onboarding':
|
||||
return (
|
||||
<OnboardingPage
|
||||
user={account}
|
||||
onOpenAffine={onOpenAffine}
|
||||
windowControl={<WindowsAppControls />}
|
||||
/>
|
||||
);
|
||||
return <OnboardingPage user={account} onOpenAffine={onOpenAffine} />;
|
||||
case 'signUp': {
|
||||
return (
|
||||
<SignUpPage
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
import { AppFallback } from '../components/affine/app-container';
|
||||
import { UserWithWorkspaceList } from '../components/pure/workspace-slider-bar/user-with-workspace-list';
|
||||
import { useNavigateHelper } from '../hooks/use-navigate-helper';
|
||||
import { AppTabsHeader } from '../modules/app-tabs-header';
|
||||
import { AuthService } from '../modules/cloud';
|
||||
import { WorkspaceSubPath } from '../shared';
|
||||
|
||||
@@ -147,6 +148,14 @@ export const Component = () => {
|
||||
// TODO(@eyhn): We need a no workspace page
|
||||
return (
|
||||
<>
|
||||
{environment.isDesktop ? (
|
||||
<AppTabsHeader
|
||||
style={{
|
||||
paddingLeft: environment.isMacOs ? 80 : 0,
|
||||
}}
|
||||
reportBoundingUpdate
|
||||
/>
|
||||
) : null}
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
|
||||
@@ -6,7 +6,16 @@ export const mainContainer = style({
|
||||
flexDirection: 'column',
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
borderTop: `1px solid ${cssVar('borderColor')}`,
|
||||
borderTop: `0.5px solid transparent`,
|
||||
transition: 'border-color 0.2s',
|
||||
selectors: {
|
||||
'&[data-dynamic-top-border="false"]': {
|
||||
borderColor: cssVar('borderColor'),
|
||||
},
|
||||
'&[data-has-scroll-top="true"]': {
|
||||
borderColor: cssVar('borderColor'),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const editorContainer = style({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Scrollable } from '@affine/component';
|
||||
import { Scrollable, useHasScrollTop } from '@affine/component';
|
||||
import { PageDetailSkeleton } from '@affine/component/page-detail-skeleton';
|
||||
import type { ChatPanel } from '@affine/core/blocksuite/presets/ai';
|
||||
import { AIProvider } from '@affine/core/blocksuite/presets/ai';
|
||||
@@ -212,18 +212,26 @@ const DetailPageImpl = memo(function DetailPageImpl() {
|
||||
view.activeSidebarTab('outline');
|
||||
}, [workbench, view]);
|
||||
|
||||
const [refCallback, hasScrollTop] = useHasScrollTop();
|
||||
const dynamicTopBorder = environment.isDesktop;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ViewHeader>
|
||||
<DetailPageHeader page={doc.blockSuiteDoc} workspace={workspace} />
|
||||
</ViewHeader>
|
||||
<ViewBody>
|
||||
<div className={styles.mainContainer}>
|
||||
<div
|
||||
className={styles.mainContainer}
|
||||
data-dynamic-top-border={dynamicTopBorder}
|
||||
data-has-scroll-top={hasScrollTop}
|
||||
>
|
||||
{/* Add a key to force rerender when page changed, to avoid error boundary persisting. */}
|
||||
<AffineErrorBoundary key={doc.id}>
|
||||
<TopTip pageId={doc.id} workspace={workspace} />
|
||||
<Scrollable.Root>
|
||||
<Scrollable.Viewport
|
||||
ref={refCallback}
|
||||
className={clsx(
|
||||
'affine-page-viewport',
|
||||
styles.affineDocViewport,
|
||||
|
||||
@@ -19,7 +19,10 @@ export const useDetailPageHeaderResponsive = (availableWidth: number) => {
|
||||
const hideToday = availableWidth < 300;
|
||||
|
||||
const showDivider =
|
||||
viewPosition.isLast && !rightSidebarOpen && !(hidePresent && hideShare);
|
||||
viewPosition.isLast &&
|
||||
!rightSidebarOpen &&
|
||||
!(hidePresent && hideShare) &&
|
||||
!environment.isDesktop;
|
||||
|
||||
return {
|
||||
hideShare,
|
||||
|
||||
Reference in New Issue
Block a user