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

View File

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

View File

@@ -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

View File

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

View File

@@ -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(),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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>

View File

@@ -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({

View File

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

View File

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

View File

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

View File

@@ -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

View File

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

View File

@@ -49,8 +49,8 @@ export const WorkbenchRoot = memo(() => {
);
useEffect(() => {
workbench.basename$.next(basename);
}, [basename, workbench.basename$]);
workbench.updateBasename(basename);
}, [basename, workbench]);
return (
<ViewIslandRegistryProvider>

View File

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