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:
@@ -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,
|
||||
});
|
||||
Reference in New Issue
Block a user