feat(electron): app tabs dnd (#7684)

<div class='graphite__hidden'>
          <div>🎥 Video uploaded on Graphite:</div>
            <a href="https://app.graphite.dev/media/video/T2klNLEk0wxLh4NRDzhk/cd84e155-9f2e-4d12-a933-8673eb6bc6cb.mp4">
              <img src="https://app.graphite.dev/api/v1/graphite/video/thumbnail/T2klNLEk0wxLh4NRDzhk/cd84e155-9f2e-4d12-a933-8673eb6bc6cb.mp4">
            </a>
          </div>
<video src="https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/T2klNLEk0wxLh4NRDzhk/cd84e155-9f2e-4d12-a933-8673eb6bc6cb.mp4">Kapture 2024-07-31 at 19.39.30.mp4</video>

fix AF-1149
fix PD-1513
fix PD-1515
This commit is contained in:
pengx17
2024-08-02 02:02:03 +00:00
parent 4719ffadc6
commit bfff10e25e
13 changed files with 567 additions and 188 deletions

View File

@@ -275,32 +275,6 @@ affine-block-hub {
} }
} }
button,
input,
select,
textarea
/* [role='button'] */ {
-webkit-app-region: no-drag;
}
#webpack-dev-server-client-overlay {
-webkit-app-region: no-drag;
}
html[data-active='false'] {
opacity: 0;
transition: opacity 0.2s 0.1s;
}
html[data-active='true']:has([data-blur-background='true']) {
opacity: 1;
transition: opacity 0.2s;
}
html[data-active='false'] * {
-webkit-app-region: no-drag !important;
}
html, html,
body { body {
height: 100%; height: 100%;

View File

@@ -31,23 +31,63 @@ export class AppTabsHeaderService extends Service {
[] []
); );
showContextMenu = async (workbenchId: string, viewIdx: number) => { showContextMenu = apis?.ui.showTabContextMenu;
await apis?.ui.showTabContextMenu(workbenchId, viewIdx);
activateView = apis?.ui.activateView;
closeTab = apis?.ui.closeTab;
onAddTab = apis?.ui.addTab;
onAddDocTab = async (
docId: string,
targetTabId?: string,
edge?: 'left' | 'right'
) => {
await apis?.ui.addTab({
view: {
path: {
pathname: '/' + docId,
},
},
target: targetTabId,
edge,
});
}; };
activateView = async (workbenchId: string, viewIdx: number) => { onAddTagTab = async (
await apis?.ui.activateView(workbenchId, viewIdx); tagId: string,
targetTabId?: string,
edge?: 'left' | 'right'
) => {
await apis?.ui.addTab({
view: {
path: {
pathname: '/tag/' + tagId,
},
},
target: targetTabId,
edge,
});
}; };
closeTab = async (workbenchId: string) => { onAddCollectionTab = async (
await apis?.ui.closeTab(workbenchId); collectionId: string,
targetTabId?: string,
edge?: 'left' | 'right'
) => {
await apis?.ui.addTab({
view: {
path: {
pathname: '/collection/' + collectionId,
},
},
target: targetTabId,
edge,
});
}; };
onAddTab = async () => { onToggleRightSidebar = apis?.ui.toggleRightSidebar;
await apis?.ui.addTab();
};
onToggleRightSidebar = async () => { moveTab = apis?.ui.moveTab;
await apis?.ui.toggleRightSidebar();
};
} }

View File

