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:
pengx17
2024-07-29 11:05:22 +00:00
parent 622715d2f3
commit 1efc1d0f5b
88 changed files with 3160 additions and 945 deletions

View File

@@ -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);
}

View File

@@ -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();
};
}

View File

@@ -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>
);
};

View File

@@ -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,
});