@@ -1,4 +1,11 @@
import { IconButton, Loading } from '@affine/component'; import {
type DropTargetDropEvent,
type DropTargetOptions,
IconButton,
Loading,
useDraggable,
useDropTarget,
} from '@affine/component';
import { import {
appSidebarFloatingAtom, appSidebarFloatingAtom,
appSidebarOpenAtom, appSidebarOpenAtom,
@@ -7,6 +14,7 @@ import {
import { appSidebarWidthAtom } from '@affine/core/components/app-sidebar/index.jotai'; import { appSidebarWidthAtom } from '@affine/core/components/app-sidebar/index.jotai';
import { WindowsAppControls } from '@affine/core/components/pure/header/windows-app-controls'; import { WindowsAppControls } from '@affine/core/components/pure/header/windows-app-controls';
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks'; import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
import type { AffineDNDData } from '@affine/core/types/dnd';
import { apis, events } from '@affine/electron-api'; import { apis, events } from '@affine/electron-api';
import { CloseIcon, PlusIcon, RightSidebarIcon } from '@blocksuite/icons/rc'; import { CloseIcon, PlusIcon, RightSidebarIcon } from '@blocksuite/icons/rc';
import { import {
@@ -33,27 +41,53 @@ import {
} from '../services/app-tabs-header-service'; } from '../services/app-tabs-header-service';
import * as styles from './styles.css'; import * as styles from './styles.css';
const TabSupportType = ['collection', 'tag', 'doc'];
const tabCanDrop =
(tab?: TabStatus): NonNullable<DropTargetOptions<AffineDNDData>['canDrop']> =>
ctx => {
if (
ctx.source.data.from?.at === 'app-header:tabs' &&
ctx.source.data.from.tabId !== tab?.id
) {
return true;
}
if (
ctx.source.data.entity?.type &&
TabSupportType.includes(ctx.source.data.entity?.type)
) {
return true;
}
return false;
};
const WorkbenchTab = ({ const WorkbenchTab = ({
workbench, workbench,
active: tabActive, active: tabActive,
tabsLength, tabsLength,
dnd,
onDrop,
}: { }: {
workbench: TabStatus; workbench: TabStatus;
active: boolean; active: boolean;
tabsLength: number; tabsLength: number;
dnd?: boolean;
onDrop?: (data: DropTargetDropEvent<AffineDNDData>) => void;
}) => { }) => {
useServiceOptional(DesktopStateSynchronizer); useServiceOptional(DesktopStateSynchronizer);
const tabsHeaderService = useService(AppTabsHeaderService); const tabsHeaderService = useService(AppTabsHeaderService);
const activeViewIndex = workbench.activeViewIndex ?? 0; const activeViewIndex = workbench.activeViewIndex ?? 0;
const onContextMenu = useAsyncCallback( const onContextMenu = useAsyncCallback(
async (viewIdx: number) => { async (viewIdx: number) => {
await tabsHeaderService.showContextMenu(workbench.id, viewIdx); await tabsHeaderService.showContextMenu?.(workbench.id, viewIdx);
}, },
[tabsHeaderService, workbench.id] [tabsHeaderService, workbench.id]
); );
const onActivateView = useAsyncCallback( const onActivateView = useAsyncCallback(
async (viewIdx: number) => { async (viewIdx: number) => {
await tabsHeaderService.activateView(workbench.id, viewIdx); await tabsHeaderService.activateView?.(workbench.id, viewIdx);
}, },
[tabsHeaderService, workbench.id] [tabsHeaderService, workbench.id]
); );
@@ -61,12 +95,46 @@ const WorkbenchTab = ({
async e => { async e => {
e.stopPropagation(); e.stopPropagation();
await tabsHeaderService.closeTab(workbench.id); await tabsHeaderService.closeTab?.(workbench.id);
}, },
[tabsHeaderService, workbench.id] [tabsHeaderService, workbench.id]
); );
const { dropTargetRef, closestEdge } = useDropTarget<AffineDNDData>(
() => ({
closestEdge: {
allowedEdges: ['left', 'right'],
},
onDrop,
dropEffect: 'move',
canDrop: tabCanDrop(workbench),
isSticky: true,
}),
[onDrop, workbench]
);
const { dragRef } = useDraggable<AffineDNDData>(
() => ({
canDrag: dnd,
data: {
from: {
at: 'app-header:tabs',
tabId: workbench.id,
},
},
dragPreviewPosition: 'pointer-outside',
}),
[dnd, workbench.id]
);
return ( return (
<div
className={styles.tabWrapper}
ref={node => {
dropTargetRef.current = node;
dragRef.current = node;
}}
>
<div <div
key={workbench.id} key={workbench.id}
data-testid="workbench-tab" data-testid="workbench-tab"
@@ -110,8 +178,9 @@ const WorkbenchTab = ({
</Fragment> </Fragment>
); );
})} })}
{!workbench.pinned ? (
<div className={styles.tabCloseButtonWrapper}> <div className={styles.tabCloseButtonWrapper}>
{!workbench.pinned && tabsLength > 1 ? ( {tabsLength > 1 ? (
<button <button
data-testid="close-tab-button" data-testid="close-tab-button"
className={styles.tabCloseButton} className={styles.tabCloseButton}
@@ -121,6 +190,9 @@ const WorkbenchTab = ({
</button> </button>
) : null} ) : null}
</div> </div>
) : null}
</div>
<div className={styles.dropIndicator} data-edge={closestEdge} />
</div> </div>
); );
}; };
@@ -164,11 +236,11 @@ export const AppTabsHeader = ({
const [pinned, unpinned] = partition(tabs, tab => tab.pinned); const [pinned, unpinned] = partition(tabs, tab => tab.pinned);
const onAddTab = useAsyncCallback(async () => { const onAddTab = useAsyncCallback(async () => {
await tabsHeaderService.onAddTab(); await tabsHeaderService.onAddTab?.();
}, [tabsHeaderService]); }, [tabsHeaderService]);
const onToggleRightSidebar = useAsyncCallback(async () => { const onToggleRightSidebar = useAsyncCallback(async () => {
await tabsHeaderService.onToggleRightSidebar(); await tabsHeaderService.onToggleRightSidebar?.();
}, [tabsHeaderService]); }, [tabsHeaderService]);
useEffect(() => { useEffect(() => {
@@ -177,6 +249,63 @@ export const AppTabsHeader = ({
} }
}, [mode]); }, [mode]);
const onDrop = useAsyncCallback(
async (data: DropTargetDropEvent<AffineDNDData>, targetId?: string) => {
const edge = data.closestEdge ?? 'right';
targetId = targetId ?? tabs.at(-1)?.id;
if (!targetId || edge === 'top' || edge === 'bottom') {
return;
}
if (data.source.data.from?.at === 'app-header:tabs') {
if (targetId === data.source.data.from.tabId) {
return;
}
return await tabsHeaderService.moveTab?.(
data.source.data.from.tabId,
targetId,
edge
);
}
if (data.source.data.entity?.type === 'doc') {
return await tabsHeaderService.onAddDocTab?.(
data.source.data.entity.id,
targetId,
edge
);
}
if (data.source.data.entity?.type === 'tag') {
return await tabsHeaderService.onAddTagTab?.(
data.source.data.entity.id,
targetId,
edge
);
}
if (data.source.data.entity?.type === 'collection') {
return await tabsHeaderService.onAddCollectionTab?.(
data.source.data.entity.id,
targetId,
edge
);
}
},
[tabs, tabsHeaderService]
);
const { dropTargetRef: spacerDropTargetRef, draggedOver } =
useDropTarget<AffineDNDData>(
() => ({
onDrop,
dropEffect: 'move',
canDrop: tabCanDrop(),
}),
[onDrop]
);
return ( return (
<div <div
className={clsx(styles.root, className)} className={clsx(styles.root, className)}
@@ -203,9 +332,11 @@ export const AppTabsHeader = ({
{pinned.map(tab => { {pinned.map(tab => {
return ( return (
<WorkbenchTab <WorkbenchTab
dnd={mode === 'app'}
tabsLength={pinned.length} tabsLength={pinned.length}
key={tab.id} key={tab.id}
workbench={tab} workbench={tab}
onDrop={data => onDrop(data, tab.id)}
active={tab.active} active={tab.active}
/> />
); );
@@ -213,21 +344,28 @@ export const AppTabsHeader = ({
{pinned.length > 0 && unpinned.length > 0 && ( {pinned.length > 0 && unpinned.length > 0 && (
<div className={styles.pinSeparator} /> <div className={styles.pinSeparator} />
)} )}
{unpinned.map(workbench => { {unpinned.map(tab => {
return ( return (
<WorkbenchTab <WorkbenchTab
dnd={mode === 'app'}
tabsLength={tabs.length} tabsLength={tabs.length}
key={workbench.id} key={tab.id}
workbench={workbench} workbench={tab}
active={workbench.active} onDrop={data => onDrop(data, tab.id)}
active={tab.active}
/> />
); );
})} })}
</div>
<div
className={styles.spacer}
ref={spacerDropTargetRef}
data-dragged-over={draggedOver}
>
<IconButton onClick={onAddTab} data-testid="add-tab-view-button"> <IconButton onClick={onAddTab} data-testid="add-tab-view-button">
<PlusIcon /> <PlusIcon />
</IconButton> </IconButton>
</div> </div>
<div className={styles.spacer} />
<IconButton size="large" onClick={onToggleRightSidebar}> <IconButton size="large" onClick={onToggleRightSidebar}>
<RightSidebarIcon /> <RightSidebarIcon />
</IconButton> </IconButton>

View File

@@ -37,7 +37,6 @@ export const tabs = style({
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
padding: '0 8px', padding: '0 8px',
gap: '8px',
overflow: 'clip', overflow: 'clip',
height: '100%', height: '100%',
selectors: { selectors: {
@@ -52,6 +51,7 @@ export const pinSeparator = style({
width: 1, width: 1,
height: 16, height: 16,
flexShrink: 0, flexShrink: 0,
marginRight: 8,
}); });
export const splitViewSeparator = style({ export const splitViewSeparator = style({
@@ -61,6 +61,16 @@ export const splitViewSeparator = style({
flexShrink: 0, flexShrink: 0,
}); });
export const tabWrapper = style({
display: 'flex',
alignItems: 'center',
height: '100%',
overflow: 'clip',
position: 'relative',
padding: '0 6px',
margin: '0 -6px',
});
export const tab = style({ export const tab = style({
height: 32, height: 32,
minWidth: 32, minWidth: 32,
@@ -75,6 +85,9 @@ export const tab = style({
position: 'relative', position: 'relative',
['WebkitAppRegion' as string]: 'no-drag', ['WebkitAppRegion' as string]: 'no-drag',
selectors: { selectors: {
[`${tabWrapper} &`]: {
marginRight: 8,
},
'&[data-active="true"]': { '&[data-active="true"]': {
background: cssVar('backgroundPrimaryColor'), background: cssVar('backgroundPrimaryColor'),
boxShadow: cssVar('shadow1'), boxShadow: cssVar('shadow1'),
@@ -85,6 +98,9 @@ export const tab = style({
'&[data-pinned="true"]': { '&[data-pinned="true"]': {
flexShrink: 0, flexShrink: 0,
}, },
[`${tabWrapper}[data-dragging="true"] &`]: {
boxShadow: `0 0 0 1px ${cssVar('primaryColor')}`,
},
}, },
}); });
@@ -192,4 +208,43 @@ export const tabCloseButton = style([
export const spacer = style({ export const spacer = style({
flexGrow: 1, flexGrow: 1,
height: '100%',
display: 'flex',
alignItems: 'center',
marginLeft: -8,
position: 'relative',
selectors: {
'&[data-dragged-over=true]:after': {
content: '""',
position: 'absolute',
top: 10,
height: 32,
left: -13,
right: 0,
width: 2,
borderRadius: 2,
background: cssVar('primaryColor'),
},
},
});
export const dropIndicator = style({
position: 'absolute',
height: 32,
top: 10,
width: 2,
borderRadius: 2,
opacity: 0,
background: cssVar('primaryColor'),
selectors: {
'&[data-edge="left"]': {
opacity: 1,
transform: 'translateX(-5px)',
},
'&[data-edge="right"]': {
right: 0,
opacity: 1,
transform: 'translateX(-9px)',
},
},
}); });

View File

@@ -40,6 +40,7 @@ export const WorkbenchLink = forwardRef<
await apis?.ui.addTab({ await apis?.ui.addTab({
basename, basename,
view: { path }, view: { path },
show: false,
}); });
} }
} else if (!environment.isDesktop) { } else if (!environment.isDesktop) {

View File

@@ -58,6 +58,10 @@ export interface AffineDNDData extends DNDData {
} }
| { | {
at: 'explorer:tags:docs'; at: 'explorer:tags:docs';
}
| {
at: 'app-header:tabs';
tabId: string;
}; };
}; };
dropTarget: dropTarget:
@@ -85,5 +89,8 @@ export interface AffineDNDData extends DNDData {
| { | {
at: 'explorer:tag'; at: 'explorer:tag';
} }
| {
at: 'app-header:tabs';
}
| Record<string, unknown>; | Record<string, unknown>;
} }

View File

@@ -0,0 +1,25 @@
button,
input,
select,
textarea
/* [role='button'] */ {
-webkit-app-region: no-drag;
}
#webpack-dev-server-client-overlay {
-webkit-app-region: no-drag;
}
html:is([data-active='false'], [data-dragging='true']) * {
-webkit-app-region: no-drag !important;
}
html[data-active='false'] {
opacity: 0;
transition: opacity 0.2s 0.1s;
}
html[data-active='true']:has([data-blur-background='true']) {
opacity: 1;
transition: opacity 0.2s;
}

View File

@@ -1,5 +1,6 @@
import './polyfill/dispose'; import './polyfill/dispose';
import '@affine/core/bootstrap/preload'; import '@affine/core/bootstrap/preload';
import './global.css';
import { appConfigProxy } from '@affine/core/hooks/use-app-config-storage'; import { appConfigProxy } from '@affine/core/hooks/use-app-config-storage';
import { performanceLogger } from '@affine/core/shared'; import { performanceLogger } from '@affine/core/shared';
@@ -96,6 +97,12 @@ function main() {
}, 50); }, 50);
window.addEventListener('resize', handleResize); window.addEventListener('resize', handleResize);
performanceMainLogger.info('setup done'); performanceMainLogger.info('setup done');
window.addEventListener('dragstart', () => {
document.documentElement.dataset.dragging = 'true';
});
window.addEventListener('dragend', () => {
document.documentElement.dataset.dragging = 'false';
});
} }
mountApp(); mountApp();

View File

@@ -2,6 +2,7 @@ import 'setimmediate';
import '@affine/component/theme/global.css'; import '@affine/component/theme/global.css';
import '@affine/component/theme/theme.css'; import '@affine/component/theme/theme.css';
import '@affine/core/bootstrap/preload'; import '@affine/core/bootstrap/preload';
import '../global.css';
import { ThemeProvider } from '@affine/component/theme-provider'; import { ThemeProvider } from '@affine/component/theme-provider';
import { configureAppTabsHeaderModule } from '@affine/core/modules/app-tabs-header'; import { configureAppTabsHeaderModule } from '@affine/core/modules/app-tabs-header';

View File

@@ -18,6 +18,7 @@ import {
initAndShowMainWindow, initAndShowMainWindow,
isActiveTab, isActiveTab,
launchStage, launchStage,
moveTab,
pingAppLayoutReady, pingAppLayoutReady,
showDevTools, showDevTools,
showTab, showTab,
@@ -193,6 +194,9 @@ export const uiHandlers = {
activateView: async (_, ...args: Parameters<typeof activateView>) => { activateView: async (_, ...args: Parameters<typeof activateView>) => {
await activateView(...args); await activateView(...args);
}, },
moveTab: async (_, ...args: Parameters<typeof moveTab>) => {
moveTab(...args);
},
toggleRightSidebar: async (_, tabId?: string) => { toggleRightSidebar: async (_, tabId?: string) => {
tabId ??= getTabViewsMeta().activeWorkbenchId; tabId ??= getTabViewsMeta().activeWorkbenchId;
if (tabId) { if (tabId) {

View File

@@ -115,8 +115,14 @@ type TabAction =
| OpenInSplitViewAction; | OpenInSplitViewAction;
type AddTabOption = { type AddTabOption = {
basename: string; basename?: string;
view?: Omit<WorkbenchViewMeta, 'id'> | Array<Omit<WorkbenchViewMeta, 'id'>>; view?: Omit<WorkbenchViewMeta, 'id'> | Array<Omit<WorkbenchViewMeta, 'id'>>;
target?: string;
edge?: 'left' | 'right';
/**
* Whether to show the tab after adding.
*/
show?: boolean;
}; };
export class WebContentViewsManager { export class WebContentViewsManager {
@@ -405,27 +411,32 @@ export class WebContentViewsManager {
id: nanoid(), id: nanoid(),
}; };
}); });
const targetItem =
workbenches.find(w => w.id === option.target) ?? workbenches.at(-1);
const newIndex =
(targetItem ? workbenches.indexOf(targetItem) : workbenches.length) +
(option.edge === 'left' ? 0 : 1);
const workbench: WorkbenchMeta = { const workbench: WorkbenchMeta = {
basename: option.basename, basename: option.basename ?? this.activeWorkbenchMeta?.basename ?? '/',
activeViewIndex: 0, activeViewIndex: 0,
views: views, views: views,
id: newKey, id: newKey,
pinned: false, pinned: targetItem?.pinned ?? false,
}; };
this.patchTabViewsMeta({ this.patchTabViewsMeta({
activeWorkbenchId: newKey, workbenches: workbenches.toSpliced(newIndex, 0, workbench),
workbenches: [...workbenches, workbench], activeWorkbenchId: this.activeWorkbenchId ?? newKey,
}); });
await this.showTab(newKey); await (option.show !== false ? this.showTab(newKey) : this.loadTab(newKey));
this.tabAction$.next({ this.tabAction$.next({
type: 'add-tab', type: 'add-tab',
payload: workbench, payload: workbench,
}); });
return { return workbench;
...option,
key: newKey,
};
}; };
loadTab = async (id: string): Promise<WebContentsView | undefined> => { loadTab = async (id: string): Promise<WebContentsView | undefined> => {
@@ -521,6 +532,42 @@ export class WebContentViewsManager {
await this.showTab(tabId); await this.showTab(tabId);
}; };
moveTab = (from: string, to: string, edge?: 'left' | 'right') => {
const workbenches = this.tabViewsMeta.workbenches;
let fromItem = workbenches.find(w => w.id === from);
const toItem = workbenches.find(w => w.id === to);
if (!fromItem || !toItem) {
return;
}
const fromIndex = workbenches.indexOf(fromItem);
fromItem = {
...fromItem,
pinned: toItem.pinned,
};
let workbenchesAfterMove = workbenches.toSpliced(fromIndex, 1);
const toIndex = workbenchesAfterMove.indexOf(toItem);
if (edge === 'left') {
workbenchesAfterMove = workbenchesAfterMove.toSpliced(
toIndex,
0,
fromItem
);
} else {
workbenchesAfterMove = workbenchesAfterMove.toSpliced(
toIndex + 1,
0,
fromItem
);
}
this.patchTabViewsMeta({
workbenches: workbenchesAfterMove,
});
};
separateView = (tabId: string, viewIndex: number) => { separateView = (tabId: string, viewIndex: number) => {
const tabMeta = this.tabViewsMeta.workbenches.find(w => w.id === tabId); const tabMeta = this.tabViewsMeta.workbenches.find(w => w.id === tabId);
if (!tabMeta) { if (!tabMeta) {
@@ -906,6 +953,7 @@ export const showTab = WebContentViewsManager.instance.showTab;
export const closeTab = WebContentViewsManager.instance.closeTab; export const closeTab = WebContentViewsManager.instance.closeTab;
export const undoCloseTab = WebContentViewsManager.instance.undoCloseTab; export const undoCloseTab = WebContentViewsManager.instance.undoCloseTab;
export const activateView = WebContentViewsManager.instance.activateView; export const activateView = WebContentViewsManager.instance.activateView;
export const moveTab = WebContentViewsManager.instance.moveTab;
export const reloadView = async () => { export const reloadView = async () => {
const id = WebContentViewsManager.instance.activeWorkbenchId; const id = WebContentViewsManager.instance.activeWorkbenchId;

View File

@@ -2,18 +2,65 @@ import { test } from '@affine-test/kit/electron';
import { import {
clickNewPageButton, clickNewPageButton,
createLinkedPage, createLinkedPage,
dragTo,
} from '@affine-test/kit/utils/page-logic'; } from '@affine-test/kit/utils/page-logic';
import { clickSideBarAllPageButton } from '@affine-test/kit/utils/sidebar';
import { expect, type Page } from '@playwright/test'; import { expect, type Page } from '@playwright/test';
async function expectActiveTab(page: Page, index: number, activeViewIndex = 0) {
await expect(
page
.getByTestId('workbench-tab')
.nth(index)
.getByTestId('split-view-label')
.nth(activeViewIndex)
).toHaveAttribute('data-active', 'true');
}
async function expectTabTitle(
page: Page,
index: number,
title: string | string[]
) {
if (typeof title === 'string') {
await expect(page.getByTestId('workbench-tab').nth(index)).toContainText(
title
);
} else {
for (let i = 0; i < title.length; i++) {
await expect(
page
.getByTestId('workbench-tab')
.nth(index)
.getByTestId('split-view-label')
.nth(i)
).toContainText(title[i]);
}
}
}
async function expectTabCount(page: Page, count: number) {
await expect(page.getByTestId('workbench-tab')).toHaveCount(count);
}
async function closeTab(page: Page, index: number) {
await page.getByTestId('workbench-tab').nth(index).hover();
await page
.getByTestId('workbench-tab')
.nth(index)
.getByTestId('close-tab-button')
.click();
}
test('create new tab', async ({ views }) => { test('create new tab', async ({ views }) => {
let page = await views.getActive(); let page = await views.getActive();
await page.getByTestId('add-tab-view-button').click(); await page.getByTestId('add-tab-view-button').click();
await expect(page.getByTestId('workbench-tab')).toHaveCount(2); await expectTabCount(page, 2);
// new tab title should be All docs // new tab title should be All docs
await expect(page.getByTestId('workbench-tab').nth(1)).toContainText( await expectTabTitle(page, 1, 'All docs');
'All docs' await expectActiveTab(page, 1);
);
page = await views.getActive(); page = await views.getActive();
// page content should be at all docs page // page content should be at all docs page
await expect(page.getByTestId('virtualized-page-list')).toContainText( await expect(page.getByTestId('virtualized-page-list')).toContainText(
@@ -24,58 +71,21 @@ test('create new tab', async ({ views }) => {
test('can switch & close tab by clicking', async ({ page }) => { test('can switch & close tab by clicking', async ({ page }) => {
await page.getByTestId('add-tab-view-button').click(); await page.getByTestId('add-tab-view-button').click();
await expect(page.getByTestId('workbench-tab').nth(1)).toHaveAttribute( await expectActiveTab(page, 1);
'data-active',
'true'
);
// switch to the previous tab by clicking on it // switch to the previous tab by clicking on it
await page.getByTestId('workbench-tab').nth(0).click(); await page.getByTestId('workbench-tab').nth(0).click();
await expect(page.getByTestId('workbench-tab').nth(0)).toHaveAttribute( await expectActiveTab(page, 0);
'data-active',
'true'
);
// switch to the next tab by clicking on it // switch to the next tab by clicking on it
await page.getByTestId('workbench-tab').nth(1).click(); await page.getByTestId('workbench-tab').nth(1).click();
await expect(page.getByTestId('workbench-tab').nth(1)).toHaveAttribute( await expectActiveTab(page, 1);
'data-active',
'true'
);
// close the current tab // close the current tab
await page await closeTab(page, 1);
.getByTestId('workbench-tab')
.nth(1)
.getByTestId('close-tab-button')
.click();
// the first tab should be active // the first tab should be active
await expect(page.getByTestId('workbench-tab').nth(0)).toHaveAttribute( await expectActiveTab(page, 0);
'data-active',
'true'
);
});
test('can switch tab by CTRL+number', async ({ page }) => {
test.fixme(); // the shortcut can be only captured by the main process
await page.keyboard.down('ControlOrMeta+T');
await expect(page.getByTestId('workbench-tab')).toHaveCount(2);
await expect(page.getByTestId('workbench-tab').nth(1)).toHaveAttribute(
'data-active',
'true'
);
// switch to the previous tab by pressing CTRL+1
await page.locator('body').press('ControlOrMeta+1');
await expect(page.getByTestId('workbench-tab').nth(0)).toHaveAttribute(
'data-active',
'true'
);
// switch to the next tab by pressing CTRL+2
await page.locator('body').press('ControlOrMeta+2');
await expect(page.getByTestId('workbench-tab').nth(1)).toHaveAttribute(
'data-active',
'true'
);
}); });
test('Collapse Sidebar', async ({ page }) => { test('Collapse Sidebar', async ({ page }) => {
@@ -100,17 +110,15 @@ test('Expand Sidebar', async ({ page }) => {
}); });
test('tab title will change when navigating', async ({ page }) => { test('tab title will change when navigating', async ({ page }) => {
await expect(page.getByTestId('workbench-tab')).toContainText( await expectTabTitle(page, 0, 'Write, Draw, Plan all at Once');
'Write, Draw, Plan all at Once'
);
// create new page // create new page
await clickNewPageButton(page); await clickNewPageButton(page);
await expect(page.getByTestId('workbench-tab')).toContainText('Untitled'); await expectTabTitle(page, 0, 'Untitled');
// go to all page // go to all page
await page.getByTestId('all-pages').click(); await page.getByTestId('all-pages').click();
await expect(page.getByTestId('workbench-tab')).toContainText('All docs'); await expectTabTitle(page, 0, 'All docs');
// go to today's journal // go to today's journal
await page.getByTestId('slider-bar-journals-button').click(); await page.getByTestId('slider-bar-journals-button').click();
@@ -120,7 +128,7 @@ test('tab title will change when navigating', async ({ page }) => {
.textContent(); .textContent();
if (dateString) { if (dateString) {
await expect(page.getByTestId('workbench-tab')).toContainText(dateString); await expectTabTitle(page, 0, dateString);
} }
}); });
@@ -149,6 +157,23 @@ async function enableSplitView(page: Page) {
await page.reload(); await page.reload();
} }
test('open new tab via cmd+click page link', async ({ page }) => {
await enableSplitView(page);
await clickNewPageButton(page);
await page.waitForTimeout(500);
await page.keyboard.press('Enter');
await createLinkedPage(page, 'hi from another page');
await page
.locator('.affine-reference-title:has-text("hi from another page")')
.click({
modifiers: ['ControlOrMeta'],
});
await expectTabCount(page, 2);
await expectTabTitle(page, 0, 'Untitled');
await expectTabTitle(page, 1, 'hi from another page');
await expectActiveTab(page, 0);
});
test('open split view', async ({ page }) => { test('open split view', async ({ page }) => {
await enableSplitView(page); await enableSplitView(page);
await clickNewPageButton(page); await clickNewPageButton(page);
@@ -164,27 +189,62 @@ test('open split view', async ({ page }) => {
// check tab title // check tab title
await expect(page.getByTestId('split-view-label')).toHaveCount(2); await expect(page.getByTestId('split-view-label')).toHaveCount(2);
await expect(page.getByTestId('split-view-label').nth(0)).toContainText( await expectTabTitle(page, 0, ['Untitled', 'hi from another page']);
'Untitled'
);
await expect(page.getByTestId('split-view-label').nth(1)).toContainText(
'hi from another page'
);
// the second split view should be active // the second split view should be active
await expect(page.getByTestId('split-view-label').nth(1)).toHaveAttribute( await expectActiveTab(page, 0, 1);
'data-active',
'true'
);
// by clicking the first split view label, the first split view should be active // by clicking the first split view label, the first split view should be active
await page.getByTestId('split-view-label').nth(0).click(); await page.getByTestId('split-view-label').nth(0).click();
await expect(page.getByTestId('split-view-label').nth(0)).toHaveAttribute( await expectActiveTab(page, 0, 0);
'data-active',
'true'
);
await expect(page.getByTestId('split-view-indicator').nth(0)).toHaveAttribute( await expect(page.getByTestId('split-view-indicator').nth(0)).toHaveAttribute(
'data-active', 'data-active',
'true' 'true'
); );
}); });
test('drag a page from "All pages" list to tabs header', async ({ page }) => {
const title = 'this is a new page to drag';
await clickNewPageButton(page, title);
await clickSideBarAllPageButton(page);
await dragTo(
page,
page.locator(`[data-testid="page-list-item"]:has-text("${title}")`),
page.getByTestId('add-tab-view-button')
);
await expectTabCount(page, 2);
await expectTabTitle(page, 1, title);
await expectActiveTab(page, 1);
});
test('reorder tabs', async ({ page }) => {
await clickNewPageButton(page);
await page.waitForTimeout(500);
await page.keyboard.press('Enter');
const titles = ['aaa', 'bbb'];
await createLinkedPage(page, titles[0]);
await createLinkedPage(page, titles[1]);
await page.locator(`.affine-reference-title:has-text("${titles[0]}")`).click({
modifiers: ['ControlOrMeta', 'Alt'],
});
await page.locator(`.affine-reference-title:has-text("${titles[1]}")`).click({
modifiers: ['ControlOrMeta', 'Alt'],
});
await expectTabTitle(page, 0, 'Untitled');
await expectTabTitle(page, 1, titles[0]);
await expectTabTitle(page, 2, titles[1]);
await dragTo(
page,
page.getByTestId('workbench-tab').nth(0),
page.getByTestId('workbench-tab').nth(1),
'right'
);
await expectTabTitle(page, 0, titles[0]);
await expectTabTitle(page, 1, 'Untitled');
await expectTabTitle(page, 2, titles[1]);
});

View File

@@ -14,8 +14,8 @@ export async function waitForAllPagesLoad(page: Page) {
}); });
} }
export async function clickNewPageButton(page: Page) { export async function clickNewPageButton(page: Page, title?: string) {
//FiXME: when the page is in edgeless mode, clickNewPageButton will create a new edgeless page // FiXME: when the page is in edgeless mode, clickNewPageButton will create a new edgeless page
const edgelessPage = page.locator('edgeless-editor'); const edgelessPage = page.locator('edgeless-editor');
if (await edgelessPage.isVisible()) { if (await edgelessPage.isVisible()) {
await page.getByTestId('switch-page-mode-button').click({ await page.getByTestId('switch-page-mode-button').click({
@@ -27,6 +27,9 @@ export async function clickNewPageButton(page: Page) {
delay: 100, delay: 100,
}); });
await waitForEmptyEditor(page); await waitForEmptyEditor(page);
if (title) {
await getBlockSuiteEditorTitle(page).fill(title);
}
} }
export async function waitForEmptyEditor(page: Page) { export async function waitForEmptyEditor(page: Page) {
@@ -75,7 +78,13 @@ export const dragTo = async (
page: Page, page: Page,
locator: Locator, locator: Locator,
target: Locator, target: Locator,
location: 'top-left' | 'top' | 'bottom' | 'center' = 'center' location:
| 'top-left'
| 'top'
| 'bottom'
| 'center'
| 'left'
| 'right' = 'center'
) => { ) => {
await locator.hover(); await locator.hover();
await page.mouse.down(); await page.mouse.down();
@@ -85,19 +94,29 @@ export const dragTo = async (
if (!targetElement) { if (!targetElement) {
throw new Error('target element not found'); throw new Error('target element not found');
} }
const position = const position = (() => {
location === 'center' switch (location) {
? { case 'center':
return {
x: targetElement.width / 2, x: targetElement.width / 2,
y: targetElement.height / 2, y: targetElement.height / 2,
};
case 'top':
return { x: targetElement.width / 2, y: 1 };
case 'bottom':
return { x: targetElement.width / 2, y: targetElement.height - 1 };
case 'left':
return { x: 1, y: targetElement.height / 2 };
case 'right':
return { x: targetElement.width - 1, y: targetElement.height / 2 };
case 'top-left':
default:
return { x: 1, y: 1 };
} }
: location === 'top-left' })();
? { x: 1, y: 1 }
: location === 'top'
? { x: targetElement.width / 2, y: 1 }
: location === 'bottom'
? { x: targetElement.width / 2, y: targetElement.height - 1 }
: { x: 1, y: 1 };
await target.hover({ await target.hover({
position: position, position: position,
}); });